diff --git a/Cargo.lock b/Cargo.lock index 9bce3f0..034f52b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,21 +2,47 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -24,6 +50,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "const-random", "getrandom 0.3.4", "once_cell", "version_check", @@ -117,20 +144,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] -name = "ar_archive_writer" -version = "0.5.1" +name = "arc-swap" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" dependencies = [ - "object 0.37.3", + "rustversion", ] -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - [[package]] name = "arrayref" version = "0.3.9" @@ -149,6 +170,45 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-compat" version = "0.2.5" @@ -205,7 +265,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ - "base64 0.22.1", + "base64", "http", "log", "url", @@ -239,6 +299,58 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backon" version = "1.6.0" @@ -250,18 +362,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base32" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" @@ -274,6 +386,17 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2 0.10.9", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -294,6 +417,9 @@ name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] [[package]] name = "blake3" @@ -327,6 +453,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -336,14 +471,30 @@ dependencies = [ "objc2", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" -dependencies = [ - "allocator-api2", -] [[package]] name = "byteorder" @@ -378,6 +529,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.56" @@ -408,6 +568,17 @@ 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" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "choice" version = "0.0.2" @@ -421,8 +592,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -432,21 +605,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" -[[package]] -name = "ci-relay" -version = "0.1.0" -dependencies = [ - "clap", - "hex", - "hmac", - "iroh", - "serde_json", - "sha2 0.10.9", - "swactor-ci", - "tiny_http", - "tokio", -] - [[package]] name = "ciborium" version = "0.2.2" @@ -474,6 +632,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clap" version = "4.5.58" @@ -574,6 +742,26 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -625,15 +813,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpp_demangle" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" -dependencies = [ - "cfg-if", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -643,113 +822,6 @@ dependencies = [ "libc", ] -[[package]] -name = "cranelift-bforest" -version = "0.116.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e15d04a0ce86cb36ead88ad68cf693ffd6cda47052b9e0ac114bc47fd9cd23c4" -dependencies = [ - "cranelift-entity", -] - -[[package]] -name = "cranelift-bitset" -version = "0.116.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c6e3969a7ce267259ce244b7867c5d3bc9e65b0a87e81039588dfdeaede9f34" -dependencies = [ - "serde", - "serde_derive", -] - -[[package]] -name = "cranelift-codegen" -version = "0.116.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c22032c4cb42558371cf516bb47f26cdad1819d3475c133e93c49f50ebf304e" -dependencies = [ - "bumpalo", - "cranelift-bforest", - "cranelift-bitset", - "cranelift-codegen-meta", - "cranelift-codegen-shared", - "cranelift-control", - "cranelift-entity", - "cranelift-isle", - "gimli", - "hashbrown 0.14.5", - "log", - "regalloc2", - "rustc-hash", - "serde", - "smallvec", - "target-lexicon 0.13.5", -] - -[[package]] -name = "cranelift-codegen-meta" -version = "0.116.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c904bc71c61b27fc57827f4a1379f29de64fe95653b620a3db77d59655eee0b8" -dependencies = [ - "cranelift-codegen-shared", -] - -[[package]] -name = "cranelift-codegen-shared" -version = "0.116.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40180f5497572f644ce88c255480981ae2ec1d7bb4d8e0c0136a13b87a2f2ceb" - -[[package]] -name = "cranelift-control" -version = "0.116.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d132c6d0bd8a489563472afc171759da0707804a65ece7ceb15a8c6d7dd5ef" -dependencies = [ - "arbitrary", -] - -[[package]] -name = "cranelift-entity" -version = "0.116.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b2d0d9618275474fbf679dd018ac6e009acbd6ae6850f6a67be33fb3b00b323" -dependencies = [ - "cranelift-bitset", - "serde", - "serde_derive", -] - -[[package]] -name = "cranelift-frontend" -version = "0.116.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fac41e16729107393174b0c9e3730fb072866100e1e64e80a1a963b2e484d57" -dependencies = [ - "cranelift-codegen", - "log", - "smallvec", - "target-lexicon 0.13.5", -] - -[[package]] -name = "cranelift-isle" -version = "0.116.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ca20d576e5070044d0a72a9effc2deacf4d6aa650403189d8ea50126483944d" - -[[package]] -name = "cranelift-native" -version = "0.116.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dee82f3f1f2c4cba9177f1cc5e350fe98764379bcd29340caa7b01f85076c7" -dependencies = [ - "cranelift-codegen", - "libc", - "target-lexicon 0.13.5", -] - [[package]] name = "crc32fast" version = "1.5.0" @@ -875,6 +947,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -882,6 +966,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -894,6 +979,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctrlc" version = "3.5.1" @@ -1019,6 +1113,25 @@ dependencies = [ "syn", ] +[[package]] +name = "dashboard" +version = "0.1.0" +dependencies = [ + "axum", + "clap", + "crossbeam-queue", + "crossterm", + "ctrlc", + "ratatui", + "serde", + "serde_json", + "swactor", + "tokio", + "tokio-stream", + "tracing", + "tracing-subscriber", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -1040,12 +1153,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] -name = "debugid" -version = "0.8.0" +name = "delegate" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" dependencies = [ - "uuid", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1055,6 +1170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", + "pem-rfc7468 0.7.0", "zeroize", ] @@ -1065,10 +1181,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" dependencies = [ "const-oid 0.10.2", - "pem-rfc7468", + "pem-rfc7468 1.0.0", "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.6" @@ -1132,6 +1262,15 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + [[package]] name = "diatomic-waker" version = "0.2.3" @@ -1145,6 +1284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -1160,27 +1300,6 @@ dependencies = [ "crypto-common 0.2.0", ] -[[package]] -name = "directories-next" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" -dependencies = [ - "cfg-if", - "dirs-sys-next", -] - -[[package]] -name = "dirs-sys-next" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" -dependencies = [ - "libc", - "redox_users", - "winapi", -] - [[package]] name = "dispatch2" version = "0.3.0" @@ -1210,11 +1329,12 @@ version = "0.1.0" dependencies = [ "ed25519-dalek 2.2.0", "iroh", + "iroh-relay", "rand_core 0.6.4", "serde", "serde_json", - "shared-types", "swactor", + "swactor-transport", "tokio", ] @@ -1260,6 +1380,20 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki 0.7.3", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -1318,6 +1452,27 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embedded-io" version = "0.4.0" @@ -1378,12 +1533,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - [[package]] name = "fastbloom" version = "0.14.1" @@ -1392,7 +1541,7 @@ checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" dependencies = [ "getrandom 0.3.4", "libm", - "rand", + "rand 0.9.2", "siphasher", ] @@ -1402,6 +1551,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1430,6 +1589,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flurry" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf5efcf77a4da27927d3ab0509dec5b0954bb3bc59da5a1de9e52642ebd4cdf9" +dependencies = [ + "ahash", + "num_cpus", + "parking_lot", + "seize 0.3.3", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1592,28 +1763,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - -[[package]] -name = "fxprof-processed-profile" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27d12c0aed7f1e24276a241aadc4cb8ea9f83000f34bc062b7cc2d51e3b0fabd" -dependencies = [ - "bitflags", - "debugid", - "fxhash", - "serde", - "serde_json", -] - [[package]] name = "generator" version = "0.8.8" @@ -1626,7 +1775,7 @@ dependencies = [ "log", "rustversion", "windows-link", - "windows-result", + "windows-result 0.4.1", ] [[package]] @@ -1637,6 +1786,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1682,14 +1832,13 @@ dependencies = [ ] [[package]] -name = "gimli" -version = "0.31.1" +name = "ghash" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" dependencies = [ - "fallible-iterator", - "indexmap", - "stable_deref_trait", + "opaque-debug", + "polyval", ] [[package]] @@ -1704,6 +1853,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.13" @@ -1748,9 +1908,6 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", -] [[package]] name = "hashbrown" @@ -1761,7 +1918,6 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.1.5", - "serde", ] [[package]] @@ -1802,10 +1958,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] -name = "hex" -version = "0.4.3" +name = "hex-literal" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" [[package]] name = "hickory-proto" @@ -1826,7 +1982,7 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand", + "rand 0.9.2", "ring", "rustls", "thiserror 2.0.18", @@ -1850,7 +2006,7 @@ dependencies = [ "moka", "once_cell", "parking_lot", - "rand", + "rand 0.9.2", "resolv-conf", "rustls", "smallvec", @@ -1860,6 +2016,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1869,6 +2034,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.0" @@ -1985,7 +2159,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-util", @@ -2016,7 +2190,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -2169,7 +2343,7 @@ dependencies = [ "hyper", "hyper-util", "log", - "rand", + "rand 0.9.2", "tokio", "url", "xmltree", @@ -2196,6 +2370,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "instability" version = "0.3.11" @@ -2209,6 +2393,19 @@ dependencies = [ "syn", ] +[[package]] +name = "integration-tests" +version = "0.1.0" +dependencies = [ + "dashboard", + "distribution", + "iroh", + "serde_json", + "swactor", + "swactor-datastore", + "ureq", +] + [[package]] name = "ipconfig" version = "0.3.2" @@ -2270,7 +2467,7 @@ dependencies = [ "pkarr", "pkcs8 0.11.0-rc.11", "portmapper", - "rand", + "rand 0.9.2", "reqwest 0.12.28", "rustc-hash", "rustls", @@ -2316,12 +2513,17 @@ version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c946095f060e6e59b9ff30cc26c75cdb758e7fb0cde8312c89e2144654989fcb" dependencies = [ + "http-body-util", + "hyper", + "hyper-util", "iroh-metrics-derive", "itoa", "n0-error", "postcard", + "reqwest 0.12.28", "ryu", "serde", + "tokio", "tracing", ] @@ -2371,11 +2573,12 @@ dependencies = [ "getrandom 0.3.4", "identity-hash", "lru-slab", - "rand", + "rand 0.9.2", "ring", "rustc-hash", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "slab", "sorted-index-buffer", "thiserror 2.0.18", @@ -2403,9 +2606,12 @@ version = "0.96.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd2b63e654b9dec799a73372cdc79b529ca6c7248c0c8de7da78a02e3a46f03c" dependencies = [ + "ahash", "blake3", "bytes", "cfg_aliases", + "clap", + "dashmap", "data-encoding", "derive_more", "getrandom 0.3.4", @@ -2425,18 +2631,29 @@ dependencies = [ "pin-project", "pkarr", "postcard", - "rand", + "rand 0.9.2", + "rcgen", + "reloadable-state", "reqwest 0.12.28", "rustls", + "rustls-cert-file-reader", + "rustls-cert-reloadable-resolver", "rustls-pki-types", "serde", "serde_bytes", + "serde_json", + "sha1 0.11.0-rc.4", + "simdutf8", "strum 0.27.2", + "time", "tokio", "tokio-rustls", + "tokio-rustls-acme", "tokio-util", "tokio-websockets", + "toml 0.9.12+spec-1.1.0", "tracing", + "tracing-subscriber", "url", "vergen-gitcl", "webpki-roots 1.0.6", @@ -2470,15 +2687,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -2494,26 +2702,6 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" -[[package]] -name = "ittapi" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" -dependencies = [ - "anyhow", - "ittapi-sys", - "log", -] - -[[package]] -name = "ittapi-sys" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" -dependencies = [ - "cc", -] - [[package]] name = "jni" version = "0.21.1" @@ -2561,12 +2749,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "leb128" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" +dependencies = [ + "spin 0.9.8", +] [[package]] name = "leb128fmt" @@ -2586,16 +2771,6 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" -[[package]] -name = "libredox" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" -dependencies = [ - "bitflags", - "libc", -] - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2620,20 +2795,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" -[[package]] -name = "local-runner" -version = "0.1.0" -dependencies = [ - "clap", - "ctrlc", - "iroh", - "runtime-dashboard", - "serde_json", - "swactor", - "swactor-ci", - "tokio", -] - [[package]] name = "lock_api" version = "0.4.14" @@ -2692,15 +2853,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" -[[package]] -name = "mach2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" -dependencies = [ - "libc", -] - [[package]] name = "matchers" version = "0.2.0" @@ -2710,21 +2862,24 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "memfd" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" -dependencies = [ - "rustix 1.1.3", -] - [[package]] name = "memoffset" version = "0.9.1" @@ -2740,6 +2895,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2962,8 +3123,8 @@ dependencies = [ "tokio-util", "tracing", "web-sys", - "windows", - "windows-result", + "windows 0.62.2", + "windows-result 0.4.1", "wmi", ] @@ -2979,12 +3140,40 @@ dependencies = [ "libc", ] +[[package]] +name = "node" +version = "0.1.0" +dependencies = [ + "clap", + "ctrlc", + "dashboard", + "distribution", + "iroh", + "libc", + "serde", + "serde_json", + "swactor", + "swactor-datastore", + "swactor-transport", + "toml 0.8.23", +] + [[package]] name = "nohash-hasher" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "ntimestamp" version = "1.0.0" @@ -3009,12 +3198,59 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3022,6 +3258,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", ] [[package]] @@ -3109,24 +3356,12 @@ dependencies = [ ] [[package]] -name = "object" -version = "0.36.7" +name = "oid-registry" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" dependencies = [ - "crc32fast", - "hashbrown 0.15.5", - "indexmap", - "memchr", -] - -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", + "asn1-rs", ] [[package]] @@ -3151,6 +3386,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.75" @@ -3195,6 +3436,59 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2 0.10.9", +] + +[[package]] +name = "pageant" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "032d6201d2fb765158455ae0d5a510c016bb6da7232e5040e39e9c8db12b0afc" +dependencies = [ + "bytes", + "delegate", + "futures", + "rand 0.8.5", + "thiserror 1.0.69", + "tokio", + "windows 0.58.0", +] + [[package]] name = "papaya" version = "0.2.3" @@ -3202,7 +3496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f92dd0b07c53a0a0c764db2ace8c541dc47320dad97c2200c2a637ab9dd2328f" dependencies = [ "equivalent", - "seize", + "seize 0.5.1", ] [[package]] @@ -3240,6 +3534,35 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -3328,6 +3651,32 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes", + "cbc", + "der 0.7.10", + "pbkdf2", + "scrypt", + "sha2 0.10.9", + "spki 0.7.3", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -3335,6 +3684,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ "der 0.7.10", + "pkcs5", + "rand_core 0.6.4", "spki 0.7.3", ] @@ -3360,7 +3711,7 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ - "base64 0.22.1", + "base64", "indexmap", "quick-xml", "serde", @@ -3395,6 +3746,29 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -3407,7 +3781,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d2a8825353ace3285138da3378b1e21860d60351942f7aa3b99b13b41f80318" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "derive_more", "futures-lite", @@ -3419,7 +3793,7 @@ dependencies = [ "n0-error", "netwatch", "num_enum", - "rand", + "rand 0.9.2", "serde", "smallvec", "socket2 0.6.2", @@ -3490,6 +3864,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -3518,8 +3901,8 @@ dependencies = [ "bit-vec", "bitflags", "num-traits", - "rand", - "rand_chacha", + "rand 0.9.2", + "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -3536,28 +3919,6 @@ dependencies = [ "proptest", ] -[[package]] -name = "psm" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" -dependencies = [ - "ar_archive_writer", - "cc", -] - -[[package]] -name = "pulley-interpreter" -version = "29.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62d95f8575df49a2708398182f49a888cf9dc30210fb1fd2df87c889edcee75d" -dependencies = [ - "cranelift-bitset", - "log", - "sptr", - "wasmtime-math", -] - [[package]] name = "pyo3" version = "0.23.5" @@ -3583,7 +3944,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94f6cbe86ef3bf18998d9df6e0f3fc1050a8c5efa409bf712e661a4366e010fb" dependencies = [ "once_cell", - "target-lexicon 0.12.16", + "target-lexicon", ] [[package]] @@ -3674,7 +4035,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.2", "ring", "rustc-hash", "rustls", @@ -3715,16 +4076,37 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "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", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -3803,6 +4185,20 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3812,31 +4208,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] - -[[package]] -name = "regalloc2" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc06e6b318142614e4a48bc725abbf08ff166694835c43c9dae5a9009704639a" -dependencies = [ - "allocator-api2", - "bumpalo", - "hashbrown 0.15.5", - "log", - "rustc-hash", - "smallvec", -] - [[package]] name = "regex" version = "1.12.3" @@ -3866,13 +4237,30 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +[[package]] +name = "reloadable-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dc20ac1418988b60072d783c9f68e28a173fb63493c127952f6face3b40c6e0" + +[[package]] +name = "reloadable-state" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3853ef78d45b50f8b989896304a85239539d39b7f866a000e8846b9b72d74ce8" +dependencies = [ + "arc-swap", + "reloadable-core", + "tokio", +] + [[package]] name = "reqwest" version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "encoding_rs", "futures-channel", @@ -3920,7 +4308,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-core", "http", @@ -3955,6 +4343,16 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -3970,30 +4368,163 @@ dependencies = [ ] [[package]] -name = "runtime-dashboard" -version = "0.1.0" +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "clap", - "crossbeam-queue", - "crossterm", - "ctrlc", - "distribution", - "iroh", - "ratatui", - "serde", - "serde_json", - "swactor", - "swactor-ci", - "tiny_http", - "tracing", - "tracing-subscriber", + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sha2 0.10.9", + "signature 2.2.0", + "spki 0.7.3", + "subtle", + "zeroize", ] [[package]] -name = "rustc-demangle" -version = "0.1.27" +name = "russh" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +checksum = "c536b90d8e2468d8dedc8de2369383c101325e23fffa3a30de713032862a11d4" +dependencies = [ + "aes", + "aes-gcm", + "async-trait", + "bitflags", + "byteorder", + "cbc", + "chacha20", + "ctr", + "curve25519-dalek 4.1.3", + "des", + "digest 0.10.7", + "elliptic-curve", + "flate2", + "futures", + "generic-array", + "hex-literal", + "hmac", + "log", + "num-bigint", + "once_cell", + "p256", + "p384", + "p521", + "poly1305", + "rand 0.8.5", + "rand_core 0.6.4", + "russh-cryptovec", + "russh-keys", + "russh-sftp", + "russh-util", + "sha1 0.10.6", + "sha2 0.10.9", + "ssh-encoding", + "ssh-key", + "subtle", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "russh-cryptovec" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fadd2c0ab350e21c66556f94ee06f766d8bdae3213857ba7610bfd8e10e51880" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "russh-keys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e3db166c8678c824627c2c46f619ed5ce4ae33f38a35403c62f6ab8f3985867" +dependencies = [ + "aes", + "async-trait", + "bcrypt-pbkdf", + "block-padding", + "byteorder", + "cbc", + "ctr", + "data-encoding", + "der 0.7.10", + "digest 0.10.7", + "ecdsa", + "ed25519-dalek 2.2.0", + "elliptic-curve", + "futures", + "getrandom 0.2.17", + "hmac", + "home", + "inout", + "log", + "md5", + "num-integer", + "p256", + "p384", + "p521", + "pageant", + "pbkdf2", + "pkcs1", + "pkcs5", + "pkcs8 0.10.2", + "rand 0.8.5", + "rand_core 0.6.4", + "rsa", + "russh-cryptovec", + "russh-util", + "sec1", + "serde", + "sha1 0.10.6", + "sha2 0.10.9", + "spki 0.7.3", + "ssh-encoding", + "ssh-key", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "typenum", + "zeroize", +] + +[[package]] +name = "russh-sftp" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb94393cafad0530145b8f626d8687f1ee1dedb93d7ba7740d6ae81868b13b5" +dependencies = [ + "bitflags", + "bytes", + "chrono", + "flurry", + "log", + "serde", + "thiserror 2.0.18", + "tokio", + "tokio-util", +] + +[[package]] +name = "russh-util" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63aeb9d2b74f8f38befdc0c5172d5ffcf58f3d2ffcb423f3b6cdfe2c2d747b80" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] [[package]] name = "rustc-hash" @@ -4010,6 +4541,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "0.38.44" @@ -4052,6 +4592,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-cert-file-reader" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb47c2a50fdfdaf95b0ac8b12620fc327da1fd4adbb30d0c56d866b005873ff" +dependencies = [ + "rustls-cert-read", + "rustls-pki-types", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "rustls-cert-read" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd46e8c5ae4de3345c4786a83f99ec7aff287209b9e26fa883c473aeb28f19d5" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-cert-reloadable-resolver" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe1baa8a3a1f05eaa9fc55aed4342867f70e5c170ea3bfed1b38c51a4857c0c8" +dependencies = [ + "futures-util", + "reloadable-state", + "rustls", + "rustls-cert-read", + "thiserror 2.0.18", +] + [[package]] name = "rustls-native-certs" version = "0.8.3" @@ -4137,6 +4711,15 @@ 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" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -4167,6 +4750,31 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.6.0" @@ -4190,6 +4798,12 @@ dependencies = [ "libc", ] +[[package]] +name = "seize" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "689224d06523904ebcc9b482c6a3f4f7fb396096645c4cd10c0d2ff7371a34d3" + [[package]] name = "seize" version = "0.5.1" @@ -4211,10 +4825,6 @@ name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" -dependencies = [ - "serde", - "serde_core", -] [[package]] name = "send_wrapper" @@ -4275,6 +4885,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -4284,6 +4905,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4309,6 +4939,28 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c777f0a122a53fddb0beb6e706771197000b8eb5c9f42b5b850f450ef48c788" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.11.0-rc.10", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -4346,14 +4998,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shared-types" -version = "0.1.0" -dependencies = [ - "blake3", - "serde", -] - [[package]] name = "shlex" version = "1.3.0" @@ -4397,6 +5041,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest 0.10.7", "rand_core 0.6.4", ] @@ -4438,9 +5083,8 @@ dependencies = [ "serde_json", "simulation", "swactor", - "swactor-ci", "tiny_http", - "toml", + "toml 0.8.23", ] [[package]] @@ -4460,9 +5104,6 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] [[package]] name = "socket2" @@ -4537,10 +5178,55 @@ dependencies = [ ] [[package]] -name = "sptr" -version = "0.3.2" +name = "ssh-cipher" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20", + "cipher", + "ctr", + "poly1305", + "ssh-encoding", + "subtle", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468 0.7.0", + "sha2 0.10.9", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "bcrypt-pbkdf", + "ed25519-dalek 2.2.0", + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha2 0.10.9", + "signature 2.2.0", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] [[package]] name = "stable_deref_trait" @@ -4562,7 +5248,7 @@ dependencies = [ "log", "nohash-hasher", "parking_lot", - "rand", + "rand 0.9.2", "serde", "serde_json", "tiny_http", @@ -4640,74 +5326,66 @@ dependencies = [ "proptest", "proptest-state-machine", "serde", - "swactor-std", "tracing", "web-time 0.2.4", ] -[[package]] -name = "swactor-bin-runner" -version = "0.1.0" -dependencies = [ - "proptest", - "swactor", - "swactor-std", - "wasmtime", - "wat", -] - -[[package]] -name = "swactor-ci" -version = "0.1.0" -dependencies = [ - "hex", - "hmac", - "serde", - "serde_json", - "serde_yaml", - "sha2 0.10.9", - "swactor", - "tiny_http", - "ureq", -] - -[[package]] -name = "swactor-crypto-wasm" -version = "0.1.0" -dependencies = [ - "ed25519-dalek 2.2.0", -] - [[package]] name = "swactor-datastore" version = "0.1.0" dependencies = [ "blake3", "clap", + "crossbeam-queue", "ctrlc", - "distribution", + "ed25519-dalek 2.2.0", "getrandom 0.2.17", + "iroh", "proptest", "proptest-state-machine", - "runtime-dashboard", + "rand_core 0.6.4", "serde", "serde_json", - "shared-types", "stateright", "swactor", - "swactor-std", "tempfile", "tiny_http", - "toml", + "tokio", + "toml 0.8.23", "ureq", ] [[package]] -name = "swactor-std" +name = "swactor-process" version = "0.1.0" dependencies = [ - "getrandom 0.2.17", + "async-trait", + "crossbeam-queue", + "libc", + "proptest", + "proptest-state-machine", + "russh", + "russh-keys", + "serde", + "serde_json", + "serde_yaml", "swactor", + "tokio", +] + +[[package]] +name = "swactor-transport" +version = "0.1.0" +dependencies = [ + "bs58", + "ed25519-dalek 2.2.0", + "iroh", + "iroh-relay", + "rand_core 0.6.4", + "serde", + "serde_json", + "swactor", + "tokio", ] [[package]] @@ -4774,12 +5452,6 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" -[[package]] -name = "target-lexicon" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" - [[package]] name = "tempfile" version = "3.25.0" @@ -4793,15 +5465,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -4885,6 +5548,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tiny_http" version = "0.12.0" @@ -4979,6 +5651,34 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls-acme" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31fcc374ec87d754358a5d0709ed1ab7671d51e0f70ddc3b17a11ac36604cfa" +dependencies = [ + "async-trait", + "base64", + "chrono", + "futures", + "log", + "num-bigint", + "pem", + "proc-macro2", + "rcgen", + "reqwest 0.12.28", + "ring", + "rustls", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-rustls", + "webpki-roots 1.0.6", + "x509-parser", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -5011,14 +5711,14 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1b6348ebfaaecd771cecb69e832961d277f59845d4220a584701f72728152b7" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-core", "futures-sink", "getrandom 0.3.4", "http", "httparse", - "rand", + "rand 0.9.2", "ring", "rustls-pki-types", "simdutf8", @@ -5034,11 +5734,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_edit 0.22.27", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -5065,7 +5780,7 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", "winnow", @@ -5098,6 +5813,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + [[package]] name = "tower" version = "0.5.3" @@ -5111,6 +5832,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -5205,17 +5927,6 @@ dependencies = [ "tracing-log", ] -[[package]] -name = "trait-variant" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "try-lock" version = "0.2.5" @@ -5281,6 +5992,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -5299,7 +6020,7 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" dependencies = [ - "base64 0.22.1", + "base64", "flate2", "log", "once_cell", @@ -5465,15 +6186,6 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasm" -version = "0.1.0" -dependencies = [ - "swactor", - "swactor-std", - "wasm-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -5533,16 +6245,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.221.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc8444fe4920de80a4fe5ab564fff2ae58b6b73166b89751f8c6c93509da32e5" -dependencies = [ - "leb128", - "wasmparser 0.221.3", -] - [[package]] name = "wasm-encoder" version = "0.244.0" @@ -5550,17 +6252,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser 0.244.0", -] - -[[package]] -name = "wasm-encoder" -version = "0.245.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9dca005e69bf015e45577e415b9af8c67e8ee3c0e38b5b0add5aa92581ed5c" -dependencies = [ - "leb128fmt", - "wasmparser 0.245.1", + "wasmparser", ] [[package]] @@ -5571,8 +6263,16 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap", - "wasm-encoder 0.244.0", - "wasmparser 0.244.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-runtime" +version = "0.1.0" +dependencies = [ + "swactor", + "wasm-bindgen", ] [[package]] @@ -5588,19 +6288,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.221.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", - "serde", -] - [[package]] name = "wasmparser" version = "0.244.0" @@ -5613,303 +6300,6 @@ dependencies = [ "semver", ] -[[package]] -name = "wasmparser" -version = "0.245.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e" -dependencies = [ - "bitflags", - "indexmap", - "semver", -] - -[[package]] -name = "wasmprinter" -version = "0.221.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7343c42a97f2926c7819ff81b64012092ae954c5d83ddd30c9fcdefd97d0b283" -dependencies = [ - "anyhow", - "termcolor", - "wasmparser 0.221.3", -] - -[[package]] -name = "wasmtime" -version = "29.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11976a250672556d1c4c04c6d5d7656ac9192ac9edc42a4587d6c21460010e69" -dependencies = [ - "addr2line", - "anyhow", - "async-trait", - "bitflags", - "bumpalo", - "cc", - "cfg-if", - "encoding_rs", - "fxprof-processed-profile", - "gimli", - "hashbrown 0.14.5", - "indexmap", - "ittapi", - "libc", - "log", - "mach2", - "memfd", - "object 0.36.7", - "once_cell", - "paste", - "postcard", - "psm", - "pulley-interpreter", - "rayon", - "rustix 0.38.44", - "semver", - "serde", - "serde_derive", - "serde_json", - "smallvec", - "sptr", - "target-lexicon 0.13.5", - "trait-variant", - "wasm-encoder 0.221.3", - "wasmparser 0.221.3", - "wasmtime-asm-macros", - "wasmtime-cache", - "wasmtime-component-macro", - "wasmtime-component-util", - "wasmtime-cranelift", - "wasmtime-environ", - "wasmtime-fiber", - "wasmtime-jit-debug", - "wasmtime-jit-icache-coherence", - "wasmtime-math", - "wasmtime-slab", - "wasmtime-versioned-export-macros", - "wasmtime-winch", - "wat", - "windows-sys 0.59.0", -] - -[[package]] -name = "wasmtime-asm-macros" -version = "29.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f178b0d125201fbe9f75beaf849bd3e511891f9e45ba216a5b620802ccf64f2" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "wasmtime-cache" -version = "29.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1161c8f62880deea07358bc40cceddc019f1c81d46007bc390710b2fe24ffc" -dependencies = [ - "anyhow", - "base64 0.21.7", - "directories-next", - "log", - "postcard", - "rustix 0.38.44", - "serde", - "serde_derive", - "sha2 0.10.9", - "toml", - "windows-sys 0.59.0", - "zstd", -] - -[[package]] -name = "wasmtime-component-macro" -version = "29.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d74de6592ed945d0a602f71243982a304d5d02f1e501b638addf57f42d57dfaf" -dependencies = [ - "anyhow", - "proc-macro2", - "quote", - "syn", - "wasmtime-component-util", - "wasmtime-wit-bindgen", - "wit-parser 0.221.3", -] - -[[package]] -name = "wasmtime-component-util" -version = "29.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707dc7b3c112ab5a366b30cfe2fb5b2f8e6a0f682f16df96a5ec582bfe6f056e" - -[[package]] -name = "wasmtime-cranelift" -version = "29.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "366be722674d4bf153290fbcbc4d7d16895cc82fb3e869f8d550ff768f9e9e87" -dependencies = [ - "anyhow", - "cfg-if", - "cranelift-codegen", - "cranelift-control", - "cranelift-entity", - "cranelift-frontend", - "cranelift-native", - "gimli", - "itertools 0.12.1", - "log", - "object 0.36.7", - "smallvec", - "target-lexicon 0.13.5", - "thiserror 1.0.69", - "wasmparser 0.221.3", - "wasmtime-environ", - "wasmtime-versioned-export-macros", -] - -[[package]] -name = "wasmtime-environ" -version = "29.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdadc1af7097347aa276a4f008929810f726b5b46946971c660b6d421e9994ad" -dependencies = [ - "anyhow", - "cpp_demangle", - "cranelift-bitset", - "cranelift-entity", - "gimli", - "indexmap", - "log", - "object 0.36.7", - "postcard", - "rustc-demangle", - "semver", - "serde", - "serde_derive", - "smallvec", - "target-lexicon 0.13.5", - "wasm-encoder 0.221.3", - "wasmparser 0.221.3", - "wasmprinter", - "wasmtime-component-util", -] - -[[package]] -name = "wasmtime-fiber" -version = "29.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccba90d4119f081bca91190485650730a617be1fff5228f8c4757ce133d21117" -dependencies = [ - "anyhow", - "cc", - "cfg-if", - "rustix 0.38.44", - "wasmtime-asm-macros", - "wasmtime-versioned-export-macros", - "windows-sys 0.59.0", -] - -[[package]] -name = "wasmtime-jit-debug" -version = "29.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e7b61488a5ee00c35c8c22de707c36c0aecacf419a3be803a6a2ba5e860f56a" -dependencies = [ - "object 0.36.7", - "rustix 0.38.44", - "wasmtime-versioned-export-macros", -] - -[[package]] -name = "wasmtime-jit-icache-coherence" -version = "29.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec5e8552e01692e6c2e5293171704fed8abdec79d1a6995a0870ab190e5747d1" -dependencies = [ - "anyhow", - "cfg-if", - "libc", - "windows-sys 0.59.0", -] - -[[package]] -name = "wasmtime-math" -version = "29.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29210ec2aa25e00f4d54605cedaf080f39ec01a872c5bd520ad04c67af1dde17" -dependencies = [ - "libm", -] - -[[package]] -name = "wasmtime-slab" -version = "29.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcb5821a96fa04ac14bc7b158bb3d5cd7729a053db5a74dad396cd513a5e5ccf" - -[[package]] -name = "wasmtime-versioned-export-macros" -version = "29.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86ff86db216dc0240462de40c8290887a613dddf9685508eb39479037ba97b5b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "wasmtime-winch" -version = "29.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdbabfb8f20502d5e1d81092b9ead3682ae59988487aafcd7567387b7a43cf8f" -dependencies = [ - "anyhow", - "cranelift-codegen", - "gimli", - "object 0.36.7", - "target-lexicon 0.13.5", - "wasmparser 0.221.3", - "wasmtime-cranelift", - "wasmtime-environ", - "winch-codegen", -] - -[[package]] -name = "wasmtime-wit-bindgen" -version = "29.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8358319c2dd1e4db79e3c1c5d3a5af84956615343f9f89f4e4996a36816e06e6" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "wit-parser 0.221.3", -] - -[[package]] -name = "wast" -version = "245.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28cf1149285569120b8ce39db8b465e8a2b55c34cbb586bd977e43e2bc7300bf" -dependencies = [ - "bumpalo", - "leb128fmt", - "memchr", - "unicode-width 0.2.0", - "wasm-encoder 0.245.1", -] - -[[package]] -name = "wat" -version = "1.245.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd48d1679b6858988cb96b154dda0ec5bbb09275b71db46057be37332d5477be" -dependencies = [ - "wast", -] - [[package]] name = "web-sys" version = "0.3.85" @@ -6005,21 +6395,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "winch-codegen" -version = "29.0.1" +name = "windows" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f849ef2c5f46cb0a20af4b4487aaa239846e52e2c03f13fa3c784684552859c" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" dependencies = [ - "anyhow", - "cranelift-codegen", - "gimli", - "regalloc2", - "smallvec", - "target-lexicon 0.13.5", - "thiserror 1.0.69", - "wasmparser 0.221.3", - "wasmtime-cranelift", - "wasmtime-environ", + "windows-core 0.58.0", + "windows-targets 0.52.6", ] [[package]] @@ -6029,7 +6411,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ "windows-collections", - "windows-core", + "windows-core 0.62.2", "windows-future", "windows-numerics", ] @@ -6040,7 +6422,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core", + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", ] [[package]] @@ -6049,11 +6444,11 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link", - "windows-result", - "windows-strings", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -6062,11 +6457,22 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core", + "windows-core 0.62.2", "windows-link", "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -6078,6 +6484,17 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -6101,7 +6518,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core", + "windows-core 0.62.2", "windows-link", ] @@ -6112,8 +6529,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ "windows-link", - "windows-result", - "windows-strings", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -6125,6 +6551,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.5.1" @@ -6476,7 +6912,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck", - "wit-parser 0.244.0", + "wit-parser", ] [[package]] @@ -6523,28 +6959,10 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder 0.244.0", + "wasm-encoder", "wasm-metadata", - "wasmparser 0.244.0", - "wit-parser 0.244.0", -] - -[[package]] -name = "wit-parser" -version = "0.221.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "896112579ed56b4a538b07a3d16e562d101ff6265c46b515ce0c701eef16b2ac" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser 0.221.3", + "wasmparser", + "wit-parser", ] [[package]] @@ -6562,7 +6980,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.244.0", + "wasmparser", ] [[package]] @@ -6576,8 +6994,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.18", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -6605,6 +7023,24 @@ dependencies = [ "web-sys", ] +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + [[package]] name = "xml-rs" version = "0.8.28" @@ -6625,9 +7061,15 @@ name = "xtask" version = "0.1.0" dependencies = [ "clap", - "libc", - "serde", - "toml", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", ] [[package]] @@ -6758,31 +7200,3 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/Cargo.toml b/Cargo.toml index 1f5565d..b940538 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,23 +1,20 @@ [workspace] members = [ ".", - "crates/python", - "crates/wasm/runtime", - "crates/bin-runner", + "crates/bindings/python", + "crates/bindings/wasm-runtime", "crates/simulation", "crates/dashboard", "crates/distribution", - "crates/std", "crates/process", "crates/datastore", - "crates/shared-types", - "crates/swactor-node", - "crates/streams", + "crates/transport", + "crates/node", "tests/docker", - "crates/ci", + "tests/integration", "xtask", ] -exclude = ["tools/depgraph", "crates/wasm/crypto"] +exclude = ["crates/bindings/wasm-crypto"] [package] name = "swactor" @@ -33,7 +30,8 @@ strip = false crate-type = ["rlib"] [features] -default = ["getrandom"] +default = ["getrandom", "std"] +std = [] # OTP patterns: supervisors, registries, timers, routers getrandom = ["dep:getrandom"] serde = ["dep:serde"] tracing = ["dep:tracing"] @@ -54,7 +52,6 @@ crossbeam-utils = "0.8.21" criterion = { version = "0.5", features = ["html_reports"] } proptest = "1" proptest-state-machine = "0.3" -swactor-std = { path = "crates/std" } [[bench]] name = "runtime_benchmarks" @@ -68,6 +65,3 @@ harness = false name = "hasher_benchmarks" harness = false -[[example]] -name = "tcp_ping_pong" -required-features = ["transport"] diff --git a/README.md b/README.md index 861a90b..cc69109 100644 --- a/README.md +++ b/README.md @@ -1,152 +1,24 @@ # swactor -Minimal actor runtime for Rust. Single-threaded or multi-threaded, with -Python and WebAssembly bindings. +Minimal actor runtime for Rust. One trait, one message type. +Single-threaded (`tick()`) or multi-threaded (`run()`). -## Quick Start +## Description -```rust -use swactor::actor::{ActorAddress, ActorInterface}; -use swactor::runtime::{Ctx, Runtime, RuntimeConfig}; +Core runtime is `src/`. Actors implement `ActorInterface` (in `actor.rs`), +interact through `Ctx` (in `runtime.rs`), and run on worker threads (`worker.rs`). -#[derive(Clone)] -struct Greet { name: String, reply_to: ActorAddress } +`crates/` builds upward: `std` adds OTP patterns (supervision, monitoring, groups), +`distribution` adds clustering, everything else composes from there. -#[derive(Clone)] -struct Greeting(String); +## Dev commands -struct Greeter; +`cargo xtask --help` for available test groups. -impl ActorInterface for Greeter { - type Incoming = Greet; - type Response = Greeting; +## Testing - fn handle(&mut self, ctx: &Ctx, msg: Greet) { - let _ = ctx.send(msg.reply_to, Greeting(format!("Hello, {}!", msg.name))); - } -} - -fn main() { - let rt = Runtime::new(RuntimeConfig::default()); - let addr = rt.spawn(Greeter).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - rt.send_to(addr, Greet { name: "world".into(), reply_to: *inbox.addr() }).unwrap(); - rt.tick(); - rt.tick(); - - println!("{}", inbox.try_recv().unwrap().0); // "Hello, world!" -} ``` - -## Features - -### Actor Model - -Actors implement one trait (`ActorInterface`), receive one message type, and -hold mutable state. No lifecycle hooks, no supervision trees, no async. - -Every actor gets a 32-byte globally unique `ActorAddress`. The same -`ctx.send(addr, msg)` call works whether the target is on the same worker, -a different worker thread, an external inbox, or a remote process. - -Single-threaded mode (`rt.tick()`) gives deterministic frame-level control. -Multi-threaded mode (`rt.run()`) spawns OS threads with adaptive backoff. - -See [docs/runtime/actor-model.md](docs/runtime/actor-model.md) and -[docs/runtime/runtime.md](docs/runtime/runtime.md) for the full model. - -### Transport - -Pluggable cross-process messaging. User-provided codecs handle serialization -(gRPC/protobuf, bincode, hand-rolled — no serde bounds imposed) and -user-provided transports handle delivery (TCP, in-memory, gRPC channel). - -```bash -cargo build --features transport -cargo run --example tcp_ping_pong --features transport -- receiver # terminal 1 -cargo run --example tcp_ping_pong --features transport -- sender # terminal 2 +cargo check --workspace +cargo xtask test +cargo xtask test essential ``` - -See [docs/distribution/transport.md](docs/distribution/transport.md) for the routing chain, codec -registry, and address resolution. - -### Runtime Dashboard - -Live web dashboard for monitoring actors, message throughput, and mailbox -depths. Supports trace recording and replay at configurable speed. - -Includes hand-authored SVG diagrams (actor lifecycle, message lifecycle, -tick cycle, transport routing) and generated diagrams from DOT sources -(architecture, dataflow, type erasure). - -See [crates/runtime-dashboard/](crates/runtime-dashboard/README.md). - -### Language Bindings - -**Python** — PyO3 via Maturin. Spawn actors from Python callables, pass -dicts as messages, single-threaded or multi-threaded. - -```bash -cd crates/swactor-python && maturin develop -``` - -Examples in `examples/python/` (single-thread, async, Jupyter notebook). - -**WASM** — wasm-bindgen. Runs single-threaded with deterministic addressing -(`no_random` feature). - -```bash -cd crates/swactor-wasm && wasm-pack build --target nodejs -``` - -### Connectome Analysis - -Structural analysis of the internal dependency graph. - -- **depgraph** (`tools/depgraph/`) — AST-based extraction of module - dependencies, outputs GraphViz DOT -- **spectral** (`tools/spectral/`) — Laplacian eigenvalue analysis, - Connectome Complexity Index (CCI), coupling heatmaps, interactive HTML - dashboard - -```bash -cargo run --manifest-path tools/depgraph/Cargo.toml -- --src-dir src/ --output deps -python tools/spectral/spectral_analysis.py deps.dot -``` - -See [docs/connectome/connectome.md](docs/connectome/connectome.md) for metric interpretation. - -## Building & Testing - -```bash -cargo test # all tests -cargo test --features transport # include transport tests -cargo run --example hello # single actor example -cargo run --example ring # 500-actor ring topology -cargo bench # benchmarks (criterion) -``` - -## Feature Flags - -| Flag | Default | What it does | -|------|---------|--------------| -| `getrandom` | yes | System RNG for actor addresses | -| `no_random` | no | Deterministic counter (WASM / reproducible tests) | -| `transport` | no | Pluggable remote messaging (codec + transport) | -| `tracing` | no | `tracing` instrumentation for runtime internals | -| `serde` | no | Serde derives for stats types | -| `python` | no | PyO3 bindings (cdylib wheel) | - -## Documentation - -| Document | Covers | -|----------|--------| -| [Actor Model](docs/runtime/actor-model.md) | Traits, type erasure, addresses | -| [Runtime](docs/runtime/runtime.md) | Runtime, Ctx, Inbox, RuntimeHandle, stats | -| [Worker Thread](docs/runtime/worker-thread.md) | Tick phases, backoff, routing, full system topology | -| [Channels](docs/runtime/channels.md) | HybridChannel, AddressMap, Placement | -| [Transport](docs/distribution/transport.md) | Codec, Transport, remote messaging, address resolution | -| [Distribution](docs/distribution/distribution.md) | SWIM membership, Kademlia, NodeDriver | -| [Connectome](docs/connectome/connectome.md) | CCI metrics, spectral analysis interpretation | -| [Dashboard](crates/runtime-dashboard/README.md) | Live web UI, trace recording, diagram index | diff --git a/benches/runtime_benchmarks.rs b/benches/runtime_benchmarks.rs index b308c04..0270440 100644 --- a/benches/runtime_benchmarks.rs +++ b/benches/runtime_benchmarks.rs @@ -7,7 +7,7 @@ use swactor::{ config::RuntimeConfig, runtime::{Ctx, Runtime}, }; -use swactor_std::{RuntimeGroups, RuntimeNaming, StdExtension}; +use swactor::std::{RuntimeGroups, RuntimeNaming, StdExtension}; // --------------------------------------------------------------------------- // Helper diff --git a/crates/bin-runner/Cargo.toml b/crates/bin-runner/Cargo.toml deleted file mode 100644 index d7891dd..0000000 --- a/crates/bin-runner/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "swactor-bin-runner" -version = "0.1.0" -edition = "2024" - -[dependencies] -swactor = { path = "../.." } -wasmtime = "29" - -[dev-dependencies] -swactor = { path = "../..", features = ["getrandom"] } -swactor-std = { path = "../std" } -wat = "1" -proptest = "1" diff --git a/crates/bin-runner/build.rs b/crates/bin-runner/build.rs deleted file mode 100644 index c1c291d..0000000 --- a/crates/bin-runner/build.rs +++ /dev/null @@ -1,28 +0,0 @@ -use std::path::Path; -use std::process::Command; - -fn main() { - let manifest_dir = env!("CARGO_MANIFEST_DIR"); - let guests_dir = Path::new(manifest_dir).join("tests/guests"); - - for guest in &["echo", "double", "silent"] { - let guest_dir = guests_dir.join(guest); - - println!( - "cargo:rerun-if-changed={}", - guest_dir.join("src/lib.rs").display() - ); - println!( - "cargo:rerun-if-changed={}", - guest_dir.join("Cargo.toml").display() - ); - - let status = Command::new("cargo") - .args(["build", "--target", "wasm32-unknown-unknown", "--release"]) - .current_dir(&guest_dir) - .status() - .unwrap_or_else(|e| panic!("failed to run cargo build for {guest} guest: {e}")); - - assert!(status.success(), "failed to build {guest} guest"); - } -} diff --git a/crates/bin-runner/src/actor.rs b/crates/bin-runner/src/actor.rs deleted file mode 100644 index 93d1c0a..0000000 --- a/crates/bin-runner/src/actor.rs +++ /dev/null @@ -1,63 +0,0 @@ -use swactor::actor::{ActorInterface, Ctx}; -use wasmtime::{Memory, Store, TypedFunc}; - -use crate::ByteMessage; - -/// State accessible to host functions during guest execution. -#[derive(Default)] -pub(crate) struct HostState { - pub outbox: Vec<(swactor::actor::ActorAddress, Vec)>, -} - -/// An actor whose logic is defined by a WebAssembly guest module. -/// -/// Messages arrive as [`ByteMessage`], are copied into Wasm linear memory, -/// and processed by the guest's `handle` export. The guest can send messages -/// back via the `swactor.send` host import. -pub struct WasmActor { - pub(crate) store: Store, - pub(crate) memory: Memory, - pub(crate) alloc: TypedFunc, - pub(crate) handle: TypedFunc<(i32, i32), ()>, -} - -impl ActorInterface for WasmActor { - type Incoming = ByteMessage; - type Response = (); - - fn handle(&mut self, ctx: &Ctx, msg: ByteMessage) { - let bytes = &msg.0; - let len: i32 = match i32::try_from(bytes.len()) { - Ok(n) => n, - Err(_) => return, // message too large for i32 ABI - }; - - // 1. Allocate space in guest memory - let ptr = match self.alloc.call(&mut self.store, len) { - Ok(ptr) if ptr < 0 => return, // invalid pointer - Ok(0) if len > 0 => return, // OOM — drop message - Ok(ptr) => ptr, - Err(_) => return, // alloc trapped — drop message - }; - - // 2. Write message bytes into guest memory - let mem = self.memory.data_mut(&mut self.store); - let end = (ptr as usize).saturating_add(bytes.len()); - if end > mem.len() { - return; // alloc returned OOB pointer — drop message - } - mem[ptr as usize..end].copy_from_slice(bytes); - - // 3. Call guest handle - if self.handle.call(&mut self.store, (ptr, len)).is_err() { - self.store.data_mut().outbox.clear(); // discard sends from incomplete operation - return; // handle trapped — drop message, keep actor alive - } - - // 4. Drain outbox → send via ctx - let outbox: Vec<_> = self.store.data_mut().outbox.drain(..).collect(); - for (dest, payload) in outbox { - let _ = ctx.send(dest, ByteMessage(payload)); - } - } -} diff --git a/crates/bin-runner/src/builder.rs b/crates/bin-runner/src/builder.rs deleted file mode 100644 index 06a199c..0000000 --- a/crates/bin-runner/src/builder.rs +++ /dev/null @@ -1,109 +0,0 @@ -use swactor::actor::ActorAddress; -use wasmtime::{Linker, Module, Store, TypedFunc}; - -use crate::actor::{HostState, WasmActor}; -use crate::engine::SharedEngine; -use crate::error::WasmActorError; - -/// Compiles a Wasm module and produces a ready-to-use [`WasmActor`]. -pub struct WasmActorBuilder { - engine: SharedEngine, - wasm_bytes: Vec, -} - -impl WasmActorBuilder { - pub fn new(engine: SharedEngine, wasm_bytes: impl Into>) -> Self { - Self { - engine, - wasm_bytes: wasm_bytes.into(), - } - } - - /// Compile the module, link host functions, and instantiate. - pub fn build(self) -> Result { - let engine = self.engine.inner(); - let module = Module::new(engine, &self.wasm_bytes)?; - - let mut linker: Linker = Linker::new(engine); - Self::link_send(&mut linker)?; - - let mut store = Store::new(engine, HostState::default()); - let instance = linker.instantiate(&mut store, &module)?; - - // Extract required exports - let memory = instance - .get_memory(&mut store, "memory") - .ok_or(WasmActorError::MissingExport("memory"))?; - - let alloc: TypedFunc = instance - .get_typed_func(&mut store, "alloc") - .map_err(|_| WasmActorError::MissingExport("alloc"))?; - - let handle: TypedFunc<(i32, i32), ()> = instance - .get_typed_func(&mut store, "handle") - .map_err(|_| WasmActorError::MissingExport("handle"))?; - - Ok(WasmActor { - store, - memory, - alloc, - handle, - }) - } - - /// Link the `swactor.send` host import. - fn link_send(linker: &mut Linker) -> Result<(), WasmActorError> { - linker.func_wrap( - "swactor", - "send", - |mut caller: wasmtime::Caller<'_, HostState>, - dest_ptr: i32, - payload_ptr: i32, - payload_len: i32| - -> Result<(), wasmtime::Error> { - let mem = caller - .get_export("memory") - .and_then(|e| e.into_memory()) - .ok_or_else(|| wasmtime::Error::msg("guest must export memory"))?; - let data = mem.data(&caller); - let mem_len = data.len(); - - // Validate non-negative arguments - if dest_ptr < 0 || payload_ptr < 0 || payload_len < 0 { - return Err(wasmtime::Error::msg( - "negative argument in swactor.send", - )); - } - - let dest_ptr = dest_ptr as usize; - let payload_ptr = payload_ptr as usize; - let payload_len = payload_len as usize; - - // Bounds-check with overflow protection - let dest_end = dest_ptr - .checked_add(32) - .ok_or_else(|| wasmtime::Error::msg("dest_ptr overflow"))?; - let payload_end = payload_ptr - .checked_add(payload_len) - .ok_or_else(|| wasmtime::Error::msg("payload range overflow"))?; - if dest_end > mem_len || payload_end > mem_len { - return Err(wasmtime::Error::msg( - "out-of-bounds memory access in swactor.send", - )); - } - - // Read 32-byte destination address - let mut addr_bytes = [0u8; 32]; - addr_bytes.copy_from_slice(&data[dest_ptr..dest_end]); - let dest = ActorAddress(addr_bytes); - - // Read payload - let payload = data[payload_ptr..payload_end].to_vec(); - - caller.data_mut().outbox.push((dest, payload)); - Ok(()) - }, - )?; - Ok(()) - } -} diff --git a/crates/bin-runner/src/engine.rs b/crates/bin-runner/src/engine.rs deleted file mode 100644 index 8cc2dfc..0000000 --- a/crates/bin-runner/src/engine.rs +++ /dev/null @@ -1,35 +0,0 @@ -use std::sync::Arc; - -use wasmtime::Engine; - -/// A shared, cheaply-cloneable Wasm engine. -/// -/// Created once and reused across multiple [`WasmActor`](crate::WasmActor) instances. -/// Configured with maximum sandboxing — no threads, no SIMD, no reference types. -#[derive(Clone)] -pub struct SharedEngine(Arc); - -impl std::fmt::Debug for SharedEngine { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_tuple("SharedEngine").field(&"").finish() - } -} - -impl SharedEngine { - /// Create a new engine with sandboxed defaults. - pub fn new() -> Result { - let mut config = wasmtime::Config::new(); - config.wasm_threads(false); - config.wasm_simd(false); - config.wasm_relaxed_simd(false); - config.wasm_reference_types(false); - config.wasm_multi_value(false); - config.wasm_bulk_memory(true); - let engine = Engine::new(&config)?; - Ok(Self(Arc::new(engine))) - } - - pub(crate) fn inner(&self) -> &Engine { - &self.0 - } -} diff --git a/crates/bin-runner/src/error.rs b/crates/bin-runner/src/error.rs deleted file mode 100644 index b91c052..0000000 --- a/crates/bin-runner/src/error.rs +++ /dev/null @@ -1,27 +0,0 @@ -use std::fmt; - -/// Errors that can occur when building or running a WasmActor. -#[derive(Debug)] -pub enum WasmActorError { - /// A required export is missing from the Wasm module. - MissingExport(&'static str), - /// The Wasm module failed to compile or instantiate. - Wasmtime(wasmtime::Error), -} - -impl fmt::Display for WasmActorError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::MissingExport(name) => write!(f, "missing required export: `{name}`"), - Self::Wasmtime(e) => write!(f, "wasmtime error: {e}"), - } - } -} - -impl std::error::Error for WasmActorError {} - -impl From for WasmActorError { - fn from(e: wasmtime::Error) -> Self { - Self::Wasmtime(e) - } -} diff --git a/crates/bin-runner/src/lib.rs b/crates/bin-runner/src/lib.rs deleted file mode 100644 index 3f744c2..0000000 --- a/crates/bin-runner/src/lib.rs +++ /dev/null @@ -1,13 +0,0 @@ -mod actor; -mod builder; -mod engine; -mod error; - -pub use actor::WasmActor; -pub use builder::WasmActorBuilder; -pub use engine::SharedEngine; -pub use error::WasmActorError; - -/// A message carrying raw bytes, suitable for passing to/from Wasm guests. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ByteMessage(pub Vec); diff --git a/crates/bin-runner/tests/guests/double/Cargo.lock b/crates/bin-runner/tests/guests/double/Cargo.lock deleted file mode 100644 index b46d745..0000000 --- a/crates/bin-runner/tests/guests/double/Cargo.lock +++ /dev/null @@ -1,7 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "double-guest" -version = "0.1.0" diff --git a/crates/bin-runner/tests/guests/double/Cargo.toml b/crates/bin-runner/tests/guests/double/Cargo.toml deleted file mode 100644 index 943eabe..0000000 --- a/crates/bin-runner/tests/guests/double/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[workspace] - -[package] -name = "double-guest" -version = "0.1.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[profile.release] -opt-level = "s" -lto = true diff --git a/crates/bin-runner/tests/guests/double/src/lib.rs b/crates/bin-runner/tests/guests/double/src/lib.rs deleted file mode 100644 index 2ea9158..0000000 --- a/crates/bin-runner/tests/guests/double/src/lib.rs +++ /dev/null @@ -1,63 +0,0 @@ -#![no_std] - -use core::cell::UnsafeCell; -use core::panic::PanicInfo; - -// --- bump allocator --- -const HEAP_SIZE: usize = 65536; - -struct BumpAlloc { - heap: UnsafeCell<[u8; HEAP_SIZE]>, - offset: UnsafeCell, -} - -unsafe impl Sync for BumpAlloc {} - -static ALLOC: BumpAlloc = BumpAlloc { - heap: UnsafeCell::new([0u8; HEAP_SIZE]), - offset: UnsafeCell::new(0), -}; - -#[unsafe(no_mangle)] -pub extern "C" fn alloc(size: i32) -> i32 { - unsafe { - let offset = &mut *ALLOC.offset.get(); - let heap = &mut *ALLOC.heap.get(); - let align = 8; - let start = (*offset + align - 1) & !(align - 1); - let end = start + size as usize; - if end > heap.len() { - return 0; // OOM - } - *offset = end; - heap.as_ptr().add(start) as i32 - } -} - -// --- host import --- -#[link(wasm_import_module = "swactor")] -unsafe extern "C" { - #[link_name = "send"] - fn host_send(dest_ptr: i32, payload_ptr: i32, payload_len: i32); -} - -/// Message format: first 32 bytes = destination address, rest = payload. -/// Sends the payload back twice to demonstrate multi-send. -#[unsafe(no_mangle)] -pub extern "C" fn handle(ptr: i32, len: i32) { - if len < 32 { - return; - } - let dest_ptr = ptr; - let payload_ptr = ptr + 32; - let payload_len = len - 32; - unsafe { - host_send(dest_ptr, payload_ptr, payload_len); - host_send(dest_ptr, payload_ptr, payload_len); - } -} - -#[panic_handler] -fn panic(_info: &PanicInfo) -> ! { - loop {} -} diff --git a/crates/bin-runner/tests/guests/echo/Cargo.lock b/crates/bin-runner/tests/guests/echo/Cargo.lock deleted file mode 100644 index 224c914..0000000 --- a/crates/bin-runner/tests/guests/echo/Cargo.lock +++ /dev/null @@ -1,7 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "echo-guest" -version = "0.1.0" diff --git a/crates/bin-runner/tests/guests/echo/Cargo.toml b/crates/bin-runner/tests/guests/echo/Cargo.toml deleted file mode 100644 index 7c33f5c..0000000 --- a/crates/bin-runner/tests/guests/echo/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[workspace] - -[package] -name = "echo-guest" -version = "0.1.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[profile.release] -opt-level = "s" -lto = true diff --git a/crates/bin-runner/tests/guests/echo/src/lib.rs b/crates/bin-runner/tests/guests/echo/src/lib.rs deleted file mode 100644 index edc69a5..0000000 --- a/crates/bin-runner/tests/guests/echo/src/lib.rs +++ /dev/null @@ -1,62 +0,0 @@ -#![no_std] - -use core::cell::UnsafeCell; -use core::panic::PanicInfo; - -// --- bump allocator --- -const HEAP_SIZE: usize = 65536; - -struct BumpAlloc { - heap: UnsafeCell<[u8; HEAP_SIZE]>, - offset: UnsafeCell, -} - -unsafe impl Sync for BumpAlloc {} - -static ALLOC: BumpAlloc = BumpAlloc { - heap: UnsafeCell::new([0u8; HEAP_SIZE]), - offset: UnsafeCell::new(0), -}; - -#[unsafe(no_mangle)] -pub extern "C" fn alloc(size: i32) -> i32 { - unsafe { - let offset = &mut *ALLOC.offset.get(); - let heap = &mut *ALLOC.heap.get(); - let align = 8; - let start = (*offset + align - 1) & !(align - 1); - let end = start + size as usize; - if end > heap.len() { - return 0; // OOM - } - *offset = end; - heap.as_ptr().add(start) as i32 - } -} - -// --- host import --- -#[link(wasm_import_module = "swactor")] -unsafe extern "C" { - #[link_name = "send"] - fn host_send(dest_ptr: i32, payload_ptr: i32, payload_len: i32); -} - -/// Message format: first 32 bytes = destination address, rest = payload. -/// Echo sends the payload portion back to the specified destination. -#[unsafe(no_mangle)] -pub extern "C" fn handle(ptr: i32, len: i32) { - if len < 32 { - return; - } - let dest_ptr = ptr; - let payload_ptr = ptr + 32; - let payload_len = len - 32; - unsafe { - host_send(dest_ptr, payload_ptr, payload_len); - } -} - -#[panic_handler] -fn panic(_info: &PanicInfo) -> ! { - loop {} -} diff --git a/crates/bin-runner/tests/guests/silent/Cargo.lock b/crates/bin-runner/tests/guests/silent/Cargo.lock deleted file mode 100644 index aff96a4..0000000 --- a/crates/bin-runner/tests/guests/silent/Cargo.lock +++ /dev/null @@ -1,7 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "silent-guest" -version = "0.1.0" diff --git a/crates/bin-runner/tests/guests/silent/Cargo.toml b/crates/bin-runner/tests/guests/silent/Cargo.toml deleted file mode 100644 index 14e524f..0000000 --- a/crates/bin-runner/tests/guests/silent/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[workspace] - -[package] -name = "silent-guest" -version = "0.1.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[profile.release] -opt-level = "s" -lto = true diff --git a/crates/bin-runner/tests/guests/silent/src/lib.rs b/crates/bin-runner/tests/guests/silent/src/lib.rs deleted file mode 100644 index c1d2a1c..0000000 --- a/crates/bin-runner/tests/guests/silent/src/lib.rs +++ /dev/null @@ -1,45 +0,0 @@ -#![no_std] - -use core::cell::UnsafeCell; -use core::panic::PanicInfo; - -// --- bump allocator --- -const HEAP_SIZE: usize = 65536; - -struct BumpAlloc { - heap: UnsafeCell<[u8; HEAP_SIZE]>, - offset: UnsafeCell, -} - -unsafe impl Sync for BumpAlloc {} - -static ALLOC: BumpAlloc = BumpAlloc { - heap: UnsafeCell::new([0u8; HEAP_SIZE]), - offset: UnsafeCell::new(0), -}; - -#[unsafe(no_mangle)] -pub extern "C" fn alloc(size: i32) -> i32 { - unsafe { - let offset = &mut *ALLOC.offset.get(); - let heap = &mut *ALLOC.heap.get(); - let align = 8; - let start = (*offset + align - 1) & !(align - 1); - let end = start + size as usize; - if end > heap.len() { - return 0; // OOM - } - *offset = end; - heap.as_ptr().add(start) as i32 - } -} - -#[unsafe(no_mangle)] -pub extern "C" fn handle(_ptr: i32, _len: i32) { - // Silent: receive bytes, do nothing -} - -#[panic_handler] -fn panic(_info: &PanicInfo) -> ! { - loop {} -} diff --git a/crates/bin-runner/tests/wasm_actor.proptest-regressions b/crates/bin-runner/tests/wasm_actor.proptest-regressions deleted file mode 100644 index 8a4ef8c..0000000 --- a/crates/bin-runner/tests/wasm_actor.proptest-regressions +++ /dev/null @@ -1,8 +0,0 @@ -# Seeds for failure cases proptest has generated in the past. It is -# automatically read and these particular cases re-run before any -# novel cases are generated. -# -# It is recommended to check this file in to source control so that -# everyone who runs the test benefits from these saved cases. -cc f8674b306ba8187ad75bcc5a33c4a679e497a0053dc6fdb2234ccaeaae8f85b7 # shrinks to guest_idx = 0, n_msgs = 0, ticks_before = 1, ticks_after = 1 -cc db98a9c0726c0477ba56ec6098a1f21518faaff4551d90e915790ce3c890dd53 # shrinks to payload = [] diff --git a/crates/bin-runner/tests/wasm_actor.rs b/crates/bin-runner/tests/wasm_actor.rs deleted file mode 100644 index 341a2d8..0000000 --- a/crates/bin-runner/tests/wasm_actor.rs +++ /dev/null @@ -1,1367 +0,0 @@ -use swactor::actor::{ActorAddress, ActorInterface}; -use swactor::runtime::{Ctx, Runtime, RuntimeConfig}; -use swactor_bin_runner::{ByteMessage, SharedEngine, WasmActor, WasmActorBuilder, WasmActorError}; -use swactor_std::{CtxWatching, StdExtension}; - -use proptest::prelude::*; - -fn guest_wasm(name: &str) -> Vec { - let path = format!( - "{}/tests/guests/{name}/target/wasm32-unknown-unknown/release/{name}_guest.wasm", - env!("CARGO_MANIFEST_DIR") - ); - std::fs::read(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}")) -} - -/// Build a message with an inbox address prepended (the guest contract). -fn framed_msg(dest: &ActorAddress, payload: &[u8]) -> ByteMessage { - let mut buf = Vec::with_capacity(32 + payload.len()); - buf.extend_from_slice(&dest.0); - buf.extend_from_slice(payload); - ByteMessage(buf) -} - -/// Build a WAT module where alloc returns a constant value. -fn alloc_returns_wat(alloc_val: i32) -> Vec { - let wat = format!( - r#"(module - (import "swactor" "send" (func $send (param i32 i32 i32))) - (memory (export "memory") 1) - (func (export "alloc") (param i32) (result i32) i32.const {alloc_val}) - (func (export "handle") (param i32 i32)) - )"# - ); - wat::parse_str(&wat).unwrap() -} - -/// Build a WAT module where handle calls send with specific arguments. -fn send_args_wat(dest_ptr: i32, payload_ptr: i32, payload_len: i32) -> Vec { - let wat = format!( - r#"(module - (import "swactor" "send" (func $send (param i32 i32 i32))) - (memory (export "memory") 1) - (func (export "alloc") (param i32) (result i32) i32.const 256) - (func (export "handle") (param i32 i32) - i32.const {dest_ptr} - i32.const {payload_ptr} - i32.const {payload_len} - call $send - ) - )"# - ); - wat::parse_str(&wat).unwrap() -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// Group 1: Builder contract -// ═══════════════════════════════════════════════════════════════════════════════ - -#[test] -fn build_succeeds_for_valid_modules() { - let engine = SharedEngine::new().unwrap(); - - // Pre-compiled guests - WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap(); - WasmActorBuilder::new(engine.clone(), guest_wasm("double")).build().unwrap(); - WasmActorBuilder::new(engine.clone(), guest_wasm("silent")).build().unwrap(); - - // Custom WAT with extra exports, data segments, funcref table - let extras = wat::parse_str(r#"(module - (import "swactor" "send" (func $send (param i32 i32 i32))) - (memory (export "memory") 1) - (data (i32.const 0) "hello") - (table 1 funcref) - (func (export "alloc") (param i32) (result i32) i32.const 256) - (func (export "handle") (param i32 i32)) - (func (export "custom_fn") (result i32) i32.const 42) - )"#).unwrap(); - WasmActorBuilder::new(engine.clone(), extras).build().unwrap(); - - // Module without send import (doesn't import swactor.send) - let no_send = wat::parse_str(r#"(module - (memory (export "memory") 1) - (func (export "alloc") (param i32) (result i32) i32.const 256) - (func (export "handle") (param i32 i32)) - )"#).unwrap(); - WasmActorBuilder::new(engine, no_send).build().unwrap(); -} - -#[test] -fn build_rejects_missing_exports() { - let engine = SharedEngine::new().unwrap(); - - // Empty module — missing everything - let result = WasmActorBuilder::new(engine.clone(), wat::parse_str("(module)").unwrap()).build(); - let err = result.err().expect("should fail"); - let msg = format!("{err}"); - assert!(msg.contains("memory") || msg.contains("alloc"), "got: {msg}"); - - // Memory only — missing alloc - let mem_only = wat::parse_str("(module (memory (export \"memory\") 1))").unwrap(); - let err = WasmActorBuilder::new(engine.clone(), mem_only).build().err().expect("should fail"); - assert!(format!("{err}").contains("alloc")); - - // Memory + alloc — missing handle - let no_handle = wat::parse_str(r#"(module - (memory (export "memory") 1) - (func (export "alloc") (param i32) (result i32) i32.const 0) - )"#).unwrap(); - let err = WasmActorBuilder::new(engine.clone(), no_handle).build().err().expect("should fail"); - assert!(format!("{err}").contains("handle")); - - // Wrong memory export name - let wrong_mem = wat::parse_str(r#"(module - (memory (export "mem") 1) - (func (export "alloc") (param i32) (result i32) i32.const 0) - (func (export "handle") (param i32 i32)) - )"#).unwrap(); - assert!(WasmActorBuilder::new(engine.clone(), wrong_mem).build().is_err()); - - // Wrong alloc signature (two params) - let wrong_alloc = wat::parse_str(r#"(module - (memory (export "memory") 1) - (func (export "alloc") (param i32 i32) (result i32) i32.const 0) - (func (export "handle") (param i32 i32)) - )"#).unwrap(); - assert!(WasmActorBuilder::new(engine.clone(), wrong_alloc).build().is_err()); - - // Wrong handle signature (returns i32) - let wrong_handle = wat::parse_str(r#"(module - (memory (export "memory") 1) - (func (export "alloc") (param i32) (result i32) i32.const 0) - (func (export "handle") (param i32 i32) (result i32) i32.const 0) - )"#).unwrap(); - assert!(WasmActorBuilder::new(engine, wrong_handle).build().is_err()); -} - -#[test] -fn build_rejects_invalid_wasm_and_disabled_features() { - let engine = SharedEngine::new().unwrap(); - - // Invalid bytes - assert!(WasmActorBuilder::new(engine.clone(), vec![0xDE, 0xAD]).build().is_err()); - - // Empty bytes - assert!(WasmActorBuilder::new(engine.clone(), vec![]).build().is_err()); - - // Truncated valid wasm - let valid = guest_wasm("echo"); - let truncated = valid[..valid.len() / 2].to_vec(); - assert!(WasmActorBuilder::new(engine.clone(), truncated).build().is_err()); - - // SIMD (disabled in engine config) - let simd = wat::parse_str(r#"(module - (memory (export "memory") 1) - (func (export "alloc") (param i32) (result i32) i32.const 0) - (func (export "handle") (param i32 i32) - v128.const i32x4 0 0 0 0 - drop - ) - )"#); - assert!(simd.is_err() || WasmActorBuilder::new(engine.clone(), simd.unwrap()).build().is_err()); - - // Multi-value (disabled) - let mv = wat::parse_str(r#"(module - (memory (export "memory") 1) - (func (export "alloc") (param i32) (result i32) i32.const 0) - (func (export "handle") (param i32 i32) - (block (result i32 i32) - i32.const 1 - i32.const 2 - ) - drop drop - ) - )"#); - assert!(mv.is_err() || WasmActorBuilder::new(engine, mv.unwrap()).build().is_err()); -} - -#[test] -fn error_display_and_from_impls() { - // MissingExport - let missing = WasmActorError::MissingExport("memory"); - let s = format!("{missing}"); - assert!(s.contains("memory"), "MissingExport display: {s}"); - - // Wasmtime error via From - let wt_err = wasmtime::Error::msg("test error"); - let converted: WasmActorError = wt_err.into(); - let s = format!("{converted}"); - assert!(s.contains("test error"), "Wasmtime display: {s}"); - - // Distinct display - let missing_s = format!("{}", WasmActorError::MissingExport("alloc")); - let wt_s = format!("{}", WasmActorError::Wasmtime(wasmtime::Error::msg("x"))); - assert_ne!(missing_s, wt_s); - - // Send + Sync - fn assert_send_sync() {} - assert_send_sync::(); -} - -proptest! { - #[test] - fn prop_builder_never_panics( - pages in 0u32..10, - alloc_return in prop_oneof![-100i32..0, 0i32..70000, Just(i32::MIN), Just(i32::MAX)], - ) { - let wat = format!( - r#"(module - (memory (export "memory") {pages}) - (func (export "alloc") (param i32) (result i32) i32.const {alloc_return}) - (func (export "handle") (param i32 i32)) - )"# - ); - if let Ok(wasm) = wat::parse_str(&wat) { - let engine = SharedEngine::new().unwrap(); - // Should succeed or return clean error — never panic - let _ = WasmActorBuilder::new(engine, wasm).build(); - } - } -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// Group 2: Echo round-trip -// ═══════════════════════════════════════════════════════════════════════════════ - -#[test] -fn echo_round_trip_story() { - let engine = SharedEngine::new().unwrap(); - let actor = WasmActorBuilder::new(engine, guest_wasm("echo")).build().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let inbox = rt.new_inbox::().unwrap(); - let addr = rt.spawn(actor).unwrap(); - - // Text payload - rt.send_to(addr, framed_msg(inbox.addr(), b"hello wasm")).unwrap(); - rt.tick(); - assert_eq!(inbox.try_recv().unwrap().0, b"hello wasm"); - - // Binary payload (all 256 byte values) - let binary: Vec = (0..=255).collect(); - rt.send_to(addr, framed_msg(inbox.addr(), &binary)).unwrap(); - rt.tick(); - assert_eq!(inbox.try_recv().unwrap().0, binary); - - // Empty message — echo needs >= 32 bytes, so no reply - rt.send_to(addr, ByteMessage(vec![])).unwrap(); - rt.tick(); - assert!(inbox.try_recv().is_none()); - - // Address-only message (32 bytes, 0 payload) — echo sends empty payload - rt.send_to(addr, framed_msg(inbox.addr(), b"")).unwrap(); - rt.tick(); - let resp = inbox.try_recv().unwrap(); - assert!(resp.0.is_empty(), "address-only should echo empty payload"); - - // Single byte payload - rt.send_to(addr, framed_msg(inbox.addr(), &[0x42])).unwrap(); - rt.tick(); - assert_eq!(inbox.try_recv().unwrap().0, vec![0x42]); - - // Actor still alive after all those messages - rt.send_to(addr, framed_msg(inbox.addr(), b"fin")).unwrap(); - rt.tick(); - assert_eq!(inbox.try_recv().unwrap().0, b"fin"); -} - -proptest! { - #[test] - fn prop_echo_preserves_arbitrary_payload(payload in proptest::collection::vec(any::(), 0..500)) { - let engine = SharedEngine::new().unwrap(); - let actor = WasmActorBuilder::new(engine, guest_wasm("echo")).build().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let inbox = rt.new_inbox::().unwrap(); - let addr = rt.spawn(actor).unwrap(); - - rt.send_to(addr, framed_msg(inbox.addr(), &payload)).unwrap(); - rt.tick(); - - if payload.is_empty() { - // echo guest: if len < 32, returns empty; if len == 32 (addr only), returns empty payload - let resp = inbox.try_recv(); - // With 32-byte address + 0-byte payload, echo sends back empty - if let Some(msg) = resp { - prop_assert!(msg.0.is_empty()); - } - } else { - let received = inbox.try_recv().expect("echo should reply for non-empty payload"); - prop_assert_eq!(received.0, payload); - } - } -} - -#[test] -fn double_and_silent_contracts() { - let engine = SharedEngine::new().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let inbox = rt.new_inbox::().unwrap(); - - // Double: 1 message in → exactly 2 out - let double = WasmActorBuilder::new(engine.clone(), guest_wasm("double")).build().unwrap(); - let daddr = rt.spawn(double).unwrap(); - rt.send_to(daddr, framed_msg(inbox.addr(), b"dup")).unwrap(); - rt.tick(); - assert_eq!(inbox.try_recv().unwrap().0, b"dup"); - assert_eq!(inbox.try_recv().unwrap().0, b"dup"); - assert!(inbox.try_recv().is_none(), "exactly 2"); - - // Silent: messages in → 0 out - let silent = WasmActorBuilder::new(engine, guest_wasm("silent")).build().unwrap(); - let saddr = rt.spawn(silent).unwrap(); - for _ in 0..10 { - rt.send_to(saddr, framed_msg(inbox.addr(), b"ignored")).unwrap(); - } - rt.tick(); - assert!(inbox.try_recv().is_none(), "silent never sends"); -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// Group 3: Alloc failure resilience -// ═══════════════════════════════════════════════════════════════════════════════ - -proptest! { - #[test] - fn prop_any_alloc_return_never_kills_actor( - alloc_val in prop_oneof![ - -100i32..0, - 0i32..70000, - Just(i32::MIN), - Just(i32::MAX), - Just(0i32), - Just(65500i32), - Just(65536i32), - ], - ) { - let wasm = alloc_returns_wat(alloc_val); - let engine = SharedEngine::new().unwrap(); - let actor = WasmActorBuilder::new(engine, wasm).build().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let addr = rt.spawn(actor).unwrap(); - - // Send two messages — actor must survive both regardless of alloc return - rt.send_to(addr, ByteMessage(vec![1, 2, 3])).unwrap(); - rt.tick(); - rt.send_to(addr, ByteMessage(vec![4, 5, 6])).unwrap(); - rt.tick(); - } -} - -#[test] -fn alloc_trap_recovery() { - // Alloc traps on first call (counter == 0), succeeds thereafter - let wat = r#"(module - (import "swactor" "send" (func $send (param i32 i32 i32))) - (memory (export "memory") 1) - (global $calls (mut i32) (i32.const 0)) - (func (export "alloc") (param $len i32) (result i32) - (global.set $calls (i32.add (global.get $calls) (i32.const 1))) - (if (result i32) (i32.eq (global.get $calls) (i32.const 1)) - (then unreachable) - (else i32.const 4096) - ) - ) - (func (export "handle") (param $ptr i32) (param $len i32) - (if (i32.ge_u (local.get $len) (i32.const 33)) - (then - (call $send - (local.get $ptr) - (i32.add (local.get $ptr) (i32.const 32)) - (i32.sub (local.get $len) (i32.const 32)) - ) - ) - ) - ) - )"#; - let engine = SharedEngine::new().unwrap(); - let actor = WasmActorBuilder::new(engine, wat::parse_str(wat).unwrap()).build().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let inbox = rt.new_inbox::().unwrap(); - let addr = rt.spawn(actor).unwrap(); - - // First message — alloc traps, dropped - rt.send_to(addr, framed_msg(inbox.addr(), b"first")).unwrap(); - rt.tick(); - assert!(inbox.try_recv().is_none(), "first msg dropped due to alloc trap"); - - // Second message — alloc succeeds, echoed - rt.send_to(addr, framed_msg(inbox.addr(), b"second")).unwrap(); - rt.tick(); - assert_eq!(inbox.try_recv().unwrap().0, b"second"); -} - -#[test] -fn alloc_alternates_and_exhaustion() { - // Part A: alternating alloc (even calls succeed, odd calls return 0) - let wat = r#"(module - (import "swactor" "send" (func $send (param i32 i32 i32))) - (memory (export "memory") 1) - (global $calls (mut i32) (i32.const 0)) - (func (export "alloc") (param $len i32) (result i32) - (global.set $calls (i32.add (global.get $calls) (i32.const 1))) - (if (result i32) (i32.rem_u (global.get $calls) (i32.const 2)) - (then i32.const 4096) - (else i32.const 0) - ) - ) - (func (export "handle") (param $ptr i32) (param $len i32) - (if (i32.ge_u (local.get $len) (i32.const 33)) - (then - (call $send - (local.get $ptr) - (i32.add (local.get $ptr) (i32.const 32)) - (i32.sub (local.get $len) (i32.const 32)) - ) - ) - ) - ) - )"#; - let engine = SharedEngine::new().unwrap(); - let actor = WasmActorBuilder::new(engine.clone(), wat::parse_str(wat).unwrap()).build().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let inbox = rt.new_inbox::().unwrap(); - let addr = rt.spawn(actor).unwrap(); - - let mut echoed = 0; - for i in 0u8..6 { - rt.send_to(addr, framed_msg(inbox.addr(), &[i])).unwrap(); - rt.tick(); - if inbox.try_recv().is_some() { echoed += 1; } - } - assert_eq!(echoed, 3, "should echo on odd-numbered alloc calls only"); - - // Part B: echo guest under sustained load — bump allocator exhaustion - let echo = WasmActorBuilder::new(engine, guest_wasm("echo")).build().unwrap(); - let rt2 = Runtime::new(RuntimeConfig::default()); - let inbox2 = rt2.new_inbox::().unwrap(); - let addr2 = rt2.spawn(echo).unwrap(); - - let mut total_echoed = 0; - for _ in 0..2000 { - rt2.send_to(addr2, framed_msg(inbox2.addr(), b"ping")).unwrap(); - rt2.tick(); - if inbox2.try_recv().is_some() { total_echoed += 1; } - } - // Some succeed (before OOM), some fail (after OOM). Actor survives throughout. - assert!(total_echoed > 0, "at least some messages should echo"); - assert!(total_echoed < 2000, "bump allocator should eventually exhaust"); -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// Group 4: Handle trap & outbox semantics -// ═══════════════════════════════════════════════════════════════════════════════ - -#[test] -fn handle_trap_actor_survives() { - let engine = SharedEngine::new().unwrap(); - - // unreachable trap - let unreachable_wat = wat::parse_str(r#"(module - (import "swactor" "send" (func $send (param i32 i32 i32))) - (memory (export "memory") 1) - (func (export "alloc") (param i32) (result i32) i32.const 256) - (func (export "handle") (param i32 i32) unreachable) - )"#).unwrap(); - let actor = WasmActorBuilder::new(engine.clone(), unreachable_wat).build().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let addr = rt.spawn(actor).unwrap(); - - // Send 3 messages — all trap, all dropped - for _ in 0..3 { - rt.send_to(addr, ByteMessage(vec![1])).unwrap(); - rt.tick(); - } - - // Division by zero trap - let divzero_wat = wat::parse_str(r#"(module - (import "swactor" "send" (func $send (param i32 i32 i32))) - (memory (export "memory") 1) - (func (export "alloc") (param i32) (result i32) i32.const 256) - (func (export "handle") (param $ptr i32) (param $len i32) - (drop (i32.div_u (local.get $len) (i32.const 0))) - ) - )"#).unwrap(); - let actor2 = WasmActorBuilder::new(engine, divzero_wat).build().unwrap(); - let addr2 = rt.spawn(actor2).unwrap(); - rt.send_to(addr2, ByteMessage(vec![1])).unwrap(); - rt.tick(); // no panic -} - -#[test] -fn outbox_cleared_on_trap() { - // Guest sends once (valid), then traps. Outbox should be cleared. - let wat = r#"(module - (import "swactor" "send" (func $send (param i32 i32 i32))) - (memory (export "memory") 1) - (func (export "alloc") (param i32) (result i32) i32.const 256) - (func (export "handle") (param $ptr i32) (param $len i32) - ;; Send a valid message - (call $send (local.get $ptr) (local.get $ptr) (i32.const 1)) - ;; Then trap - unreachable - ) - )"#; - let engine = SharedEngine::new().unwrap(); - let actor = WasmActorBuilder::new(engine, wat::parse_str(wat).unwrap()).build().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let inbox = rt.new_inbox::().unwrap(); - let addr = rt.spawn(actor).unwrap(); - - rt.send_to(addr, framed_msg(inbox.addr(), b"data")).unwrap(); - rt.tick(); - assert!(inbox.try_recv().is_none(), "outbox cleared on trap — no messages delivered"); - - // Actor survives - rt.send_to(addr, ByteMessage(vec![1])).unwrap(); - rt.tick(); -} - -proptest! { - #[test] - fn prop_any_send_args_never_crash_host( - dest_ptr in any::(), - payload_ptr in any::(), - payload_len in any::(), - ) { - let wasm = send_args_wat(dest_ptr, payload_ptr, payload_len); - let engine = SharedEngine::new().unwrap(); - let actor = WasmActorBuilder::new(engine, wasm).build().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let addr = rt.spawn(actor).unwrap(); - - rt.send_to(addr, ByteMessage(vec![0u8; 64])).unwrap(); - rt.tick(); // never panic - } -} - -#[test] -fn send_boundary_conditions() { - let engine = SharedEngine::new().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let inbox = rt.new_inbox::().unwrap(); - - // Zero-length payload sends empty ByteMessage - let zero_len = wat::parse_str(r#"(module - (import "swactor" "send" (func $send (param i32 i32 i32))) - (memory (export "memory") 1) - (func (export "alloc") (param i32) (result i32) i32.const 256) - (func (export "handle") (param $ptr i32) (param $len i32) - (call $send (local.get $ptr) (i32.const 0) (i32.const 0)) - ) - )"#).unwrap(); - let actor = WasmActorBuilder::new(engine.clone(), zero_len).build().unwrap(); - let addr = rt.spawn(actor).unwrap(); - rt.send_to(addr, framed_msg(inbox.addr(), b"x")).unwrap(); - rt.tick(); - let msg = inbox.try_recv().expect("zero-len payload should deliver"); - assert!(msg.0.is_empty()); - - // Dest at exact memory boundary: ptr=65504, needs 32 bytes → end=65536 = memory size. Works. - let exact_end = send_args_wat(65504, 0, 1); - let actor2 = WasmActorBuilder::new(engine.clone(), exact_end).build().unwrap(); - let addr2 = rt.spawn(actor2).unwrap(); - rt.send_to(addr2, ByteMessage(vec![0u8; 64])).unwrap(); - rt.tick(); // should not trap (exact fit) - - // Dest one past boundary: ptr=65505 → end=65537 > 65536. Traps. - let one_past = send_args_wat(65505, 0, 1); - let actor3 = WasmActorBuilder::new(engine.clone(), one_past).build().unwrap(); - let addr3 = rt.spawn(actor3).unwrap(); - rt.send_to(addr3, ByteMessage(vec![0u8; 64])).unwrap(); - rt.tick(); // traps but actor survives - - // Send to garbage address — silently dropped (no matching inbox) - let garbage = wat::parse_str(r#"(module - (import "swactor" "send" (func $send (param i32 i32 i32))) - (memory (export "memory") 1) - (data (i32.const 500) "\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff") - (func (export "alloc") (param i32) (result i32) i32.const 256) - (func (export "handle") (param $ptr i32) (param $len i32) - (call $send (i32.const 500) (i32.const 0) (i32.const 1)) - ) - )"#).unwrap(); - let actor4 = WasmActorBuilder::new(engine, garbage).build().unwrap(); - let addr4 = rt.spawn(actor4).unwrap(); - rt.send_to(addr4, ByteMessage(vec![1])).unwrap(); - rt.tick(); - assert!(inbox.try_recv().is_none(), "garbage addr → no delivery to our inbox"); -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// Group 5: Lifecycle & integration -// ═══════════════════════════════════════════════════════════════════════════════ - -#[test] -fn full_lifecycle_story() { - let engine = SharedEngine::new().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let inbox = rt.new_inbox::().unwrap(); - - // Spawn echo, use it - let echo = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap(); - let addr1 = rt.spawn(echo).unwrap(); - rt.send_to(addr1, framed_msg(inbox.addr(), b"alive")).unwrap(); - rt.tick(); - assert_eq!(inbox.try_recv().unwrap().0, b"alive"); - - // Stop it - let _ = rt.stop_actor(addr1); - rt.tick(); - rt.tick(); - - // Send to dead actor — silently dropped - let _ = rt.send_to(addr1, framed_msg(inbox.addr(), b"dead")); - rt.tick(); - assert!(inbox.try_recv().is_none()); - - // Respawn — different address, still works - let echo2 = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap(); - let addr2 = rt.spawn(echo2).unwrap(); - assert_ne!(addr1, addr2, "respawned actor gets different address"); - rt.send_to(addr2, framed_msg(inbox.addr(), b"new")).unwrap(); - rt.tick(); - assert_eq!(inbox.try_recv().unwrap().0, b"new"); - - // Double-stop is fine - let _ = rt.stop_actor(addr2); - let _ = rt.stop_actor(addr2); - rt.tick(); -} - -struct Forwarder { - engine: SharedEngine, - wasm_bytes: Vec, - inbox_addr: ActorAddress, -} - -#[derive(Clone)] -struct ForwardMsg(Vec); - -impl ActorInterface for Forwarder { - type Incoming = ForwardMsg; - type Response = (); - fn handle(&mut self, ctx: &Ctx, msg: ForwardMsg) { - let wasm = WasmActorBuilder::new(self.engine.clone(), self.wasm_bytes.clone()) - .build() - .unwrap(); - let wasm_addr = ctx.spawn(wasm).unwrap(); - let _ = ctx.send(wasm_addr, framed_msg(&self.inbox_addr, &msg.0)); - } -} - -#[test] -fn wasm_native_interop() { - let engine = SharedEngine::new().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let inbox = rt.new_inbox::().unwrap(); - - // Native forwarder spawns a WASM echo actor and forwards a message to it - let forwarder = Forwarder { - engine: engine.clone(), - wasm_bytes: guest_wasm("echo"), - inbox_addr: inbox.addr().clone(), - }; - let fwd_addr = rt.spawn(forwarder).unwrap(); - rt.send_to(fwd_addr, ForwardMsg(b"via-native".to_vec())).unwrap(); - for _ in 0..5 { rt.tick(); } - assert_eq!(inbox.try_recv().unwrap().0, b"via-native"); -} - -#[test] -fn wasm_relay_chain() { - // A→B→C→inbox: each echo actor strips 32 bytes of address and sends payload to that address - let engine = SharedEngine::new().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let inbox = rt.new_inbox::().unwrap(); - - let a = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap(); - let b = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap(); - let c = WasmActorBuilder::new(engine, guest_wasm("echo")).build().unwrap(); - let addr_a = rt.spawn(a).unwrap(); - let addr_b = rt.spawn(b).unwrap(); - let addr_c = rt.spawn(c).unwrap(); - - // Nested framed: [addr_b | addr_c | addr_inbox | "end"] - let mut payload = Vec::new(); - payload.extend_from_slice(&addr_b.0); - payload.extend_from_slice(&addr_c.0); - payload.extend_from_slice(&inbox.addr().0); - payload.extend_from_slice(b"end"); - - rt.send_to(addr_a, ByteMessage(payload)).unwrap(); - for _ in 0..3 { rt.tick(); } - - // After 3 hops, inbox should have the final payload "end" - // A sends [addr_c | addr_inbox | "end"] to B - // B sends [addr_inbox | "end"] to C - // C sends "end" to inbox - let msg = inbox.try_recv().expect("relay chain should deliver"); - assert_eq!(msg.0, b"end"); -} - -struct DeathCounter { - count: std::sync::Arc, -} - -#[derive(Clone)] -struct WatchAddr(ActorAddress); - -impl ActorInterface for DeathCounter { - type Incoming = WatchAddr; - type Response = (); - fn handle(&mut self, ctx: &Ctx, msg: WatchAddr) { - ctx.watch(msg.0); - } - fn on_actor_exit(&mut self, _ctx: &Ctx, _exited: swactor::actor::ActorExited) { - self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - } -} - -#[test] -fn watch_notification() { - let engine = SharedEngine::new().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()) - .with_extension(std::sync::Arc::new(StdExtension::new())); - - let count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let target = WasmActorBuilder::new(engine, guest_wasm("silent")).build().unwrap(); - let target_addr = rt.spawn(target).unwrap(); - let watcher_addr = rt.spawn(DeathCounter { count: count.clone() }).unwrap(); - - rt.send_to(watcher_addr, WatchAddr(target_addr)).unwrap(); - for _ in 0..3 { rt.tick(); } - - let _ = rt.stop_actor(target_addr); - for _ in 0..5 { rt.tick(); } - - assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1); -} - -proptest! { - #[test] - fn prop_lifecycle_fuzz( - guest_idx in 0usize..3, - n_msgs in 0u8..20, - ticks_before in 1usize..5, - ticks_after in 1usize..5, - ) { - let names = ["echo", "double", "silent"]; - let engine = SharedEngine::new().unwrap(); - let actor = WasmActorBuilder::new(engine, guest_wasm(names[guest_idx])).build().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let inbox = rt.new_inbox::().unwrap(); - let addr = rt.spawn(actor).unwrap(); - - for _ in 0..n_msgs { - let _ = rt.send_to(addr, framed_msg(inbox.addr(), b"x")); - } - for _ in 0..ticks_before { rt.tick(); } - let _ = rt.stop_actor(addr); - for _ in 0..ticks_after { rt.tick(); } - while inbox.try_recv().is_some() {} - } -} - -proptest! { - #[test] - fn prop_mixed_guest_response_counts( - echo_n in 0usize..5, - double_n in 0usize..5, - silent_n in 0usize..5, - ) { - let engine = SharedEngine::new().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let inbox = rt.new_inbox::().unwrap(); - - for _ in 0..echo_n { - let a = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap(); - let addr = rt.spawn(a).unwrap(); - rt.send_to(addr, framed_msg(inbox.addr(), b"e")).unwrap(); - } - for _ in 0..double_n { - let a = WasmActorBuilder::new(engine.clone(), guest_wasm("double")).build().unwrap(); - let addr = rt.spawn(a).unwrap(); - rt.send_to(addr, framed_msg(inbox.addr(), b"d")).unwrap(); - } - for _ in 0..silent_n { - let a = WasmActorBuilder::new(engine.clone(), guest_wasm("silent")).build().unwrap(); - let addr = rt.spawn(a).unwrap(); - rt.send_to(addr, ByteMessage(b"s".to_vec())).unwrap(); - } - - rt.tick(); - let total: usize = std::iter::from_fn(|| inbox.try_recv()).count(); - prop_assert_eq!(total, echo_n + 2 * double_n); - } -} - -#[test] -fn non_byte_message_silently_ignored() { - // Send a String (wrong type) to a WASM actor — should be silently dropped - let engine = SharedEngine::new().unwrap(); - let actor = WasmActorBuilder::new(engine, guest_wasm("echo")).build().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let inbox = rt.new_inbox::().unwrap(); - let addr = rt.spawn(actor).unwrap(); - - // send_to with wrong type — shouldn't panic - let _ = rt.send_to(addr, "wrong type".to_string()); - rt.tick(); - assert!(inbox.try_recv().is_none()); - - // Actor still alive - rt.send_to(addr, framed_msg(inbox.addr(), b"ok")).unwrap(); - rt.tick(); - assert_eq!(inbox.try_recv().unwrap().0, b"ok"); -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// Group 6: Engine & runtime config -// ═══════════════════════════════════════════════════════════════════════════════ - -#[test] -fn shared_engine_traits_and_clone() { - fn assert_send_sync() {} - fn assert_send() {} - assert_send_sync::(); - assert_send::(); - - let engine = SharedEngine::new().unwrap(); - let _ = format!("{:?}", engine); // Debug doesn't panic - - // Clone produces working independent engine - let clone = engine.clone(); - let a1 = WasmActorBuilder::new(engine, guest_wasm("echo")).build().unwrap(); - let a2 = WasmActorBuilder::new(clone, guest_wasm("echo")).build().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let inbox = rt.new_inbox::().unwrap(); - let addr1 = rt.spawn(a1).unwrap(); - let addr2 = rt.spawn(a2).unwrap(); - rt.send_to(addr1, framed_msg(inbox.addr(), b"c1")).unwrap(); - rt.send_to(addr2, framed_msg(inbox.addr(), b"c2")).unwrap(); - rt.tick(); - let mut msgs: Vec> = std::iter::from_fn(|| inbox.try_recv().map(|m| m.0)).collect(); - msgs.sort(); - assert_eq!(msgs, vec![b"c1".to_vec(), b"c2".to_vec()]); - - // ByteMessage traits - let bm = ByteMessage(vec![1, 2, 3]); - let bm2 = bm.clone(); - assert_eq!(bm, bm2); - let _ = format!("{:?}", bm); -} - -#[test] -fn multi_thread_runtime() { - let engine = SharedEngine::new().unwrap(); - let config = RuntimeConfig { num_threads: 4, ..RuntimeConfig::default() }; - let rt = Runtime::new(config); - let inbox = rt.new_inbox::().unwrap(); - - let mut addrs = Vec::new(); - for _ in 0..10 { - let actor = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap(); - addrs.push(rt.spawn(actor).unwrap()); - } - for (i, addr) in addrs.iter().enumerate() { - rt.send_to(*addr, framed_msg(inbox.addr(), &[i as u8])).unwrap(); - } - - let handle = rt.run().unwrap(); - let mut received = Vec::new(); - for _ in 0..40 { - std::thread::sleep(std::time::Duration::from_millis(25)); - while let Some(msg) = inbox.try_recv() { - received.push(msg.0[0]); - } - if received.len() == 10 { break; } - } - handle.shutdown(); - received.sort(); - assert_eq!(received, (0..10u8).collect::>()); -} - -#[test] -fn budget_and_mailbox() { - let engine = SharedEngine::new().unwrap(); - - // Budget=1: one message processed per tick - let mut cfg = RuntimeConfig::default(); - cfg.actor_message_budget = 1; - let rt = Runtime::new(cfg); - let inbox = rt.new_inbox::().unwrap(); - let echo = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap(); - let addr = rt.spawn(echo).unwrap(); - rt.send_to(addr, framed_msg(inbox.addr(), b"a")).unwrap(); - rt.send_to(addr, framed_msg(inbox.addr(), b"b")).unwrap(); - rt.send_to(addr, framed_msg(inbox.addr(), b"c")).unwrap(); - rt.tick(); - let first_tick: usize = std::iter::from_fn(|| inbox.try_recv()).count(); - assert_eq!(first_tick, 1, "budget=1 processes exactly 1 per tick"); - rt.tick(); - rt.tick(); - let rest: usize = std::iter::from_fn(|| inbox.try_recv()).count(); - assert_eq!(rest, 2, "remaining 2 processed over next 2 ticks"); - - // Self-send loop bounded by budget — no explosion - let mut cfg2 = RuntimeConfig::default(); - cfg2.actor_message_budget = 2; - let rt2 = Runtime::new(cfg2); - let echo2 = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap(); - let self_addr = rt2.spawn(echo2).unwrap(); - rt2.send_to(self_addr, framed_msg(&self_addr, b"loop")).unwrap(); - for _ in 0..5 { rt2.tick(); } // no panic, bounded - - // DropOldest mailbox - use swactor::runtime::MailboxOverflow; - let mut cfg3 = RuntimeConfig::default(); - cfg3.default_mailbox_capacity = 3; - cfg3.mailbox_overflow = MailboxOverflow::DropOldest; - let rt3 = Runtime::new(cfg3); - let inbox3 = rt3.new_inbox::().unwrap(); - let echo3 = WasmActorBuilder::new(engine, guest_wasm("echo")).build().unwrap(); - let addr3 = rt3.spawn(echo3).unwrap(); - for i in 0..10u8 { - rt3.send_to(addr3, framed_msg(inbox3.addr(), &[i])).unwrap(); - } - rt3.tick(); - let responses: Vec<_> = std::iter::from_fn(|| inbox3.try_recv()).collect(); - assert!(responses.len() <= 3, "mailbox capacity limits processing: got {}", responses.len()); -} - -#[test] -fn multiple_runtimes_and_scale() { - let engine = SharedEngine::new().unwrap(); - - // Two runtimes share engine - let rt1 = Runtime::new(RuntimeConfig::default()); - let rt2 = Runtime::new(RuntimeConfig::default()); - let inbox1 = rt1.new_inbox::().unwrap(); - let inbox2 = rt2.new_inbox::().unwrap(); - let a1 = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap(); - let a2 = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap(); - let addr1 = rt1.spawn(a1).unwrap(); - let addr2 = rt2.spawn(a2).unwrap(); - rt1.send_to(addr1, framed_msg(inbox1.addr(), b"rt1")).unwrap(); - rt2.send_to(addr2, framed_msg(inbox2.addr(), b"rt2")).unwrap(); - rt1.tick(); - rt2.tick(); - assert_eq!(inbox1.try_recv().unwrap().0, b"rt1"); - assert_eq!(inbox2.try_recv().unwrap().0, b"rt2"); - - // 50 actors from same engine - let rt3 = Runtime::new(RuntimeConfig::default()); - let inbox3 = rt3.new_inbox::().unwrap(); - for i in 0..50u8 { - let a = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap(); - let addr = rt3.spawn(a).unwrap(); - rt3.send_to(addr, framed_msg(inbox3.addr(), &[i])).unwrap(); - } - rt3.tick(); - let count: usize = std::iter::from_fn(|| inbox3.try_recv()).count(); - assert_eq!(count, 50); - - // Build+drop 100 actors without spawning — no leak - for _ in 0..100 { - let _ = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap(); - } -} - -#[test] -fn ordering_and_determinism() { - let engine = SharedEngine::new().unwrap(); - - // FIFO ordering - let rt = Runtime::new(RuntimeConfig::default()); - let inbox = rt.new_inbox::().unwrap(); - let echo = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap(); - let addr = rt.spawn(echo).unwrap(); - for i in 0..20u8 { - rt.send_to(addr, framed_msg(inbox.addr(), &[i])).unwrap(); - } - rt.tick(); - let msgs: Vec = std::iter::from_fn(|| inbox.try_recv().map(|m| m.0[0])).collect(); - assert_eq!(msgs, (0..20u8).collect::>(), "FIFO preserved"); - - // Determinism: same input → same output across runs - let mut results = Vec::new(); - for _ in 0..5 { - let echo = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let inbox = rt.new_inbox::().unwrap(); - let addr = rt.spawn(echo).unwrap(); - rt.send_to(addr, framed_msg(inbox.addr(), b"deterministic")).unwrap(); - rt.tick(); - results.push(inbox.try_recv().unwrap().0); - } - assert!(results.windows(2).all(|w| w[0] == w[1]), "deterministic across runs"); -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// Group 7: WASM feature coverage & guest computation -// ═══════════════════════════════════════════════════════════════════════════════ - -#[test] -fn kitchen_sink_wat_module() { - // Single WAT module exercising as many supported WASM features as possible. - // If this builds and doesn't trap, the engine config is correct. - let wat = r#"(module - (import "swactor" "send" (func $send (param i32 i32 i32))) - (memory (export "memory") 2) - - ;; Data segments - (data (i32.const 0) "kitchen-sink") - - ;; Globals: mutable and immutable - (global $counter (mut i32) (i32.const 0)) - (global $MAGIC i32 (i32.const 42)) - - ;; Table for call_indirect - (table 2 funcref) - (elem (i32.const 0) $helper_add $helper_sub) - - ;; Internal helper functions - (func $helper_add (param i32 i32) (result i32) - (i32.add (local.get 0) (local.get 1))) - (func $helper_sub (param i32 i32) (result i32) - (i32.sub (local.get 0) (local.get 1))) - (func $deep_call (param $x i32) (result i32) - (i32.mul (local.get $x) (i32.const 2))) - - (func (export "alloc") (param $len i32) (result i32) i32.const 4096) - (func (export "handle") (param $ptr i32) (param $len i32) - (local $a i32) - (local $b i64) - (local $c f32) - (local $d f64) - (local $i i32) - (local $tmp i32) - - ;; Increment global counter - (global.set $counter (i32.add (global.get $counter) (i32.const 1))) - - ;; Read immutable global - (local.set $a (global.get $MAGIC)) - - ;; i32 arithmetic - (local.set $a (i32.add (local.get $a) (i32.const 10))) - (local.set $a (i32.sub (local.get $a) (i32.const 5))) - (local.set $a (i32.mul (local.get $a) (i32.const 3))) - (local.set $a (i32.div_u (local.get $a) (i32.const 2))) - (local.set $a (i32.rem_u (local.get $a) (i32.const 100))) - - ;; Bitwise operations - (local.set $a (i32.and (local.get $a) (i32.const 0xFF))) - (local.set $a (i32.or (local.get $a) (i32.const 0x10))) - (local.set $a (i32.xor (local.get $a) (i32.const 0x01))) - (local.set $a (i32.shl (local.get $a) (i32.const 2))) - (local.set $a (i32.shr_u (local.get $a) (i32.const 1))) - (local.set $a (i32.shr_s (local.get $a) (i32.const 1))) - (local.set $a (i32.rotl (local.get $a) (i32.const 3))) - (local.set $a (i32.rotr (local.get $a) (i32.const 3))) - - ;; Bit counting - (drop (i32.clz (local.get $a))) - (drop (i32.ctz (local.get $a))) - (drop (i32.popcnt (local.get $a))) - (drop (i32.eqz (local.get $a))) - - ;; Comparisons - (drop (i32.gt_s (local.get $a) (i32.const 0))) - (drop (i32.lt_s (local.get $a) (i32.const 100))) - (drop (i32.le_u (local.get $a) (i32.const 200))) - (drop (i32.ge_s (local.get $a) (i32.const -1))) - - ;; i64 operations - (local.set $b (i64.const 9999999999)) - (local.set $b (i64.add (local.get $b) (i64.const 1))) - (i64.store (i32.const 800) (local.get $b)) - (drop (i64.load (i32.const 800))) - - ;; i64 ↔ i32 conversions - (drop (i32.wrap_i64 (local.get $b))) - (drop (i64.extend_i32_s (local.get $a))) - - ;; f32 operations - (local.set $c (f32.const 3.14)) - (local.set $c (f32.add (local.get $c) (f32.const 1.0))) - (local.set $c (f32.mul (local.get $c) (f32.const 2.0))) - (f32.store (i32.const 900) (local.get $c)) - (drop (f32.load (i32.const 900))) - - ;; f64 operations - (local.set $d (f64.promote_f32 (local.get $c))) - (local.set $d (f64.mul (local.get $d) (f64.const 0.5))) - (f64.store (i32.const 920) (local.get $d)) - - ;; Sign extension - (drop (i32.extend8_s (i32.const 0x80))) - (drop (i32.extend16_s (i32.const 0x8000))) - - ;; Memory store/load variants - (i32.store8 (i32.const 700) (i32.const 0xAB)) - (i32.store16 (i32.const 702) (i32.const 0xCDEF)) - (drop (i32.load8_u (i32.const 700))) - (drop (i32.load16_u (i32.const 702))) - - ;; local.tee - (local.set $tmp (local.tee $a (i32.const 77))) - - ;; Nested function calls - (drop (call $deep_call (i32.const 5))) - (drop (call $helper_add (i32.const 10) (i32.const 20))) - - ;; call_indirect via table - (drop (call_indirect (type 0) (i32.const 7) (i32.const 3) (i32.const 0))) - (drop (call_indirect (type 0) (i32.const 7) (i32.const 3) (i32.const 1))) - - ;; Control flow: if/else - (if (i32.gt_s (local.get $len) (i32.const 0)) - (then nop) - (else nop) - ) - - ;; select - (drop (select (i32.const 10) (i32.const 20) (i32.const 1))) - - ;; block + br_if - (block $skip - (br_if $skip (i32.eqz (local.get $len))) - nop - ) - - ;; loop with counter - (local.set $i (i32.const 0)) - (block $exit - (loop $loop - (br_if $exit (i32.ge_u (local.get $i) (i32.const 5))) - (local.set $i (i32.add (local.get $i) (i32.const 1))) - (br $loop) - ) - ) - - ;; br_table dispatch - (block $b0 (block $b1 (block $b2 - (br_table $b0 $b1 $b2 (i32.const 1)) - ) nop) nop) ;; falls through - - ;; Bulk memory: fill and copy - (memory.fill (i32.const 600) (i32.const 0xAA) (i32.const 32)) - (memory.copy (i32.const 650) (i32.const 600) (i32.const 32)) - - ;; memory.size and memory.grow - (drop (memory.size)) - (drop (memory.grow (i32.const 1))) - - ;; nop - nop - ) - - ;; Type for call_indirect - (type (func (param i32 i32) (result i32))) - )"#; - let engine = SharedEngine::new().unwrap(); - let actor = WasmActorBuilder::new(engine, wat::parse_str(wat).unwrap()).build().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let addr = rt.spawn(actor).unwrap(); - - // Send message, tick — no trap - rt.send_to(addr, ByteMessage(vec![1, 2, 3, 4])).unwrap(); - rt.tick(); - - // Send again — global counter increments, still no trap - rt.send_to(addr, ByteMessage(vec![5, 6, 7, 8])).unwrap(); - rt.tick(); -} - -#[test] -fn guest_mutable_state_persists() { - // Global counter increments on each handle call, sends count back - let wat = r#"(module - (import "swactor" "send" (func $send (param i32 i32 i32))) - (memory (export "memory") 1) - (global $count (mut i32) (i32.const 0)) - (func (export "alloc") (param $len i32) (result i32) i32.const 4096) - (func (export "handle") (param $ptr i32) (param $len i32) - (if (i32.lt_u (local.get $len) (i32.const 32)) (then return)) - (global.set $count (i32.add (global.get $count) (i32.const 1))) - (i32.store (i32.const 200) (global.get $count)) - (call $send (local.get $ptr) (i32.const 200) (i32.const 4)) - ) - )"#; - let engine = SharedEngine::new().unwrap(); - let actor = WasmActorBuilder::new(engine, wat::parse_str(wat).unwrap()).build().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let inbox = rt.new_inbox::().unwrap(); - let addr = rt.spawn(actor).unwrap(); - - for expected in 1..=5u32 { - rt.send_to(addr, framed_msg(inbox.addr(), b"x")).unwrap(); - rt.tick(); - let resp = inbox.try_recv().expect("should get count back"); - let count = u32::from_le_bytes([resp.0[0], resp.0[1], resp.0[2], resp.0[3]]); - assert_eq!(count, expected, "counter should persist across messages"); - } -} - -#[test] -fn guest_transforms_payload() { - let engine = SharedEngine::new().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let inbox = rt.new_inbox::().unwrap(); - - // XOR each byte with 0xFF (bitwise NOT) - let xor_wat = r#"(module - (import "swactor" "send" (func $send (param i32 i32 i32))) - (memory (export "memory") 1) - (func (export "alloc") (param $len i32) (result i32) i32.const 4096) - (func (export "handle") (param $ptr i32) (param $len i32) - (local $i i32) - (if (i32.lt_u (local.get $len) (i32.const 33)) (then return)) - (local.set $i (i32.const 32)) - (block $exit (loop $loop - (br_if $exit (i32.ge_u (local.get $i) (local.get $len))) - (i32.store8 - (i32.add (local.get $ptr) (local.get $i)) - (i32.xor (i32.load8_u (i32.add (local.get $ptr) (local.get $i))) (i32.const 0xFF))) - (local.set $i (i32.add (local.get $i) (i32.const 1))) - (br $loop) - )) - (call $send (local.get $ptr) - (i32.add (local.get $ptr) (i32.const 32)) - (i32.sub (local.get $len) (i32.const 32))) - ) - )"#; - let xor = WasmActorBuilder::new(engine.clone(), wat::parse_str(xor_wat).unwrap()).build().unwrap(); - let xaddr = rt.spawn(xor).unwrap(); - rt.send_to(xaddr, framed_msg(inbox.addr(), &[0x00, 0xFF, 0xAA])).unwrap(); - rt.tick(); - assert_eq!(inbox.try_recv().unwrap().0, vec![0xFF, 0x00, 0x55]); - - // Reverse payload - let rev_wat = r#"(module - (import "swactor" "send" (func $send (param i32 i32 i32))) - (memory (export "memory") 1) - (func (export "alloc") (param $len i32) (result i32) i32.const 4096) - (func (export "handle") (param $ptr i32) (param $len i32) - (local $i i32) (local $plen i32) (local $pstart i32) - (if (i32.lt_u (local.get $len) (i32.const 33)) (then return)) - (local.set $pstart (i32.add (local.get $ptr) (i32.const 32))) - (local.set $plen (i32.sub (local.get $len) (i32.const 32))) - (local.set $i (i32.const 0)) - (block $exit (loop $loop - (br_if $exit (i32.ge_u (local.get $i) (local.get $plen))) - (i32.store8 - (i32.add (i32.const 900) (local.get $i)) - (i32.load8_u (i32.add (local.get $pstart) - (i32.sub (i32.sub (local.get $plen) (i32.const 1)) (local.get $i))))) - (local.set $i (i32.add (local.get $i) (i32.const 1))) - (br $loop) - )) - (call $send (local.get $ptr) (i32.const 900) (local.get $plen)) - ) - )"#; - let rev = WasmActorBuilder::new(engine, wat::parse_str(rev_wat).unwrap()).build().unwrap(); - let raddr = rt.spawn(rev).unwrap(); - rt.send_to(raddr, framed_msg(inbox.addr(), b"abcde")).unwrap(); - rt.tick(); - assert_eq!(inbox.try_recv().unwrap().0, b"edcba"); -} - -#[test] -fn guest_multi_send_patterns() { - let engine = SharedEngine::new().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let inbox = rt.new_inbox::().unwrap(); - - // Guest sends to 2 different destinations in one handle call - let two_dest_wat = r#"(module - (import "swactor" "send" (func $send (param i32 i32 i32))) - (memory (export "memory") 1) - (func (export "alloc") (param $len i32) (result i32) i32.const 1024) - (func (export "handle") (param $ptr i32) (param $len i32) - ;; Layout: [addr1:32][addr2:32][payload:rest] - (if (i32.ge_u (local.get $len) (i32.const 65)) - (then - (call $send (local.get $ptr) - (i32.add (local.get $ptr) (i32.const 64)) - (i32.sub (local.get $len) (i32.const 64))) - (call $send (i32.add (local.get $ptr) (i32.const 32)) - (i32.add (local.get $ptr) (i32.const 64)) - (i32.sub (local.get $len) (i32.const 64))) - ) - ) - ) - )"#; - let actor = WasmActorBuilder::new(engine.clone(), wat::parse_str(two_dest_wat).unwrap()) - .build().unwrap(); - let inbox2 = rt.new_inbox::().unwrap(); - let addr = rt.spawn(actor).unwrap(); - - let mut msg = Vec::new(); - msg.extend_from_slice(&inbox.addr().0); // addr1 - msg.extend_from_slice(&inbox2.addr().0); // addr2 - msg.extend_from_slice(b"shared"); // payload - rt.send_to(addr, ByteMessage(msg)).unwrap(); - rt.tick(); - assert_eq!(inbox.try_recv().unwrap().0, b"shared"); - assert_eq!(inbox2.try_recv().unwrap().0, b"shared"); - - // Guest sends 100 messages in one handle call - let many_sends_wat = r#"(module - (import "swactor" "send" (func $send (param i32 i32 i32))) - (memory (export "memory") 1) - (func (export "alloc") (param $len i32) (result i32) i32.const 1024) - (func (export "handle") (param $ptr i32) (param $len i32) - (local $i i32) - (if (i32.lt_u (local.get $len) (i32.const 32)) (then return)) - (local.set $i (i32.const 0)) - (block $exit (loop $loop - (br_if $exit (i32.ge_u (local.get $i) (i32.const 100))) - (i32.store8 (i32.const 900) (local.get $i)) - (call $send (local.get $ptr) (i32.const 900) (i32.const 1)) - (local.set $i (i32.add (local.get $i) (i32.const 1))) - (br $loop) - )) - ) - )"#; - let actor2 = WasmActorBuilder::new(engine, wat::parse_str(many_sends_wat).unwrap()) - .build().unwrap(); - let addr2 = rt.spawn(actor2).unwrap(); - rt.send_to(addr2, framed_msg(inbox.addr(), b"go")).unwrap(); - rt.tick(); - let count: usize = std::iter::from_fn(|| inbox.try_recv()).count(); - assert_eq!(count, 100, "all 100 sends should be delivered"); -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// Group 8: Stress -// ═══════════════════════════════════════════════════════════════════════════════ - -#[test] -fn echo_sustained_load() { - let engine = SharedEngine::new().unwrap(); - let rt = Runtime::new(RuntimeConfig::default()); - let inbox = rt.new_inbox::().unwrap(); - - // 500 messages to echo actor - let echo = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap(); - let addr = rt.spawn(echo).unwrap(); - for i in 0..500u16 { - rt.send_to(addr, framed_msg(inbox.addr(), &i.to_le_bytes())).unwrap(); - } - for _ in 0..20 { rt.tick(); } - let count: usize = std::iter::from_fn(|| inbox.try_recv()).count(); - assert_eq!(count, 500, "all 500 messages echoed"); - - // Spawn and stop 100 actors — no panic, no leak - for _ in 0..100 { - let a = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap(); - let a_addr = rt.spawn(a).unwrap(); - rt.send_to(a_addr, framed_msg(inbox.addr(), b"x")).unwrap(); - rt.tick(); - let _ = rt.stop_actor(a_addr); - rt.tick(); - } - // Drain inbox - while inbox.try_recv().is_some() {} -} diff --git a/crates/python/Cargo.toml b/crates/bindings/python/Cargo.toml similarity index 85% rename from crates/python/Cargo.toml rename to crates/bindings/python/Cargo.toml index 00cd26e..3488195 100644 --- a/crates/python/Cargo.toml +++ b/crates/bindings/python/Cargo.toml @@ -8,5 +8,5 @@ name = "swactor" crate-type = ["cdylib"] [dependencies] -swactor = { path = "../.." } +swactor = { path = "../../.." } pyo3 = { version = "0.23", features = ["extension-module"] } diff --git a/crates/python/pyproject.toml b/crates/bindings/python/pyproject.toml similarity index 100% rename from crates/python/pyproject.toml rename to crates/bindings/python/pyproject.toml diff --git a/crates/python/src/lib.rs b/crates/bindings/python/src/lib.rs similarity index 100% rename from crates/python/src/lib.rs rename to crates/bindings/python/src/lib.rs diff --git a/crates/python/uv.lock b/crates/bindings/python/uv.lock similarity index 100% rename from crates/python/uv.lock rename to crates/bindings/python/uv.lock diff --git a/crates/wasm/crypto/Cargo.toml b/crates/bindings/wasm-crypto/Cargo.toml similarity index 100% rename from crates/wasm/crypto/Cargo.toml rename to crates/bindings/wasm-crypto/Cargo.toml diff --git a/crates/wasm/crypto/src/lib.rs b/crates/bindings/wasm-crypto/src/lib.rs similarity index 100% rename from crates/wasm/crypto/src/lib.rs rename to crates/bindings/wasm-crypto/src/lib.rs diff --git a/crates/wasm/runtime/Cargo.toml b/crates/bindings/wasm-runtime/Cargo.toml similarity index 66% rename from crates/wasm/runtime/Cargo.toml rename to crates/bindings/wasm-runtime/Cargo.toml index 156c268..a227dbc 100644 --- a/crates/wasm/runtime/Cargo.toml +++ b/crates/bindings/wasm-runtime/Cargo.toml @@ -7,6 +7,5 @@ edition = "2024" crate-type = ["cdylib"] [dependencies] -swactor = { path = "../../..", default-features = false, features = ["wasm"] } -swactor-std = { path = "../../std", default-features = false, features = ["wasm"] } +swactor = { path = "../../..", default-features = false, features = ["wasm", "std"] } wasm-bindgen = "0.2" diff --git a/crates/wasm/runtime/demo.html b/crates/bindings/wasm-runtime/demo.html similarity index 100% rename from crates/wasm/runtime/demo.html rename to crates/bindings/wasm-runtime/demo.html diff --git a/crates/wasm/runtime/src/lib.rs b/crates/bindings/wasm-runtime/src/lib.rs similarity index 99% rename from crates/wasm/runtime/src/lib.rs rename to crates/bindings/wasm-runtime/src/lib.rs index 1268b49..fafb01e 100644 --- a/crates/wasm/runtime/src/lib.rs +++ b/crates/bindings/wasm-runtime/src/lib.rs @@ -4,7 +4,7 @@ use wasm_bindgen::prelude::*; use swactor::actor::{ActorAddress, ActorExited, ActorInterface}; use swactor::runtime::{Ctx, Inbox, Runtime, RuntimeConfig}; -use swactor_std::{CtxGroups, CtxWatching, RuntimeNaming, RuntimeGroups, StdExtension}; +use swactor::std::{CtxGroups, CtxWatching, RuntimeNaming, RuntimeGroups, StdExtension}; // ─── Core JS-facing types ─────────────────────────────────────────────────── diff --git a/crates/wasm/runtime/test.mjs b/crates/bindings/wasm-runtime/test.mjs similarity index 100% rename from crates/wasm/runtime/test.mjs rename to crates/bindings/wasm-runtime/test.mjs diff --git a/crates/ci/Cargo.toml b/crates/ci/Cargo.toml deleted file mode 100644 index 4473d9c..0000000 --- a/crates/ci/Cargo.toml +++ /dev/null @@ -1,60 +0,0 @@ -[package] -name = "swactor-ci" -version = "0.1.0" -edition = "2024" - -[dependencies] -swactor = { path = "../..", features = ["serde"] } -serde = { version = "1", features = ["derive"] } -serde_yaml = "0.9" -serde_json = "1" - -# Shared optional deps (used by lib local feature and binaries) -tiny_http = { version = "0.12", optional = true } -ureq = { version = "2", features = ["json"], optional = true } -hmac = { version = "0.12", optional = true } -sha2 = { version = "0.10", optional = true } -hex = { version = "0.4", optional = true } - -# Binary-only optional deps -iroh = { version = "0.96", optional = true } -tokio = { version = "1", features = ["rt-multi-thread"], optional = true } -clap = { version = "4", features = ["derive"], optional = true } -ctrlc = { version = "3", optional = true } -dashboard = { path = "../dashboard", optional = true } - -[features] -default = [] -local = [ - "dep:tiny_http", - "dep:ureq", - "dep:hmac", - "dep:sha2", - "dep:hex", -] -relay = [ - "local", - "dep:iroh", - "dep:tokio", - "dep:clap", -] -runner = [ - "local", - "dep:iroh", - "dep:tokio", - "dep:clap", - "dep:ctrlc", - "dep:dashboard", -] - -[dev-dependencies] - -[[bin]] -name = "ci-relay" -path = "src/bin/ci_relay.rs" -required-features = ["relay"] - -[[bin]] -name = "local-runner" -path = "src/bin/local_runner.rs" -required-features = ["runner"] diff --git a/crates/ci/src/bin/ci_relay.rs b/crates/ci/src/bin/ci_relay.rs deleted file mode 100644 index 1271b43..0000000 --- a/crates/ci/src/bin/ci_relay.rs +++ /dev/null @@ -1,243 +0,0 @@ -//! ci-relay — webhook relay for VPS side. -//! -//! Receives Forgejo webhook POSTs over HTTP, then forwards the parsed -//! `WebhookEvent` payloads to the Thinkpad local-runner over iroh. - -use std::sync::Arc; -use std::time::Duration; - -use clap::Parser; -use iroh::{Endpoint, RelayMode}; -use tokio::sync::Mutex as TokioMutex; - -use swactor_ci::webhook_server::parse_webhook_json; -use swactor_ci::{EventType, WebhookEvent}; - -/// ALPN protocol identifier for CI relay traffic over iroh. -const ALPN: &[u8] = b"swactor/ci/1"; - -/// Wire tag for WebhookEvent messages. -const WEBHOOK_TAG: &str = "ci::WebhookEvent"; - -#[derive(Parser)] -#[command(name = "ci-relay", about = "Webhook relay: Forgejo → iroh → local-runner")] -struct Args { - /// HTTP port for receiving Forgejo webhooks. - #[arg(long, default_value = "8787")] - port: u16, - - /// Webhook secret for HMAC-SHA256 verification (empty to skip). - #[arg(long, default_value = "")] - secret: String, -} - -fn main() { - let args = Args::parse(); - - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .expect("failed to build tokio runtime"); - - let endpoint = rt.block_on(async { - Endpoint::builder() - .alpns(vec![ALPN.to_vec()]) - .relay_mode(RelayMode::Default) - .bind() - .await - .expect("failed to bind iroh endpoint") - }); - - let node_id = endpoint.id(); - eprintln!("ci-relay started"); - eprintln!(" Iroh Node ID: {node_id}"); - eprintln!(" Webhook HTTP: http://0.0.0.0:{}", args.port); - eprintln!(); - eprintln!("Waiting for runner to connect..."); - - // Shared state: the active connection from the Thinkpad runner. - let connection: Arc>> = - Arc::new(TokioMutex::new(None)); - - // Spawn a task that accepts inbound iroh connections from the runner. - { - let endpoint = endpoint.clone(); - let connection = Arc::clone(&connection); - rt.spawn(async move { - loop { - match endpoint.accept().await { - Some(incoming) => match incoming.await { - Ok(conn) => { - let remote = conn.remote_id(); - eprintln!("Runner connected: {remote}"); - *connection.lock().await = Some(conn); - } - Err(e) => { - eprintln!("iroh accept error: {e}"); - } - }, - None => { - eprintln!("iroh endpoint closed"); - break; - } - } - } - }); - } - - // Run the HTTP webhook listener on a standard thread (blocking). - let secret = args.secret.clone(); - let server = tiny_http::Server::http(format!("0.0.0.0:{}", args.port)) - .expect("failed to start HTTP server"); - - eprintln!("Listening for webhooks..."); - - for mut request in server.incoming_requests() { - let response = handle_webhook(&mut request, &secret, &connection, &rt); - let _ = request.respond(response); - } -} - -/// Handle an incoming webhook HTTP request. -/// -/// Parses and verifies the webhook, then forwards the event over iroh. -fn handle_webhook( - request: &mut tiny_http::Request, - secret: &str, - connection: &Arc>>, - rt: &tokio::runtime::Runtime, -) -> tiny_http::Response>> { - use hmac::{Hmac, Mac}; - use sha2::Sha256; - - if request.method() != &tiny_http::Method::Post { - return tiny_http::Response::from_string("method not allowed").with_status_code(405); - } - - // Read body. - let mut body = String::new(); - if let Err(e) = std::io::Read::read_to_string(&mut request.as_reader(), &mut body) { - eprintln!("webhook: failed to read body: {e}"); - return tiny_http::Response::from_string("bad request").with_status_code(400); - } - - // Verify HMAC-SHA256 signature if secret is non-empty. - if !secret.is_empty() { - let sig_header = request - .headers() - .iter() - .find(|h| h.field.equiv("X-Forgejo-Signature")) - .map(|h| h.value.as_str().to_string()); - - match sig_header { - Some(sig_hex) => { - type HmacSha256 = Hmac; - let mut mac = - HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC key creation"); - hmac::Mac::update(&mut mac, body.as_bytes()); - let expected = hex::encode(mac.finalize().into_bytes()); - if sig_hex != expected { - eprintln!("webhook: signature mismatch"); - return tiny_http::Response::from_string("unauthorized").with_status_code(401); - } - } - None => { - eprintln!("webhook: missing signature header"); - return tiny_http::Response::from_string("unauthorized").with_status_code(401); - } - } - } - - // Determine event type from Forgejo header. - let event_header = request - .headers() - .iter() - .find(|h| h.field.equiv("X-Forgejo-Event")) - .map(|h| h.value.as_str().to_string()) - .unwrap_or_default(); - - let event_type = match event_header.as_str() { - "push" => EventType::Push, - "create" => EventType::Tag, - "pull_request" => EventType::Merge, - other => { - eprintln!("webhook: ignoring event type '{other}'"); - return tiny_http::Response::from_string("ignored").with_status_code(200); - } - }; - - // Parse JSON body. - let json: serde_json::Value = match serde_json::from_str(&body) { - Ok(v) => v, - Err(e) => { - eprintln!("webhook: failed to parse JSON: {e}"); - return tiny_http::Response::from_string("bad json").with_status_code(400); - } - }; - - let webhook_event = match parse_webhook_json(&json, event_type) { - Some(e) => e, - None => { - eprintln!("webhook: could not extract fields from JSON"); - return tiny_http::Response::from_string("bad payload").with_status_code(400); - } - }; - - eprintln!( - "webhook: {} {} on {}/{}", - webhook_event.commit_sha.get(..8).unwrap_or(&webhook_event.commit_sha), - webhook_event.branch, - webhook_event.repo_owner, - webhook_event.repo_name, - ); - - // Forward over iroh. - match forward_event(&webhook_event, connection, rt) { - Ok(()) => { - eprintln!(" → forwarded to runner"); - tiny_http::Response::from_string("ok").with_status_code(200) - } - Err(e) => { - eprintln!(" → forward failed: {e}"); - tiny_http::Response::from_string("relay error").with_status_code(502) - } - } -} - -/// Serialize and send a WebhookEvent over the iroh connection. -fn forward_event( - event: &WebhookEvent, - connection: &Arc>>, - rt: &tokio::runtime::Runtime, -) -> Result<(), Box> { - let payload = serde_json::to_vec(event)?; - - rt.block_on(async { - let guard = connection.lock().await; - let conn = guard.as_ref().ok_or("no runner connected")?; - - let mut send = conn.open_uni().await?; - write_tagged_message(&mut send, WEBHOOK_TAG.as_bytes(), &payload).await?; - send.finish()?; - - // Wait briefly for the stream to flush. - tokio::time::sleep(Duration::from_millis(50)).await; - - Ok(()) - }) -} - -/// Write a tagged message to a QUIC send stream. -/// -/// Frame format: `[4B tag_len][tag_bytes][payload_bytes]` -async fn write_tagged_message( - send: &mut iroh::endpoint::SendStream, - tag: &[u8], - payload: &[u8], -) -> Result<(), Box> { - let tag_len = (tag.len() as u32).to_be_bytes(); - send.write_all(&tag_len).await?; - send.write_all(tag).await?; - send.write_all(payload).await?; - Ok(()) -} diff --git a/crates/ci/src/bin/local_runner.rs b/crates/ci/src/bin/local_runner.rs deleted file mode 100644 index 67adac4..0000000 --- a/crates/ci/src/bin/local_runner.rs +++ /dev/null @@ -1,386 +0,0 @@ -//! local-runner — single-machine CI runner for the Thinkpad. -//! -//! Receives Forgejo webhooks, queues pipelines, and executes jobs -//! one at a time for benchmark isolation. - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use std::thread; -use std::time::Duration; - -use clap::Parser; - -use swactor::actor::ActorAddress; -use swactor::config::RuntimeConfig; -use swactor::runtime::Runtime; - -use dashboard::ci_collector::{ - CiSnapshot, CiStatsProvider, JobSnapshot, PipelineSnapshot, ProvisionerStatus, -}; - -use swactor_ci::local_coordinator::{LocalCiSnapshot, LocalCoordinator, LocalCoordinatorMsg}; -use swactor_ci::pipeline::PipelineExecution; -use swactor_ci::status_reporter::StatusReporter; -use swactor_ci::webhook_server; -use swactor_ci::yaml; -use swactor_ci::{CiConfig, LocalCiConfig}; - -#[derive(Parser)] -#[command(name = "local-runner", about = "Swactor local CI runner")] -struct Args { - /// Webhook listen port. - #[arg(long, default_value = "8787")] - port: u16, - - /// Forgejo instance URL. - #[arg(long, default_value = "")] - forgejo_url: String, - - /// Forgejo API token. - #[arg(long, default_value = "")] - forgejo_token: String, - - /// Webhook secret for HMAC verification (empty to skip). - #[arg(long, default_value = "")] - secret: String, - - /// Path to .ci.yml file. - #[arg(long, default_value = ".ci.yml")] - yaml: String, - - /// Base directory for git checkouts. - #[arg(long, default_value = "./ci-work")] - work_dir: String, - - /// Git clone URL for the repository. - #[arg(long, default_value = "")] - repo_url: String, - - /// Dashboard HTTP port (omit to disable). - #[arg(long)] - dashboard_port: Option, - - /// Iroh Node ID of the ci-relay on the VPS (hex). - /// When set, webhooks arrive via iroh instead of HTTP. - #[arg(long)] - relay_node_id: Option, -} - -/// Bridge from LocalCiSnapshot to CiSnapshot for the dashboard. -struct LocalCiSnapshotProvider { - snapshot: Arc>, -} - -impl CiStatsProvider for LocalCiSnapshotProvider { - fn snapshot(&self) -> CiSnapshot { - let local = self.snapshot.lock().unwrap().clone(); - CiSnapshot { - active_pipelines: local - .active_pipelines - .iter() - .map(pipeline_to_dashboard) - .collect(), - recent_pipelines: local - .recent_pipelines - .iter() - .map(pipeline_to_dashboard) - .collect(), - provisioner_status: ProvisionerStatus::Online, - active_instances: if local.has_running_job { 1 } else { 0 }, - } - } -} - -fn pipeline_to_dashboard(p: &PipelineExecution) -> PipelineSnapshot { - // Convert between swactor_ci types and dashboard::ci_collector types - // (structurally identical but separate to avoid a dependency cycle). - let status: dashboard::ci_collector::PipelineStatus = - serde_json::from_value(serde_json::to_value(&p.status).unwrap()).unwrap(); - - PipelineSnapshot { - pipeline_id: dashboard::ci_collector::PipelineId(p.pipeline_id.0), - pipeline_name: p.pipeline_name.clone(), - repo_owner: p.repo_owner.clone(), - repo_name: p.repo_name.clone(), - commit_sha: p.commit_sha.clone(), - branch: p.branch.clone(), - status, - jobs: p - .jobs - .values() - .map(|j| { - let job_status: dashboard::ci_collector::JobStatus = - serde_json::from_value(serde_json::to_value(&j.status).unwrap()).unwrap(); - JobSnapshot { - job_id: dashboard::ci_collector::JobId { - pipeline_id: dashboard::ci_collector::PipelineId(j.job_id.pipeline_id.0), - job_name: j.job_id.job_name.clone(), - }, - job_name: j.definition.name.clone(), - status: job_status, - output_line_count: j.output_lines.len(), - } - }) - .collect(), - } -} - -fn main() { - let args = Args::parse(); - let stop = Arc::new(AtomicBool::new(false)); - - // Signal handler. - { - let stop = Arc::clone(&stop); - ctrlc::set_handler(move || { - stop.store(true, Ordering::Relaxed); - }) - .expect("failed to set signal handler"); - } - - // Optionally start dashboard. - let dash = args.dashboard_port.map(|port| { - let d = dashboard::start_dashboard(dashboard::DashboardConfig { - port, - ..Default::default() - }); - d.install_tracing(); - d - }); - - // Create 2-thread runtime. - let num_threads = 2; - let collector = dashboard::collector::StatsCollector::new(num_threads); - let mut rt = Runtime::new(RuntimeConfig { - num_threads, - max_actors: 256, - channel_buffer_size: 2000, - ..Default::default() - }); - rt.set_stats_hook(collector.clone()); - - // Build config. - let ci_config = CiConfig { - webhook_port: args.port, - webhook_secret: args.secret.clone(), - forgejo_url: args.forgejo_url.clone(), - forgejo_token: args.forgejo_token.clone(), - data_dir: args.work_dir.clone(), - }; - let local_config = LocalCiConfig { - ci: ci_config, - repo_url: args.repo_url.clone(), - work_dir: args.work_dir.clone(), - ci_yaml_path: args.yaml.clone(), - }; - - // Spawn StatusReporter. - let reporter_addr = rt - .spawn(StatusReporter::new()) - .expect("failed to spawn StatusReporter"); - - // Spawn LocalCoordinator. - let coordinator = LocalCoordinator::new(local_config).with_status_reporter(reporter_addr); - let ci_snapshot = coordinator.ci_snapshot(); - let coordinator_addr = rt - .spawn(coordinator) - .expect("failed to spawn LocalCoordinator"); - - // Load CI YAML from disk. - let yaml_content = std::fs::read_to_string(&args.yaml) - .unwrap_or_else(|e| panic!("failed to read {}: {e}", args.yaml)); - let ci_yaml = yaml::parse_ci_yaml(&yaml_content) - .unwrap_or_else(|e| panic!("failed to parse CI YAML: {e}")); - - // Start runtime. - let handle = rt.run().expect("failed to start runtime"); - - // Send CiYaml to coordinator. - let _ = handle - .runtime - .send_to(coordinator_addr, LocalCoordinatorMsg::SetCiYaml(ci_yaml)); - - // Wire dashboard. - if let Some(ref d) = dash { - d.set_runtime(Arc::clone(&handle.runtime), collector); - let provider = Arc::new(LocalCiSnapshotProvider { - snapshot: ci_snapshot, - }); - d.set_ci(provider); - } - - // Start webhook source: iroh relay or HTTP listener. - if let Some(ref relay_id_hex) = args.relay_node_id { - start_iroh_receiver( - relay_id_hex, - Arc::clone(&handle.runtime), - coordinator_addr, - Arc::clone(&stop), - ); - } else { - let _webhook_handle = webhook_server::start_webhook_listener( - args.port, - args.secret, - Arc::clone(&handle.runtime), - coordinator_addr, - ); - } - - eprintln!("Local CI runner started"); - if args.relay_node_id.is_some() { - eprintln!(" Webhook: via iroh relay"); - } else { - eprintln!(" Webhook: http://0.0.0.0:{}", args.port); - } - eprintln!(" YAML: {}", args.yaml); - eprintln!(" Workdir: {}", args.work_dir); - if let Some(port) = args.dashboard_port { - eprintln!(" Dashboard: http://0.0.0.0:{port}"); - } - - // Main loop — just wait for ctrlc. - while !stop.load(Ordering::Relaxed) { - thread::sleep(Duration::from_millis(100)); - } - - eprintln!("\nShutting down..."); - handle.shutdown(); - if let Some(d) = dash { - d.shutdown(); - } - handle.join(); -} - -// ─── Iroh Webhook Receiver ────────────────────────────────────────────────── - -/// ALPN protocol identifier — must match ci-relay. -const CI_ALPN: &[u8] = b"swactor/ci/1"; - -/// Connect to the VPS ci-relay via iroh and receive WebhookEvents. -/// -/// Runs in a background thread with its own tokio runtime. -fn start_iroh_receiver( - relay_id_hex: &str, - swactor_rt: Arc, - coordinator_addr: ActorAddress, - stop: Arc, -) { - let relay_key: iroh::PublicKey = relay_id_hex - .parse() - .unwrap_or_else(|e| panic!("invalid relay node ID '{relay_id_hex}': {e}")); - - thread::Builder::new() - .name("iroh-receiver".into()) - .spawn(move || { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("failed to build tokio runtime for iroh receiver"); - - rt.block_on(async move { - let endpoint = iroh::Endpoint::builder() - .alpns(vec![CI_ALPN.to_vec()]) - .relay_mode(iroh::RelayMode::Default) - .bind() - .await - .expect("failed to bind iroh endpoint"); - - eprintln!(" Iroh local ID: {}", endpoint.id()); - - // Outer reconnection loop: reconnect when the connection drops. - while !stop.load(Ordering::Relaxed) { - eprintln!(" Connecting to relay {relay_key}..."); - - let conn = match endpoint.connect(relay_key, CI_ALPN).await { - Ok(c) => c, - Err(e) => { - eprintln!("iroh: connect failed: {e}, retrying in 5s..."); - tokio::time::sleep(Duration::from_secs(5)).await; - continue; - } - }; - - eprintln!(" Connected to relay!"); - - // Receive loop: the relay opens uni streams to send us events. - while !stop.load(Ordering::Relaxed) { - match tokio::time::timeout(Duration::from_secs(1), conn.accept_uni()).await - { - Ok(Ok(mut recv)) => { - match read_tagged_message(&mut recv).await { - Ok((tag, payload)) => { - if tag == "ci::WebhookEvent" { - match serde_json::from_slice::< - swactor_ci::WebhookEvent, - >( - &payload - ) { - Ok(event) => { - eprintln!( - "iroh: received webhook {} on {}", - event - .commit_sha - .get(..8) - .unwrap_or(&event.commit_sha), - event.branch, - ); - let _ = swactor_rt.send_to( - coordinator_addr, - LocalCoordinatorMsg::Webhook(event), - ); - } - Err(e) => { - eprintln!( - "iroh: failed to deserialize event: {e}" - ) - } - } - } else { - eprintln!("iroh: unknown tag '{tag}', ignoring"); - } - } - Err(e) => { - eprintln!("iroh: read error: {e}"); - break; - } - } - } - Ok(Err(e)) => { - eprintln!("iroh: connection lost: {e}, reconnecting..."); - break; - } - Err(_) => { - // 1s poll timeout — just loop and check stop flag. - } - } - } - } - - endpoint.close().await; - }); - }) - .expect("failed to spawn iroh-receiver thread"); -} - -/// Read a tagged message from a QUIC recv stream. -/// -/// Frame format: `[4B tag_len][tag_bytes][payload_bytes]` -async fn read_tagged_message( - recv: &mut iroh::endpoint::RecvStream, -) -> Result<(String, Vec), Box> { - let mut tag_len_buf = [0u8; 4]; - recv.read_exact(&mut tag_len_buf).await?; - let tag_len = u32::from_be_bytes(tag_len_buf) as usize; - - if tag_len > 1024 { - return Err("tag too large".into()); - } - - let mut tag_buf = vec![0u8; tag_len]; - recv.read_exact(&mut tag_buf).await?; - let tag = String::from_utf8(tag_buf)?; - - let payload = recv.read_to_end(64 * 1024).await?; - - Ok((tag, payload)) -} diff --git a/crates/ci/src/coordinator.rs b/crates/ci/src/coordinator.rs deleted file mode 100644 index 1e94aac..0000000 --- a/crates/ci/src/coordinator.rs +++ /dev/null @@ -1,430 +0,0 @@ -//! Coordinator actor: central brain of the CI system. -//! -//! Receives webhook events, manages pipeline lifecycles, dispatches jobs -//! to the Provisioner and RunnerSupervisor actors. - -use std::collections::HashMap; - -use swactor::actor::{ActorAddress, ActorInterface, Ctx}; - -use crate::pipeline::PipelineExecution; -use crate::yaml::{self, CiYaml}; -use crate::{ - CiConfig, JobComplete, JobId, JobProgress, JobStatus, PipelineId, ProvisionRequest, - ProvisionResponse, StatusUpdate, TerminateRequest, WebhookEvent, -}; - -/// Messages the Coordinator can receive. -#[derive(Debug, Clone)] -pub enum CoordinatorMsg { - /// A webhook event from Forgejo. - Webhook(WebhookEvent), - /// The CI YAML config to use (loaded externally or from the repo). - SetCiYaml(CiYaml), - /// Response from the Provisioner. - ProvisionResponse(ProvisionResponse), - /// Streamed output from a RunnerSupervisor. - JobProgress(JobProgress), - /// Final result from a RunnerSupervisor. - JobComplete(JobComplete), - /// Notification that the provisioner is offline (detected via SWIM). - ProvisionerOffline, - /// Notification that the provisioner is back online. - ProvisionerOnline, -} - -/// The Coordinator actor state. -pub struct Coordinator { - config: CiConfig, - ci_yaml: Option, - pipelines: HashMap, - next_pipeline_id: u64, - provisioner_addr: Option, - provisioner_online: bool, - /// Maps job_id → runner supervisor address. - runner_addrs: HashMap, - /// Captured status updates (for testing/simulation). - status_updates: Vec, - /// Jobs waiting for the provisioner to come online. - queued_provisions: Vec, -} - -impl Coordinator { - pub fn new(config: CiConfig) -> Self { - Self { - config, - ci_yaml: None, - pipelines: HashMap::new(), - next_pipeline_id: 1, - provisioner_addr: None, - provisioner_online: false, - runner_addrs: HashMap::new(), - status_updates: Vec::new(), - queued_provisions: Vec::new(), - } - } - - pub fn with_provisioner(mut self, addr: ActorAddress) -> Self { - self.provisioner_addr = Some(addr); - self.provisioner_online = true; - self - } - - pub fn with_ci_yaml(mut self, yaml: CiYaml) -> Self { - self.ci_yaml = Some(yaml); - self - } - - pub fn pipelines(&self) -> &HashMap { - &self.pipelines - } - - pub fn status_updates(&self) -> &[StatusUpdate] { - &self.status_updates - } - - fn handle_webhook(&mut self, ctx: &Ctx, event: WebhookEvent) { - let ci = match &self.ci_yaml { - Some(ci) => ci.clone(), - None => return, - }; - - let matched = yaml::matching_pipelines(&ci, &event); - for pipeline_name in matched { - let pipeline_def = &ci.pipelines[&pipeline_name]; - let pipeline_id = PipelineId(self.next_pipeline_id); - self.next_pipeline_id += 1; - - // Build job definitions. - let job_defs: Vec<_> = pipeline_def - .jobs - .iter() - .map(|(name, def)| yaml::to_job_definition(name, def)) - .collect(); - - let pipeline = PipelineExecution::new( - pipeline_id, - pipeline_name.clone(), - event.repo_owner.clone(), - event.repo_name.clone(), - event.commit_sha.clone(), - event.branch.clone(), - job_defs, - ); - - // Set pending status on Forgejo. - self.emit_status_update(StatusUpdate { - repo_owner: event.repo_owner.clone(), - repo_name: event.repo_name.clone(), - commit_sha: event.commit_sha.clone(), - state: "pending".into(), - context: format!("ci/{pipeline_name}"), - description: format!("Pipeline '{pipeline_name}' is pending"), - target_url: None, - }); - - self.pipelines.insert(pipeline_id, pipeline); - - // Start eligible jobs. - self.advance_pipeline(ctx, pipeline_id); - } - } - - fn advance_pipeline(&mut self, ctx: &Ctx, pipeline_id: PipelineId) { - let pipeline = match self.pipelines.get(&pipeline_id) { - Some(p) => p, - None => return, - }; - - // If pipeline is already terminal, emit final status. - if pipeline.status.is_terminal() { - let update = StatusUpdate { - repo_owner: pipeline.repo_owner.clone(), - repo_name: pipeline.repo_name.clone(), - commit_sha: pipeline.commit_sha.clone(), - state: pipeline.status.forgejo_state().into(), - context: format!("ci/{}", pipeline.pipeline_name), - description: format!( - "Pipeline '{}' {}", - pipeline.pipeline_name, - pipeline.status.forgejo_state() - ), - target_url: None, - }; - self.emit_status_update(update); - return; - } - - let eligible = pipeline.eligible_jobs(); - let repo_owner = pipeline.repo_owner.clone(); - let repo_name = pipeline.repo_name.clone(); - let commit_sha = pipeline.commit_sha.clone(); - - for job_name in eligible { - let job_id = JobId { - pipeline_id, - job_name: job_name.clone(), - }; - - let spec = { - let pipeline = self.pipelines.get(&pipeline_id).unwrap(); - let job = &pipeline.jobs[&job_name]; - crate::InstanceSpec { - docker_required: job.definition.docker, - ..Default::default() - } - }; - - if self.provisioner_online { - // Request provisioning. - if let Some(prov_addr) = self.provisioner_addr { - let request = ProvisionRequest { - job_id: job_id.clone(), - instance_spec: spec, - }; - let _ = ctx.send(prov_addr, crate::provisioner::ProvisionerMsg::Provision(request)); - } - if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) { - if let Some(job) = pipeline.jobs.get_mut(&job_name) { - job.status = JobStatus::Provisioning; - } - } - } else { - // Queue for later. - let request = ProvisionRequest { - job_id: job_id.clone(), - instance_spec: spec, - }; - self.queued_provisions.push(request); - if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) { - if let Some(job) = pipeline.jobs.get_mut(&job_name) { - job.status = JobStatus::WaitingForProvisioner; - } - } - } - - // Emit per-job status. - self.emit_status_update(StatusUpdate { - repo_owner: repo_owner.clone(), - repo_name: repo_name.clone(), - commit_sha: commit_sha.clone(), - state: "pending".into(), - context: format!("ci/{job_name}"), - description: format!("Job '{job_name}' is provisioning"), - target_url: None, - }); - } - } - - fn handle_provision_response(&mut self, ctx: &Ctx, response: ProvisionResponse) { - let pipeline_id = response.job_id.pipeline_id; - let job_name = response.job_id.job_name.clone(); - - match response.result { - Ok(instance) => { - // Store instance_id for cleanup. - if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) { - if let Some(job) = pipeline.jobs.get_mut(&job_name) { - job.instance_id = Some(instance.instance_id.clone()); - job.status = JobStatus::Running; - } - } - - // Spawn a RunnerSupervisor for this job. - let pipeline = &self.pipelines[&pipeline_id]; - let job = &pipeline.jobs[&job_name]; - - let start_job = crate::StartJob { - job_id: response.job_id.clone(), - instance: instance.clone(), - repo_url: format!( - "{}/{}/{}", - self.config.forgejo_url, pipeline.repo_owner, pipeline.repo_name - ), - commit_sha: pipeline.commit_sha.clone(), - job_def: job.definition.clone(), - }; - - let runner = crate::runner::RunnerSupervisor::new( - ctx.self_addr(), - start_job, - ); - - match ctx.spawn(runner) { - Ok(runner_addr) => { - self.runner_addrs.insert(response.job_id, runner_addr); - } - Err(_) => { - // Failed to spawn runner — mark job as failed. - if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) { - pipeline.set_job_status( - &job_name, - JobStatus::Failed { - reason: "failed to spawn runner".into(), - }, - ); - } - self.advance_pipeline(ctx, pipeline_id); - } - } - } - Err(err) => { - if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) { - pipeline.set_job_status( - &job_name, - JobStatus::Failed { - reason: err.to_string(), - }, - ); - } - self.advance_pipeline(ctx, pipeline_id); - } - } - } - - fn handle_job_complete(&mut self, ctx: &Ctx, complete: JobComplete) { - let pipeline_id = complete.job_id.pipeline_id; - let job_name = complete.job_id.job_name.clone(); - - // Send terminate request for the instance. - if let Some(pipeline) = self.pipelines.get(&pipeline_id) { - if let Some(job) = pipeline.jobs.get(&job_name) { - if let Some(ref instance_id) = job.instance_id { - let terminate = TerminateRequest { - job_id: complete.job_id.clone(), - instance_id: instance_id.clone(), - }; - if let Some(prov_addr) = self.provisioner_addr { - let _ = ctx.send( - prov_addr, - crate::provisioner::ProvisionerMsg::Terminate(terminate), - ); - } - } - } - } - - // Update job status. - let status = match complete.result { - Ok(_) => JobStatus::Passed, - Err(ref failure) => JobStatus::Failed { - reason: failure.to_string(), - }, - }; - - let (repo_owner, repo_name, commit_sha) = { - let pipeline = match self.pipelines.get(&pipeline_id) { - Some(p) => p, - None => return, - }; - ( - pipeline.repo_owner.clone(), - pipeline.repo_name.clone(), - pipeline.commit_sha.clone(), - ) - }; - - // Emit per-job final status. - self.emit_status_update(StatusUpdate { - repo_owner, - repo_name, - commit_sha, - state: match &status { - JobStatus::Passed => "success".into(), - _ => "failure".into(), - }, - context: format!("ci/{job_name}"), - description: format!("Job '{job_name}' completed"), - target_url: None, - }); - - if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) { - pipeline.set_job_status(&job_name, status); - } - - // Remove runner address. - self.runner_addrs.remove(&complete.job_id); - - // Advance pipeline to schedule downstream jobs. - self.advance_pipeline(ctx, pipeline_id); - } - - fn handle_provisioner_offline(&mut self) { - self.provisioner_online = false; - - // Mark all provisioning jobs as waiting. - for pipeline in self.pipelines.values_mut() { - for job in pipeline.jobs.values_mut() { - if job.status == JobStatus::Provisioning { - job.status = JobStatus::WaitingForProvisioner; - } - } - } - } - - fn handle_provisioner_online(&mut self, ctx: &Ctx) { - self.provisioner_online = true; - - // Flush queued provision requests. - let queued = std::mem::take(&mut self.queued_provisions); - for request in queued { - if let Some(prov_addr) = self.provisioner_addr { - let _ = ctx.send(prov_addr, crate::provisioner::ProvisionerMsg::Provision(request)); - } - } - - // Re-advance pipelines that have waiting jobs. - let pipeline_ids: Vec = self.pipelines.keys().copied().collect(); - for pid in pipeline_ids { - // Move waiting jobs back to provisioning. - if let Some(pipeline) = self.pipelines.get_mut(&pid) { - let waiting_jobs: Vec = pipeline - .jobs - .iter() - .filter(|(_, j)| j.status == JobStatus::WaitingForProvisioner) - .map(|(name, _)| name.clone()) - .collect(); - - for job_name in waiting_jobs { - if let Some(job) = pipeline.jobs.get_mut(&job_name) { - job.status = JobStatus::Pending; - } - } - } - self.advance_pipeline(ctx, pid); - } - } - - fn emit_status_update(&mut self, update: StatusUpdate) { - self.status_updates.push(update); - } -} - -impl ActorInterface for Coordinator { - type Incoming = CoordinatorMsg; - type Response = (); - - fn handle(&mut self, ctx: &Ctx, msg: CoordinatorMsg) { - match msg { - CoordinatorMsg::Webhook(event) => self.handle_webhook(ctx, event), - CoordinatorMsg::SetCiYaml(yaml) => { - self.ci_yaml = Some(yaml); - } - CoordinatorMsg::ProvisionResponse(resp) => { - self.handle_provision_response(ctx, resp); - } - CoordinatorMsg::JobProgress(progress) => { - if let Some(pipeline) = self.pipelines.get_mut(&progress.job_id.pipeline_id) { - if let Some(job) = pipeline.jobs.get_mut(&progress.job_id.job_name) { - job.output_lines.push(progress.output_line); - } - } - } - CoordinatorMsg::JobComplete(complete) => { - self.handle_job_complete(ctx, complete); - } - CoordinatorMsg::ProvisionerOffline => self.handle_provisioner_offline(), - CoordinatorMsg::ProvisionerOnline => self.handle_provisioner_online(ctx), - } - } -} diff --git a/crates/ci/src/lib.rs b/crates/ci/src/lib.rs deleted file mode 100644 index 5a0f2ad..0000000 --- a/crates/ci/src/lib.rs +++ /dev/null @@ -1,315 +0,0 @@ -pub mod coordinator; -pub mod local_coordinator; -pub mod local_runner; -pub mod pipeline; -pub mod provisioner; -pub mod runner; -pub mod status_reporter; -pub mod webhook_server; -pub mod yaml; - -use std::collections::HashMap; -use std::fmt; -use std::net::IpAddr; - -use serde::{Deserialize, Serialize}; - -// ─── Core Identifiers ─────────────────────────────────────────────────────── - -/// Unique identifier for a pipeline execution. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct PipelineId(pub u64); - -impl fmt::Display for PipelineId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "pipeline-{}", self.0) - } -} - -/// Unique identifier for a job within a pipeline. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct JobId { - pub pipeline_id: PipelineId, - pub job_name: String, -} - -impl fmt::Display for JobId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}/{}", self.pipeline_id, self.job_name) - } -} - -// ─── Job Status ───────────────────────────────────────────────────────────── - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum JobStatus { - Pending, - WaitingForProvisioner, - Provisioning, - Running, - Passed, - Failed { reason: String }, - Skipped, - Interrupted, -} - -impl JobStatus { - pub fn is_terminal(&self) -> bool { - matches!( - self, - JobStatus::Passed | JobStatus::Failed { .. } | JobStatus::Skipped | JobStatus::Interrupted - ) - } -} - -// ─── Pipeline Status ──────────────────────────────────────────────────────── - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum PipelineStatus { - Pending, - Running, - Passed, - Failed, - Error { reason: String }, -} - -impl PipelineStatus { - pub fn is_terminal(&self) -> bool { - matches!( - self, - PipelineStatus::Passed | PipelineStatus::Failed | PipelineStatus::Error { .. } - ) - } - - /// Convert to Forgejo commit status string. - pub fn forgejo_state(&self) -> &'static str { - match self { - PipelineStatus::Pending => "pending", - PipelineStatus::Running => "pending", - PipelineStatus::Passed => "success", - PipelineStatus::Failed => "failure", - PipelineStatus::Error { .. } => "error", - } - } -} - -// ─── Instance Types ───────────────────────────────────────────────────────── - -/// Specification for a spot instance. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InstanceSpec { - pub min_cpus: u32, - pub min_ram_mb: u32, - pub min_disk_gb: u32, - pub docker_required: bool, - pub region_preferences: Vec, -} - -impl Default for InstanceSpec { - fn default() -> Self { - Self { - min_cpus: 2, - min_ram_mb: 2048, - min_disk_gb: 20, - docker_required: false, - region_preferences: Vec::new(), - } - } -} - -/// Connection details for a provisioned spot instance. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InstanceReady { - pub instance_id: String, - pub ip: IpAddr, - pub ssh_port: u16, - pub ssh_host_key: String, -} - -// ─── Provisioner ↔ Coordinator Messages ───────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProvisionRequest { - pub job_id: JobId, - pub instance_spec: InstanceSpec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProvisionResponse { - pub job_id: JobId, - pub result: Result, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ProvisionError { - NoCapacity, - ProviderError(String), - Timeout, - ProvisionerOffline, -} - -impl fmt::Display for ProvisionError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ProvisionError::NoCapacity => write!(f, "no capacity available"), - ProvisionError::ProviderError(msg) => write!(f, "provider error: {msg}"), - ProvisionError::Timeout => write!(f, "provisioning timed out"), - ProvisionError::ProvisionerOffline => write!(f, "provisioner is offline"), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TerminateRequest { - pub job_id: JobId, - pub instance_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TerminateAck { - pub job_id: JobId, -} - -// ─── Coordinator ↔ RunnerSupervisor Messages ──────────────────────────────── - -/// Sent from Coordinator to RunnerSupervisor to begin a job. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StartJob { - pub job_id: JobId, - pub instance: InstanceReady, - pub repo_url: String, - pub commit_sha: String, - pub job_def: JobDefinition, -} - -/// Streamed output from RunnerSupervisor back to Coordinator. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JobProgress { - pub job_id: JobId, - pub output_line: String, -} - -/// Final result from RunnerSupervisor. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JobComplete { - pub job_id: JobId, - pub result: Result, - pub artifacts: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JobSuccess; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum JobFailure { - CommandFailed { exit_code: i32, last_lines: Vec }, - SshError(String), - ExecError(String), - Timeout, - Interrupted, -} - -impl fmt::Display for JobFailure { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - JobFailure::CommandFailed { exit_code, .. } => { - write!(f, "command exited with code {exit_code}") - } - JobFailure::SshError(msg) => write!(f, "SSH error: {msg}"), - JobFailure::ExecError(msg) => write!(f, "exec error: {msg}"), - JobFailure::Timeout => write!(f, "job timed out"), - JobFailure::Interrupted => write!(f, "spot instance interrupted"), - } - } -} - -// ─── Job Definition ───────────────────────────────────────────────────────── - -/// A parsed job from the .ci.yml file. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JobDefinition { - pub name: String, - pub run: Vec, - pub needs: Vec, - pub timeout_secs: u64, - pub docker: bool, - pub artifacts: Vec, - pub env: HashMap, -} - -// ─── Webhook Types ────────────────────────────────────────────────────────── - -/// Parsed webhook event from Forgejo. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WebhookEvent { - pub event_type: EventType, - pub repo_owner: String, - pub repo_name: String, - pub branch: String, - pub commit_sha: String, - pub tag: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum EventType { - Push, - Tag, - Merge, -} - -// ─── Forgejo Status Updates ───────────────────────────────────────────────── - -/// A commit status update to send to Forgejo's API. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StatusUpdate { - pub repo_owner: String, - pub repo_name: String, - pub commit_sha: String, - pub state: String, - pub context: String, - pub description: String, - pub target_url: Option, -} - -// ─── Coordinator Config ───────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CiConfig { - pub webhook_port: u16, - pub webhook_secret: String, - pub forgejo_url: String, - pub forgejo_token: String, - pub data_dir: String, -} - -impl Default for CiConfig { - fn default() -> Self { - Self { - webhook_port: 8787, - webhook_secret: String::new(), - forgejo_url: String::new(), - forgejo_token: String::new(), - data_dir: "./ci-data".into(), - } - } -} - -// ─── Local CI Types ────────────────────────────────────────────────────────── - -/// Job execution request for local runner (no InstanceReady needed). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LocalStartJob { - pub job_id: JobId, - pub work_dir: String, - pub job_def: JobDefinition, - pub env_overrides: HashMap, -} - -/// Configuration for the local CI runner. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LocalCiConfig { - pub ci: CiConfig, - pub repo_url: String, - pub work_dir: String, - pub ci_yaml_path: String, -} diff --git a/crates/ci/src/local_coordinator.rs b/crates/ci/src/local_coordinator.rs deleted file mode 100644 index 2858662..0000000 --- a/crates/ci/src/local_coordinator.rs +++ /dev/null @@ -1,565 +0,0 @@ -//! LocalCoordinator actor: single-machine CI brain. -//! -//! Receives webhook events, queues pipelines, executes jobs one at a time -//! directly on the host. Supports branch-level supersede for queued pipelines. - -use std::collections::{HashMap, VecDeque}; -use std::process::Command; -use std::sync::{Arc, Mutex}; - -use swactor::actor::{ActorAddress, ActorInterface, Ctx}; - -use crate::pipeline::PipelineExecution; -use crate::status_reporter::{JobOutput, StatusReporterMsg}; -use crate::yaml::{self, CiYaml}; -use crate::{ - JobComplete, JobId, JobProgress, JobStatus, LocalCiConfig, LocalStartJob, PipelineId, - PipelineStatus, StatusUpdate, WebhookEvent, -}; - -/// Messages the LocalCoordinator can receive. -#[derive(Debug, Clone)] -pub enum LocalCoordinatorMsg { - Webhook(WebhookEvent), - SetCiYaml(CiYaml), - JobProgress(JobProgress), - JobComplete(JobComplete), - GitReady { - pipeline_id: PipelineId, - job_name: String, - work_dir: String, - }, -} - -/// Lightweight snapshot of coordinator state (no dashboard dependency). -/// The binary layer converts this to `CiSnapshot` for the dashboard. -#[derive(Debug, Clone, Default)] -pub struct LocalCiSnapshot { - pub active_pipelines: Vec, - pub recent_pipelines: Vec, - pub has_running_job: bool, -} - -/// The LocalCoordinator actor state. -pub struct LocalCoordinator { - config: LocalCiConfig, - ci_yaml: Option, - pipelines: HashMap, - next_pipeline_id: u64, - /// FIFO queue of pipeline IDs awaiting execution. - queue: VecDeque, - /// The pipeline currently being executed. - active_pipeline: Option, - /// At most one running job (job_id, runner actor address). - running_job: Option<(JobId, ActorAddress)>, - /// Status reporter actor address. - status_reporter_addr: Option, - /// Bounded ring of finished pipelines. - completed: VecDeque, - /// Shared snapshot for external consumers (e.g. dashboard binary). - ci_snapshot: Arc>, -} - -impl LocalCoordinator { - pub fn new(config: LocalCiConfig) -> Self { - Self { - config, - ci_yaml: None, - pipelines: HashMap::new(), - next_pipeline_id: 1, - queue: VecDeque::new(), - active_pipeline: None, - running_job: None, - status_reporter_addr: None, - completed: VecDeque::new(), - ci_snapshot: Arc::new(Mutex::new(LocalCiSnapshot::default())), - } - } - - pub fn with_status_reporter(mut self, addr: ActorAddress) -> Self { - self.status_reporter_addr = Some(addr); - self - } - - pub fn with_ci_yaml(mut self, yaml: CiYaml) -> Self { - self.ci_yaml = Some(yaml); - self - } - - pub fn ci_snapshot(&self) -> Arc> { - Arc::clone(&self.ci_snapshot) - } - - pub fn pipelines(&self) -> &HashMap { - &self.pipelines - } - - pub fn completed(&self) -> &VecDeque { - &self.completed - } - - pub fn queue(&self) -> &VecDeque { - &self.queue - } - - pub fn active_pipeline(&self) -> Option { - self.active_pipeline - } - - pub fn running_job(&self) -> Option<&(JobId, ActorAddress)> { - self.running_job.as_ref() - } - - fn handle_webhook(&mut self, ctx: &Ctx, event: WebhookEvent) { - let ci = match &self.ci_yaml { - Some(ci) => ci.clone(), - None => return, - }; - - let matched = yaml::matching_pipelines(&ci, &event); - for pipeline_name in matched { - let pipeline_def = &ci.pipelines[&pipeline_name]; - let pipeline_id = PipelineId(self.next_pipeline_id); - self.next_pipeline_id += 1; - - let job_defs: Vec<_> = pipeline_def - .jobs - .iter() - .map(|(name, def)| yaml::to_job_definition(name, def)) - .collect(); - - let pipeline = PipelineExecution::new( - pipeline_id, - pipeline_name.clone(), - event.repo_owner.clone(), - event.repo_name.clone(), - event.commit_sha.clone(), - event.branch.clone(), - job_defs, - ); - - self.emit_status( - ctx, - StatusUpdate { - repo_owner: event.repo_owner.clone(), - repo_name: event.repo_name.clone(), - commit_sha: event.commit_sha.clone(), - state: "pending".into(), - context: format!("ci/{pipeline_name}"), - description: format!("Pipeline '{pipeline_name}' is pending"), - target_url: None, - }, - ); - - self.pipelines.insert(pipeline_id, pipeline); - self.enqueue_pipeline(pipeline_id, &event.branch); - } - - self.try_schedule_next(ctx); - } - - /// Enqueue a pipeline, superseding any queued pipeline for the same branch. - fn enqueue_pipeline(&mut self, pipeline_id: PipelineId, branch: &str) { - // Scan queue for entry with same branch (not the active pipeline). - let supersede_idx = self.queue.iter().position(|&qid| { - self.pipelines - .get(&qid) - .map(|p| p.branch == branch) - .unwrap_or(false) - }); - - if let Some(idx) = supersede_idx { - let old_id = self.queue[idx]; - // Mark old pipeline as superseded. - if let Some(old_pipeline) = self.pipelines.get_mut(&old_id) { - old_pipeline.status = PipelineStatus::Error { - reason: "superseded".into(), - }; - // Mark all pending jobs as skipped. - let job_names: Vec = old_pipeline.jobs.keys().cloned().collect(); - for name in job_names { - if old_pipeline.jobs[&name].status == JobStatus::Pending { - old_pipeline.set_job_status(&name, JobStatus::Skipped); - } - } - } - // Archive the superseded pipeline. - if let Some(old_pipeline) = self.pipelines.remove(&old_id) { - self.archive_pipeline(old_pipeline); - } - // Replace queue entry. - self.queue[idx] = pipeline_id; - } else { - self.queue.push_back(pipeline_id); - } - } - - /// Core scheduling: one job at a time. - fn try_schedule_next(&mut self, ctx: &Ctx) { - // If a job is already running, nothing to do. - if self.running_job.is_some() { - return; - } - - // If we have an active pipeline, try to find eligible jobs. - if let Some(active_id) = self.active_pipeline { - if let Some(pipeline) = self.pipelines.get(&active_id) { - let eligible = pipeline.eligible_jobs(); - if !eligible.is_empty() { - let job_name = eligible[0].clone(); - self.start_job(ctx, active_id, &job_name); - return; - } - - // No eligible jobs — check if pipeline is terminal. - if pipeline.status.is_terminal() { - let pipeline = self.pipelines.remove(&active_id).unwrap(); - self.emit_status( - ctx, - StatusUpdate { - repo_owner: pipeline.repo_owner.clone(), - repo_name: pipeline.repo_name.clone(), - commit_sha: pipeline.commit_sha.clone(), - state: pipeline.status.forgejo_state().into(), - context: format!("ci/{}", pipeline.pipeline_name), - description: format!( - "Pipeline '{}' {}", - pipeline.pipeline_name, - pipeline.status.forgejo_state() - ), - target_url: None, - }, - ); - self.emit_pipeline_comment(ctx, &pipeline); - self.archive_pipeline(pipeline); - self.active_pipeline = None; - // Recurse to pick next from queue. - self.try_schedule_next(ctx); - return; - } - } - // Pipeline exists but no eligible jobs and not terminal — waiting for running job. - return; - } - - // No active pipeline — pop from queue. - if let Some(next_id) = self.queue.pop_front() { - self.active_pipeline = Some(next_id); - self.try_schedule_next(ctx); - } - } - - fn start_job(&mut self, ctx: &Ctx, pipeline_id: PipelineId, job_name: &str) { - let pipeline = match self.pipelines.get_mut(&pipeline_id) { - Some(p) => p, - None => return, - }; - let job = match pipeline.jobs.get_mut(job_name) { - Some(j) => j, - None => return, - }; - - job.status = JobStatus::Running; - - let work_dir = format!( - "{}/pipeline-{}", - self.config.work_dir, pipeline_id.0 - ); - - // Build CI env overrides. - let mut env_overrides = HashMap::new(); - env_overrides.insert("CI".into(), "true".into()); - env_overrides.insert("CI_COMMIT_SHA".into(), pipeline.commit_sha.clone()); - env_overrides.insert("CI_BRANCH".into(), pipeline.branch.clone()); - env_overrides.insert("CI_PIPELINE_ID".into(), pipeline_id.0.to_string()); - env_overrides.insert("CI_JOB_NAME".into(), job_name.to_string()); - - let start_job = LocalStartJob { - job_id: job.job_id.clone(), - work_dir: work_dir.clone(), - job_def: job.definition.clone(), - env_overrides, - }; - - // Perform git checkout inline (blocks this worker, acceptable for local runner). - let sha = pipeline.commit_sha.clone(); - let repo_url = self.config.repo_url.clone(); - let git_ok = self.git_checkout(&repo_url, &sha, &work_dir); - - if !git_ok { - if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) { - pipeline.set_job_status( - job_name, - JobStatus::Failed { - reason: "git checkout failed".into(), - }, - ); - } - self.try_schedule_next(ctx); - return; - } - - // Emit per-job running status. - if let Some(pipeline) = self.pipelines.get(&pipeline_id) { - self.emit_status( - ctx, - StatusUpdate { - repo_owner: pipeline.repo_owner.clone(), - repo_name: pipeline.repo_name.clone(), - commit_sha: pipeline.commit_sha.clone(), - state: "pending".into(), - context: format!("ci/{job_name}"), - description: format!("Job '{job_name}' is running"), - target_url: None, - }, - ); - } - - // Spawn LocalRunner actor. - let runner = - crate::local_runner::LocalRunner::new(ctx.self_addr(), start_job); - - match ctx.spawn(runner) { - Ok(runner_addr) => { - let job_id = JobId { - pipeline_id, - job_name: job_name.to_string(), - }; - self.running_job = Some((job_id, runner_addr)); - } - Err(_) => { - if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) { - pipeline.set_job_status( - job_name, - JobStatus::Failed { - reason: "failed to spawn runner".into(), - }, - ); - } - self.try_schedule_next(ctx); - } - } - } - - fn git_checkout(&self, repo_url: &str, sha: &str, work_dir: &str) -> bool { - let path = std::path::Path::new(work_dir); - if path.join(".git").exists() { - // Already cloned — fetch and checkout. - let fetch = Command::new("git") - .args(["fetch", "origin"]) - .current_dir(work_dir) - .output(); - if fetch.is_err() || !fetch.unwrap().status.success() { - return false; - } - let checkout = Command::new("git") - .args(["checkout", sha]) - .current_dir(work_dir) - .output(); - checkout.map(|o| o.status.success()).unwrap_or(false) - } else { - // Fresh clone. - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - let clone = Command::new("git") - .args(["clone", repo_url, work_dir]) - .output(); - if clone.is_err() || !clone.as_ref().unwrap().status.success() { - return false; - } - let checkout = Command::new("git") - .args(["checkout", sha]) - .current_dir(work_dir) - .output(); - checkout.map(|o| o.status.success()).unwrap_or(false) - } - } - - fn handle_job_complete(&mut self, ctx: &Ctx, complete: JobComplete) { - let pipeline_id = complete.job_id.pipeline_id; - let job_name = complete.job_id.job_name.clone(); - - let status = match complete.result { - Ok(_) => JobStatus::Passed, - Err(ref failure) => JobStatus::Failed { - reason: failure.to_string(), - }, - }; - - // Build a description with output tail for failures. - let description = match &status { - JobStatus::Passed => format!("Job '{job_name}' passed"), - JobStatus::Failed { reason } => { - let output_tail = self - .pipelines - .get(&pipeline_id) - .and_then(|p| p.jobs.get(&job_name)) - .map(|j| { - let lines: Vec<&str> = j - .output_lines - .iter() - .rev() - .take(10) - .map(|s| s.as_str()) - .collect(); - lines.into_iter().rev().collect::>().join("\n") - }) - .unwrap_or_default(); - - let mut desc = format!("Job '{job_name}' failed: {reason}"); - if !output_tail.is_empty() { - desc.push_str("\n"); - desc.push_str(&output_tail); - } - // Cap at ~250 chars for the status description field. - if desc.len() > 250 { - desc.truncate(247); - desc.push_str("..."); - } - desc - } - _ => format!("Job '{job_name}' completed"), - }; - - // Emit per-job final status. - if let Some(pipeline) = self.pipelines.get(&pipeline_id) { - self.emit_status( - ctx, - StatusUpdate { - repo_owner: pipeline.repo_owner.clone(), - repo_name: pipeline.repo_name.clone(), - commit_sha: pipeline.commit_sha.clone(), - state: match &status { - JobStatus::Passed => "success".into(), - _ => "failure".into(), - }, - context: format!("ci/{job_name}"), - description, - target_url: None, - }, - ); - } - - if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) { - pipeline.set_job_status(&job_name, status); - } - - // Clear running job. - self.running_job = None; - - // Schedule next. - self.try_schedule_next(ctx); - } - - fn emit_status(&self, ctx: &Ctx, update: StatusUpdate) { - if let Some(reporter_addr) = self.status_reporter_addr { - let _ = ctx.send( - reporter_addr, - StatusReporterMsg::Report { - update, - forgejo_url: self.config.ci.forgejo_url.clone(), - forgejo_token: self.config.ci.forgejo_token.clone(), - }, - ); - } - } - - fn emit_pipeline_comment(&self, ctx: &Ctx, pipeline: &crate::pipeline::PipelineExecution) { - let reporter_addr = match self.status_reporter_addr { - Some(addr) => addr, - None => return, - }; - - let job_outputs: Vec = pipeline - .jobs - .values() - .map(|job| { - let passed = job.status == JobStatus::Passed; - let failure_reason = match &job.status { - JobStatus::Failed { reason } => Some(reason.clone()), - _ => None, - }; - JobOutput { - job_name: job.definition.name.clone(), - passed, - failure_reason, - output_lines: job.output_lines.clone(), - } - }) - .collect(); - - let _ = ctx.send( - reporter_addr, - StatusReporterMsg::PostPipelineComment { - repo_owner: pipeline.repo_owner.clone(), - repo_name: pipeline.repo_name.clone(), - commit_sha: pipeline.commit_sha.clone(), - branch: pipeline.branch.clone(), - pipeline_name: pipeline.pipeline_name.clone(), - pipeline_state: pipeline.status.forgejo_state().to_string(), - job_outputs, - forgejo_url: self.config.ci.forgejo_url.clone(), - forgejo_token: self.config.ci.forgejo_token.clone(), - }, - ); - } - - fn archive_pipeline(&mut self, pipeline: PipelineExecution) { - self.completed.push_back(pipeline); - if self.completed.len() > 50 { - self.completed.pop_front(); - } - } - - fn update_snapshot(&self) { - let active: Vec = self.pipelines.values().cloned().collect(); - let recent: Vec = self - .completed - .iter() - .rev() - .take(20) - .cloned() - .collect(); - - if let Ok(mut snap) = self.ci_snapshot.lock() { - snap.active_pipelines = active; - snap.recent_pipelines = recent; - snap.has_running_job = self.running_job.is_some(); - } - } -} - -impl ActorInterface for LocalCoordinator { - type Incoming = LocalCoordinatorMsg; - type Response = (); - - fn handle(&mut self, ctx: &Ctx, msg: LocalCoordinatorMsg) { - match msg { - LocalCoordinatorMsg::Webhook(event) => self.handle_webhook(ctx, event), - LocalCoordinatorMsg::SetCiYaml(yaml) => { - self.ci_yaml = Some(yaml); - } - LocalCoordinatorMsg::JobProgress(progress) => { - if let Some(pipeline) = self.pipelines.get_mut(&progress.job_id.pipeline_id) { - if let Some(job) = pipeline.jobs.get_mut(&progress.job_id.job_name) { - job.output_lines.push(progress.output_line); - } - } - } - LocalCoordinatorMsg::JobComplete(complete) => { - self.handle_job_complete(ctx, complete); - } - LocalCoordinatorMsg::GitReady { - pipeline_id, - job_name, - work_dir, - } => { - // Git ready is used in the async variant; for now handled inline in start_job. - let _ = (pipeline_id, job_name, work_dir); - } - } - - self.update_snapshot(); - } -} diff --git a/crates/ci/src/local_runner.rs b/crates/ci/src/local_runner.rs deleted file mode 100644 index 0cd0b7f..0000000 --- a/crates/ci/src/local_runner.rs +++ /dev/null @@ -1,304 +0,0 @@ -//! LocalRunner actor: executes job commands directly on the host via shell. -//! -//! Short-lived actor, one per job. Spawned by LocalCoordinator when a job -//! is ready to execute. - -use std::io::BufRead; -use std::process::{Child, Command, Stdio}; -use std::sync::{Arc, Mutex}; - -use swactor::actor::{ActorAddress, ActorInterface, Ctx}; - -use crate::local_coordinator::LocalCoordinatorMsg; -use crate::{JobComplete, JobFailure, JobProgress, JobSuccess, LocalStartJob}; - -/// Messages the LocalRunner can receive. -#[derive(Debug, Clone)] -pub enum LocalRunnerMsg { - /// Begin executing the job (sent to self in on_start). - Execute, - /// Simulated: job completed (for testing without real shell). - SimComplete(Result<(), String>), -} - -/// LocalRunner actor state. -pub struct LocalRunner { - coordinator_addr: ActorAddress, - start_job: LocalStartJob, -} - -impl LocalRunner { - pub fn new(coordinator_addr: ActorAddress, start_job: LocalStartJob) -> Self { - Self { - coordinator_addr, - start_job, - } - } - - /// Execute all commands in the job definition, streaming output back. - fn execute(&self, ctx: &Ctx) { - let job_id = &self.start_job.job_id; - let work_dir = &self.start_job.work_dir; - let timeout_secs = self.start_job.job_def.timeout_secs; - - eprintln!( - "[runner] job {}/{} starting ({} commands, timeout {}s, workdir {})", - job_id.pipeline_id.0, - job_id.job_name, - self.start_job.job_def.run.len(), - timeout_secs, - work_dir, - ); - - for cmd_str in &self.start_job.job_def.run { - eprintln!("[runner] exec: {cmd_str}"); - - // Send progress: command being run. - let _ = ctx.send( - self.coordinator_addr, - LocalCoordinatorMsg::JobProgress(JobProgress { - job_id: job_id.clone(), - output_line: format!("$ {cmd_str}"), - }), - ); - - let child_result = Command::new("sh") - .arg("-c") - .arg(cmd_str) - .current_dir(work_dir) - .envs(&self.start_job.env_overrides) - .envs(&self.start_job.job_def.env) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn(); - - let mut child = match child_result { - Ok(c) => c, - Err(e) => { - eprintln!("[runner] spawn failed: {e}"); - let _ = ctx.send( - self.coordinator_addr, - LocalCoordinatorMsg::JobComplete(JobComplete { - job_id: job_id.clone(), - result: Err(JobFailure::ExecError(e.to_string())), - artifacts: Vec::new(), - }), - ); - ctx.stop_self(); - return; - } - }; - - // Timeout: spawn a thread that kills the child after timeout_secs. - let child_id = child.id(); - let kill_flag = Arc::new(Mutex::new(false)); - let kill_flag_clone = Arc::clone(&kill_flag); - let (done_tx, done_rx) = std::sync::mpsc::channel::<()>(); - let timeout_handle = std::thread::spawn(move || { - if done_rx - .recv_timeout(std::time::Duration::from_secs(timeout_secs)) - .is_err() - { - *kill_flag_clone.lock().unwrap() = true; - // Actually kill the child process so the pipe readers unblock. - let _ = std::process::Command::new("kill") - .args(["-9", &child_id.to_string()]) - .status(); - } - }); - - // Read stdout and stderr concurrently to avoid pipe-buffer deadlock. - let (last_lines, timed_out) = - drain_child_output(&mut child, job_id, ctx, self.coordinator_addr, &kill_flag); - - let status = child.wait(); - - // Signal timeout thread that we're done. - let _ = done_tx.send(()); - let _ = timeout_handle.join(); - - if timed_out || *kill_flag.lock().unwrap() { - eprintln!("[runner] command timed out after {timeout_secs}s"); - let _ = ctx.send( - self.coordinator_addr, - LocalCoordinatorMsg::JobComplete(JobComplete { - job_id: job_id.clone(), - result: Err(JobFailure::Timeout), - artifacts: Vec::new(), - }), - ); - ctx.stop_self(); - return; - } - - match status { - Ok(exit) if exit.success() => { - eprintln!("[runner] command succeeded"); - } - Ok(exit) => { - let exit_code = exit.code().unwrap_or(-1); - eprintln!("[runner] command failed (exit {exit_code})"); - for line in &last_lines { - eprintln!("[runner] {line}"); - } - let _ = ctx.send( - self.coordinator_addr, - LocalCoordinatorMsg::JobComplete(JobComplete { - job_id: job_id.clone(), - result: Err(JobFailure::CommandFailed { - exit_code, - last_lines, - }), - artifacts: Vec::new(), - }), - ); - ctx.stop_self(); - return; - } - Err(e) => { - eprintln!("[runner] wait failed: {e}"); - let _ = ctx.send( - self.coordinator_addr, - LocalCoordinatorMsg::JobComplete(JobComplete { - job_id: job_id.clone(), - result: Err(JobFailure::ExecError(e.to_string())), - artifacts: Vec::new(), - }), - ); - ctx.stop_self(); - return; - } - } - } - - eprintln!( - "[runner] job {}/{} passed", - job_id.pipeline_id.0, job_id.job_name - ); - let _ = ctx.send( - self.coordinator_addr, - LocalCoordinatorMsg::JobComplete(JobComplete { - job_id: job_id.clone(), - result: Ok(JobSuccess), - artifacts: Vec::new(), - }), - ); - ctx.stop_self(); - } -} - -/// Drain stdout and stderr from a child process concurrently. -/// -/// Spawns a background thread for stderr so that both pipes are consumed -/// in parallel, preventing the classic pipe-buffer deadlock where the child -/// blocks writing to a full stderr while the parent blocks reading stdout. -/// -/// Returns (last_lines, timed_out). -fn drain_child_output( - child: &mut Child, - job_id: &crate::JobId, - ctx: &Ctx, - coordinator_addr: ActorAddress, - kill_flag: &Arc>, -) -> (Vec, bool) { - let stdout = child.stdout.take(); - let stderr = child.stderr.take(); - - // Collect stderr on a background thread. - let stderr_job_id = job_id.clone(); - let stderr_kill = Arc::clone(kill_flag); - let stderr_handle = std::thread::spawn(move || { - let mut lines = Vec::new(); - if let Some(stderr) = stderr { - let reader = std::io::BufReader::new(stderr); - for line in reader.lines() { - if *stderr_kill.lock().unwrap() { - break; - } - if let Ok(line) = line { - lines.push(line); - } - } - } - lines - }); - - // Read stdout on the current thread, streaming progress. - let mut last_lines: Vec = Vec::new(); - if let Some(stdout) = stdout { - let reader = std::io::BufReader::new(stdout); - for line in reader.lines() { - if let Ok(line) = line { - let _ = ctx.send( - coordinator_addr, - LocalCoordinatorMsg::JobProgress(JobProgress { - job_id: job_id.clone(), - output_line: line.clone(), - }), - ); - last_lines.push(line); - if last_lines.len() > 50 { - last_lines.remove(0); - } - } - } - } - - // Join stderr thread and stream its lines as progress. - let timed_out = *kill_flag.lock().unwrap(); - let stderr_lines = stderr_handle.join().unwrap_or_default(); - for line in &stderr_lines { - let _ = ctx.send( - coordinator_addr, - LocalCoordinatorMsg::JobProgress(JobProgress { - job_id: stderr_job_id.clone(), - output_line: format!("[stderr] {line}"), - }), - ); - } - - // Merge stderr into last_lines tail. - for line in stderr_lines { - last_lines.push(line); - if last_lines.len() > 50 { - last_lines.remove(0); - } - } - - (last_lines, timed_out) -} - -impl ActorInterface for LocalRunner { - type Incoming = LocalRunnerMsg; - type Response = (); - - fn on_start(&mut self, ctx: &Ctx) { - let _ = ctx.send(ctx.self_addr(), LocalRunnerMsg::Execute); - } - - fn handle(&mut self, ctx: &Ctx, msg: LocalRunnerMsg) { - match msg { - LocalRunnerMsg::Execute => { - self.execute(ctx); - } - LocalRunnerMsg::SimComplete(result) => { - let complete = JobComplete { - job_id: self.start_job.job_id.clone(), - result: match result { - Ok(()) => Ok(JobSuccess), - Err(msg) => Err(JobFailure::CommandFailed { - exit_code: 1, - last_lines: vec![msg], - }), - }, - artifacts: Vec::new(), - }; - let _ = ctx.send( - self.coordinator_addr, - LocalCoordinatorMsg::JobComplete(complete), - ); - ctx.stop_self(); - } - } - } -} diff --git a/crates/ci/src/provisioner.rs b/crates/ci/src/provisioner.rs deleted file mode 100644 index 26b007c..0000000 --- a/crates/ci/src/provisioner.rs +++ /dev/null @@ -1,86 +0,0 @@ -//! Provisioner actor: manages spot instance lifecycle via pluggable provider scripts. -//! -//! In real deployment, runs on the developer's laptop and calls cloud provider APIs. -//! In simulation, provisions are driven by the simulation harness. - -use swactor::actor::{ActorAddress, ActorInterface, Ctx}; - -use crate::coordinator::CoordinatorMsg; -use crate::{ - InstanceReady, ProvisionError, ProvisionRequest, ProvisionResponse, TerminateRequest, -}; - -/// Messages the Provisioner can receive. -#[derive(Debug, Clone)] -pub enum ProvisionerMsg { - /// Request to provision a new spot instance. - Provision(ProvisionRequest), - /// Request to terminate a spot instance. - Terminate(TerminateRequest), - /// Simulated: provisioning result delivered asynchronously. - SimProvisionResult { - request: ProvisionRequest, - result: Result, - }, -} - -/// Provisioner actor state. -/// -/// In real deployment, this would invoke provider scripts. -/// In simulation, the sim harness controls provision outcomes. -pub struct Provisioner { - coordinator_addr: ActorAddress, - /// Active instances tracked for cleanup. - active_instances: Vec, -} - -impl Provisioner { - pub fn new(coordinator_addr: ActorAddress) -> Self { - Self { - coordinator_addr, - active_instances: Vec::new(), - } - } - - pub fn active_instances(&self) -> &[String] { - &self.active_instances - } -} - -impl ActorInterface for Provisioner { - type Incoming = ProvisionerMsg; - type Response = (); - - fn handle(&mut self, ctx: &Ctx, msg: ProvisionerMsg) { - match msg { - ProvisionerMsg::Provision(_request) => { - // In real deployment: invoke provider script, await result. - // In simulation: the sim harness sends SimProvisionResult. - } - ProvisionerMsg::Terminate(request) => { - self.active_instances.retain(|id| id != &request.instance_id); - // In real deployment: invoke provider destroy script. - // In simulation: just track the termination. - let _ = ctx.send( - self.coordinator_addr, - CoordinatorMsg::ProvisionResponse(ProvisionResponse { - job_id: request.job_id.clone(), - result: Err(ProvisionError::NoCapacity), // placeholder, terminate doesn't need response - }), - ); - } - ProvisionerMsg::SimProvisionResult { request, result } => { - if let Ok(ref instance) = result { - self.active_instances.push(instance.instance_id.clone()); - } - let _ = ctx.send( - self.coordinator_addr, - CoordinatorMsg::ProvisionResponse(ProvisionResponse { - job_id: request.job_id, - result, - }), - ); - } - } - } -} diff --git a/crates/ci/src/runner.rs b/crates/ci/src/runner.rs deleted file mode 100644 index 99c0357..0000000 --- a/crates/ci/src/runner.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! RunnerSupervisor actor: manages SSH session and job execution on a spot instance. -//! -//! Spawned per-job by the Coordinator. Owns the connection to the spot instance. - -use swactor::actor::{ActorAddress, ActorInterface, Ctx}; - -use crate::coordinator::CoordinatorMsg; -use crate::{JobComplete, JobFailure, JobProgress, JobSuccess, StartJob}; - -/// Messages the RunnerSupervisor can receive. -#[derive(Debug, Clone)] -pub enum RunnerMsg { - /// Begin executing the job (sent immediately after spawn via on_start). - Execute, - /// Simulated: job command output line. - OutputLine(String), - /// Simulated: job completed successfully. - SimComplete(Result<(), String>), -} - -/// RunnerSupervisor actor state. -/// -/// In real deployment, this would manage an SSH connection. -/// In simulation, job execution is driven by external messages. -pub struct RunnerSupervisor { - coordinator_addr: ActorAddress, - start_job: StartJob, -} - -impl RunnerSupervisor { - pub fn new(coordinator_addr: ActorAddress, start_job: StartJob) -> Self { - Self { - coordinator_addr, - start_job, - } - } -} - -impl ActorInterface for RunnerSupervisor { - type Incoming = RunnerMsg; - type Response = (); - - fn on_start(&mut self, ctx: &Ctx) { - // In simulation, the sim harness will send SimComplete messages. - // In real deployment, this would initiate SSH connection + command execution. - let _ = ctx.send(ctx.self_addr(), RunnerMsg::Execute); - } - - fn handle(&mut self, ctx: &Ctx, msg: RunnerMsg) { - match msg { - RunnerMsg::Execute => { - // In real mode, we'd SSH into the instance and run commands. - // In simulation, this is a no-op; SimComplete drives completion. - } - RunnerMsg::OutputLine(line) => { - let _ = ctx.send( - self.coordinator_addr, - CoordinatorMsg::JobProgress(JobProgress { - job_id: self.start_job.job_id.clone(), - output_line: line, - }), - ); - } - RunnerMsg::SimComplete(result) => { - let complete = JobComplete { - job_id: self.start_job.job_id.clone(), - result: match result { - Ok(()) => Ok(JobSuccess), - Err(msg) => Err(JobFailure::CommandFailed { - exit_code: 1, - last_lines: vec![msg], - }), - }, - artifacts: Vec::new(), - }; - let _ = ctx.send( - self.coordinator_addr, - CoordinatorMsg::JobComplete(complete), - ); - ctx.stop_self(); - } - } - } -} diff --git a/crates/ci/src/status_reporter.rs b/crates/ci/src/status_reporter.rs deleted file mode 100644 index 35f08e6..0000000 --- a/crates/ci/src/status_reporter.rs +++ /dev/null @@ -1,327 +0,0 @@ -//! StatusReporter actor: fire-and-forget Forgejo commit status updates. -//! -//! Receives status update messages and POSTs them to the Forgejo API. -//! Can also post pipeline summary comments to PRs. - -use swactor::actor::{ActorInterface, Ctx}; - -use crate::StatusUpdate; - -/// Captured output for a single job, used to build PR comments. -#[derive(Debug, Clone)] -pub struct JobOutput { - pub job_name: String, - pub passed: bool, - pub failure_reason: Option, - pub output_lines: Vec, -} - -/// Messages the StatusReporter can receive. -#[derive(Debug, Clone)] -pub enum StatusReporterMsg { - Report { - update: StatusUpdate, - forgejo_url: String, - forgejo_token: String, - }, - PostPipelineComment { - repo_owner: String, - repo_name: String, - commit_sha: String, - branch: String, - pipeline_name: String, - pipeline_state: String, - job_outputs: Vec, - forgejo_url: String, - forgejo_token: String, - }, -} - -/// StatusReporter actor state. -pub struct StatusReporter; - -impl StatusReporter { - pub fn new() -> Self { - Self - } - - #[cfg(feature = "local")] - fn post_status(update: &StatusUpdate, forgejo_url: &str, forgejo_token: &str) { - let url = format!( - "{}/api/v1/repos/{}/{}/statuses/{}", - forgejo_url.trim_end_matches('/'), - update.repo_owner, - update.repo_name, - update.commit_sha, - ); - - let mut body = serde_json::json!({ - "state": update.state, - "context": update.context, - "description": update.description, - }); - - if let Some(ref target_url) = update.target_url { - body["target_url"] = serde_json::Value::String(target_url.clone()); - } - - let result = ureq::post(&url) - .set("Authorization", &format!("token {forgejo_token}")) - .set("Content-Type", "application/json") - .send_string(&body.to_string()); - - if let Err(e) = result { - eprintln!("StatusReporter: failed to post status to {url}: {e}"); - } - } - - #[cfg(feature = "local")] - fn find_pr_for_branch( - forgejo_url: &str, - forgejo_token: &str, - repo_owner: &str, - repo_name: &str, - branch: &str, - ) -> Option { - let url = format!( - "{}/api/v1/repos/{}/{}/pulls?state=open&limit=50", - forgejo_url.trim_end_matches('/'), - repo_owner, - repo_name, - ); - - let response = ureq::get(&url) - .set("Authorization", &format!("token {forgejo_token}")) - .call(); - - let response = match response { - Ok(r) => r, - Err(e) => { - eprintln!("StatusReporter: failed to list PRs: {e}"); - return None; - } - }; - - let body: serde_json::Value = match response.into_json() { - Ok(v) => v, - Err(e) => { - eprintln!("StatusReporter: failed to parse PR list: {e}"); - return None; - } - }; - - let prs = body.as_array()?; - for pr in prs { - let head_ref = pr.get("head")?.get("ref")?.as_str()?; - if head_ref == branch { - return pr.get("number")?.as_u64(); - } - } - None - } - - #[cfg(feature = "local")] - fn post_pr_comment( - forgejo_url: &str, - forgejo_token: &str, - repo_owner: &str, - repo_name: &str, - pr_number: u64, - body_text: &str, - ) -> Option { - let url = format!( - "{}/api/v1/repos/{}/{}/issues/{}/comments", - forgejo_url.trim_end_matches('/'), - repo_owner, - repo_name, - pr_number, - ); - - let body = serde_json::json!({ - "body": body_text, - }); - - let result = ureq::post(&url) - .set("Authorization", &format!("token {forgejo_token}")) - .set("Content-Type", "application/json") - .send_string(&body.to_string()); - - match result { - Ok(response) => { - let json: serde_json::Value = response.into_json().ok()?; - json.get("html_url")?.as_str().map(|s| s.to_string()) - } - Err(e) => { - eprintln!("StatusReporter: failed to post PR comment: {e}"); - None - } - } - } - - #[cfg(feature = "local")] - fn build_pipeline_comment( - pipeline_name: &str, - pipeline_state: &str, - commit_sha: &str, - job_outputs: &[JobOutput], - ) -> String { - let mut md = format!("## Pipeline `{pipeline_name}` — {pipeline_state}\n\n"); - let short_sha = if commit_sha.len() > 7 { - &commit_sha[..7] - } else { - commit_sha - }; - md.push_str(&format!("Commit: `{short_sha}`\n\n")); - - for job in job_outputs { - let status_label = if job.passed { - "passed".to_string() - } else { - match &job.failure_reason { - Some(reason) => format!("failed: {reason}"), - None => "failed".to_string(), - } - }; - - md.push_str(&format!( - "
\n{} — {}\n\n", - job.job_name, status_label - )); - - let max_lines = 100; - let total = job.output_lines.len(); - let lines: &[String] = if total > max_lines { - md.push_str(&format!("_Showing last {max_lines} of {total} lines_\n\n")); - &job.output_lines[total - max_lines..] - } else { - &job.output_lines - }; - - md.push_str("```\n"); - for line in lines { - md.push_str(line); - md.push('\n'); - } - md.push_str("```\n\n
\n\n"); - } - - md - } - - #[cfg(feature = "local")] - fn handle_pipeline_comment( - repo_owner: &str, - repo_name: &str, - commit_sha: &str, - branch: &str, - pipeline_name: &str, - pipeline_state: &str, - job_outputs: &[JobOutput], - forgejo_url: &str, - forgejo_token: &str, - ) { - let pr_number = match Self::find_pr_for_branch( - forgejo_url, - forgejo_token, - repo_owner, - repo_name, - branch, - ) { - Some(n) => n, - None => { - eprintln!( - "StatusReporter: no open PR for branch '{branch}', skipping comment" - ); - return; - } - }; - - let comment_body = - Self::build_pipeline_comment(pipeline_name, pipeline_state, commit_sha, job_outputs); - - let comment_url = Self::post_pr_comment( - forgejo_url, - forgejo_token, - repo_owner, - repo_name, - pr_number, - &comment_body, - ); - - // Re-post pipeline status with target_url pointing to the comment. - if let Some(ref url) = comment_url { - let update = StatusUpdate { - repo_owner: repo_owner.to_string(), - repo_name: repo_name.to_string(), - commit_sha: commit_sha.to_string(), - state: pipeline_state.to_string(), - context: format!("ci/{pipeline_name}"), - description: format!("Pipeline '{pipeline_name}' {pipeline_state}"), - target_url: Some(url.clone()), - }; - Self::post_status(&update, forgejo_url, forgejo_token); - } - } -} - -impl ActorInterface for StatusReporter { - type Incoming = StatusReporterMsg; - type Response = (); - - fn handle(&mut self, _ctx: &Ctx, msg: StatusReporterMsg) { - match msg { - StatusReporterMsg::Report { - update, - forgejo_url, - forgejo_token, - } => { - #[cfg(feature = "local")] - Self::post_status(&update, &forgejo_url, &forgejo_token); - - #[cfg(not(feature = "local"))] - { - let _ = (update, forgejo_url, forgejo_token); - } - } - StatusReporterMsg::PostPipelineComment { - repo_owner, - repo_name, - commit_sha, - branch, - pipeline_name, - pipeline_state, - job_outputs, - forgejo_url, - forgejo_token, - } => { - #[cfg(feature = "local")] - Self::handle_pipeline_comment( - &repo_owner, - &repo_name, - &commit_sha, - &branch, - &pipeline_name, - &pipeline_state, - &job_outputs, - &forgejo_url, - &forgejo_token, - ); - - #[cfg(not(feature = "local"))] - { - let _ = ( - repo_owner, - repo_name, - commit_sha, - branch, - pipeline_name, - pipeline_state, - job_outputs, - forgejo_url, - forgejo_token, - ); - } - } - } - } -} diff --git a/crates/ci/src/webhook_server.rs b/crates/ci/src/webhook_server.rs deleted file mode 100644 index f636ebd..0000000 --- a/crates/ci/src/webhook_server.rs +++ /dev/null @@ -1,188 +0,0 @@ -//! Webhook HTTP listener: receives Forgejo webhook POSTs and forwards -//! them to the LocalCoordinator actor. -//! -//! Runs as a standard thread (not an actor) using `tiny_http`. - -use crate::{EventType, WebhookEvent}; - -/// Start the webhook listener in a new thread. -/// -/// Returns a join handle for the listener thread. -#[cfg(feature = "local")] -pub fn start_webhook_listener( - port: u16, - secret: String, - runtime: std::sync::Arc, - coordinator_addr: swactor::actor::ActorAddress, -) -> std::thread::JoinHandle<()> { - std::thread::Builder::new() - .name("webhook-listener".into()) - .spawn(move || { - let server = tiny_http::Server::http(format!("0.0.0.0:{port}")) - .expect("failed to start webhook server"); - - eprintln!("Webhook listener on http://0.0.0.0:{port}"); - - for mut request in server.incoming_requests() { - let response = handle_request(&mut request, &secret, &runtime, coordinator_addr); - let _ = request.respond(response); - } - }) - .expect("failed to spawn webhook listener thread") -} - -#[cfg(feature = "local")] -fn handle_request( - request: &mut tiny_http::Request, - secret: &str, - runtime: &std::sync::Arc, - coordinator_addr: swactor::actor::ActorAddress, -) -> tiny_http::Response>> { - use hmac::{Hmac, Mac}; - use sha2::Sha256; - use crate::local_coordinator::LocalCoordinatorMsg; - - // Only accept POST. - if request.method() != &tiny_http::Method::Post { - return tiny_http::Response::from_string("method not allowed") - .with_status_code(405); - } - - // Read body. - let mut body = String::new(); - if let Err(e) = std::io::Read::read_to_string(&mut request.as_reader(), &mut body) { - eprintln!("webhook: failed to read body: {e}"); - return tiny_http::Response::from_string("bad request") - .with_status_code(400); - } - - // Verify HMAC-SHA256 signature if secret is non-empty. - if !secret.is_empty() { - let sig_header = request - .headers() - .iter() - .find(|h| h.field.equiv("X-Forgejo-Signature")) - .map(|h| h.value.as_str().to_string()); - - match sig_header { - Some(sig_hex) => { - type HmacSha256 = Hmac; - let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) - .expect("HMAC key creation"); - hmac::Mac::update(&mut mac, body.as_bytes()); - let expected = hex::encode(mac.finalize().into_bytes()); - if sig_hex != expected { - eprintln!("webhook: signature mismatch"); - return tiny_http::Response::from_string("unauthorized") - .with_status_code(401); - } - } - None => { - eprintln!("webhook: missing signature header"); - return tiny_http::Response::from_string("unauthorized") - .with_status_code(401); - } - } - } - - // Determine event type from Forgejo header. - let event_header = request - .headers() - .iter() - .find(|h| h.field.equiv("X-Forgejo-Event")) - .map(|h| h.value.as_str().to_string()) - .unwrap_or_default(); - - let event_type = match event_header.as_str() { - "push" => EventType::Push, - "create" => EventType::Tag, - "pull_request" => EventType::Merge, - other => { - eprintln!("webhook: ignoring event type '{other}'"); - return tiny_http::Response::from_string("ignored").with_status_code(200); - } - }; - - // Parse JSON body to extract fields. - let json: serde_json::Value = match serde_json::from_str(&body) { - Ok(v) => v, - Err(e) => { - eprintln!("webhook: failed to parse JSON: {e}"); - return tiny_http::Response::from_string("bad json").with_status_code(400); - } - }; - - let webhook_event = match parse_webhook_json(&json, event_type) { - Some(e) => e, - None => { - eprintln!("webhook: could not extract webhook fields from JSON"); - return tiny_http::Response::from_string("bad payload").with_status_code(400); - } - }; - - // Send to coordinator. - let _ = runtime.send_to(coordinator_addr, LocalCoordinatorMsg::Webhook(webhook_event)); - - tiny_http::Response::from_string("ok").with_status_code(200) -} - -/// Parse a Forgejo webhook JSON payload into a WebhookEvent. -pub fn parse_webhook_json(json: &serde_json::Value, event_type: EventType) -> Option { - let repo = json.get("repository")?; - let repo_owner = repo - .get("owner") - .and_then(|o| o.get("login")) - .or_else(|| repo.get("owner").and_then(|o| o.get("username"))) - .and_then(|v| v.as_str())? - .to_string(); - let repo_name = repo.get("name").and_then(|v| v.as_str())?.to_string(); - - let (branch, commit_sha, tag) = match event_type { - EventType::Push => { - let reference = json.get("ref").and_then(|v| v.as_str()).unwrap_or(""); - let branch = reference.strip_prefix("refs/heads/").unwrap_or(reference); - let sha = json - .get("after") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - (branch.to_string(), sha, None) - } - EventType::Tag => { - let reference = json.get("ref").and_then(|v| v.as_str()).unwrap_or(""); - let tag_name = reference.strip_prefix("refs/tags/").unwrap_or(reference); - let sha = json - .get("sha") - .or_else(|| json.get("after")) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - (String::new(), sha, Some(tag_name.to_string())) - } - EventType::Merge => { - let pr = json.get("pull_request")?; - let branch = pr - .get("head") - .and_then(|h| h.get("ref")) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let sha = pr - .get("head") - .and_then(|h| h.get("sha")) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - (branch, sha, None) - } - }; - - Some(WebhookEvent { - event_type, - repo_owner, - repo_name, - branch, - commit_sha, - tag, - }) -} diff --git a/crates/dashboard/AGENTS.md b/crates/dashboard/AGENTS.md index 51ffad5..89542fe 100644 --- a/crates/dashboard/AGENTS.md +++ b/crates/dashboard/AGENTS.md @@ -9,13 +9,13 @@ to stdout (one object per line). Human-readable output goes to stderr. ### Launching ```bash -cargo run -p runtime-dashboard --example investigate_demo +cargo run -p dashboard --example investigate_demo ``` Or programmatically against any running runtime: ```rust -use runtime_dashboard::investigate::run_investigate; +use dashboard::investigate::run_investigate; run_investigate(runtime_arc, collector_arc)?; // blocks on stdin ``` diff --git a/crates/dashboard/Cargo.toml b/crates/dashboard/Cargo.toml index 5d7fd03..4601dea 100644 --- a/crates/dashboard/Cargo.toml +++ b/crates/dashboard/Cargo.toml @@ -5,7 +5,6 @@ edition = "2024" [dependencies] swactor = { path = "../..", features = ["serde", "tracing"] } -swactor-std = { path = "../std", default-features = false } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["registry"] } serde = { version = "1", features = ["derive"] } @@ -16,25 +15,14 @@ tokio-stream = "0.1" crossbeam-queue = "0.3.12" ratatui = { version = "0.29", optional = true, default-features = false, features = ["crossterm"] } crossterm = { version = "0.28", optional = true } -distribution = { path = "../distribution", optional = true } clap = { version = "4", features = ["derive"], optional = true } ctrlc = "3" -iroh = { version = "0.96", optional = true } [features] -default = ["distribution"] +default = [] tui = ["dep:ratatui", "dep:crossterm"] -distribution = ["dep:distribution"] -node = ["distribution", "dep:clap", "swactor/transport", "tcp"] -tcp = ["distribution/tcp"] -iroh = ["distribution/iroh", "dep:iroh"] [[bin]] name = "swactor-tui" path = "src/bin/tui.rs" required-features = ["tui"] - -[[bin]] -name = "swactor-node" -path = "src/bin/swactor-node.rs" -required-features = ["node"] diff --git a/crates/dashboard/README.md b/crates/dashboard/README.md index e9a70ab..3290178 100644 --- a/crates/dashboard/README.md +++ b/crates/dashboard/README.md @@ -1,4 +1,4 @@ -# runtime-dashboard +# dashboard Visual dashboard for the swactor runtime. Provides a live HTTP dashboard, a terminal UI (TUI), trace recording/replay, and an HTTP API for programmatic @@ -16,7 +16,7 @@ runtime investigation. Start the dashboard demo and open it in a browser: ```bash -cargo run -p runtime-dashboard --example dashboard_demo +cargo run -p dashboard --example dashboard_demo ``` Pages: @@ -33,9 +33,9 @@ membership and actor registrations in the directory/cache. A standalone binary that connects to any running dashboard over SSE: ```bash -cargo run -p runtime-dashboard --features tui --bin swactor-tui +cargo run -p dashboard --features tui --bin swactor-tui # or point at a specific endpoint -cargo run -p runtime-dashboard --features tui --bin swactor-tui -- http://localhost:9090 +cargo run -p dashboard --features tui --bin swactor-tui -- http://localhost:9090 ``` Views (cycle with Tab): @@ -70,7 +70,7 @@ All examples are run from the workspace root. **HTTP dashboard** — live workload with distribution cluster, Ctrl+C to stop: ```bash -cargo run -p runtime-dashboard --example dashboard_demo +cargo run -p dashboard --example dashboard_demo # http://localhost:9090 — runtime overview # http://localhost:9090/distribution — cluster view ``` @@ -78,14 +78,14 @@ cargo run -p runtime-dashboard --example dashboard_demo **Benchmarks** — four automated scenarios (~20 s total): ```bash -cargo run -p runtime-dashboard --example bench_dashboard +cargo run -p dashboard --example bench_dashboard # open http://localhost:9090 ``` **Record & replay** — records ~10 s of activity, then serves a replay: ```bash -cargo run -p runtime-dashboard --example record_and_replay_demo +cargo run -p dashboard --example record_and_replay_demo # live dashboard at http://localhost:9090 during recording # replay dashboard at http://localhost:9091 after recording finishes # Ctrl+C to stop diff --git a/crates/dashboard/examples/bench_dashboard.rs b/crates/dashboard/examples/bench_dashboard.rs deleted file mode 100644 index a2ed48f..0000000 --- a/crates/dashboard/examples/bench_dashboard.rs +++ /dev/null @@ -1,224 +0,0 @@ -use std::thread; -use std::time::{Duration, Instant}; - -use swactor::actor::{ActorAddress, ActorInterface, Ctx}; -use swactor::config::{BackoffPolicy, RuntimeConfig}; -use swactor::runtime::Runtime; - -use dashboard::collector::StatsCollector; -use dashboard::{start_dashboard, DashboardConfig}; - -// --------------------------------------------------------------------------- -// Actors -// --------------------------------------------------------------------------- - -#[derive(Clone)] -struct Work; - -struct SinkActor { - count: u64, -} - -impl SinkActor { - fn new() -> Self { - Self { count: 0 } - } -} - -impl ActorInterface for SinkActor { - type Incoming = Work; - type Response = (); - fn handle(&mut self, _ctx: &Ctx, _msg: Work) { - self.count += 1; - } -} - -#[derive(Clone)] -struct RingMsg; - -struct RingActor { - next: ActorAddress, -} - -impl ActorInterface for RingActor { - type Incoming = RingMsg; - type Response = (); - fn handle(&mut self, ctx: &Ctx, _msg: RingMsg) { - let _ = ctx.send(self.next, RingMsg); - } -} - -#[derive(Clone)] -struct SpawnCmd; - -struct SpawnerActor { - spawned: u64, -} - -impl SpawnerActor { - fn new() -> Self { - Self { spawned: 0 } - } -} - -impl ActorInterface for SpawnerActor { - type Incoming = SpawnCmd; - type Response = (); - fn handle(&mut self, ctx: &Ctx, _msg: SpawnCmd) { - for _ in 0..20 { - let _ = ctx.spawn(SinkActor::new()); - self.spawned += 1; - } - } -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn bench_config(threads: usize, max_actors: usize, max_messages: usize) -> RuntimeConfig { - RuntimeConfig { - num_threads: threads, - max_actors, - channel_buffer_size: max_messages, - backoff_policy: BackoffPolicy { - spin_threshold: 32, - yield_threshold: 64, - sleep_increment_us: 10, - sleep_max_us: 100, - }, - ..Default::default() - } -} - -fn run_for(duration: Duration, mut tick: impl FnMut()) { - let deadline = Instant::now() + duration; - while Instant::now() < deadline { - tick(); - } -} - -// --------------------------------------------------------------------------- -// Scenarios -// --------------------------------------------------------------------------- - -fn scenario_single_actor(dash: &dashboard::DashboardHandle) { - eprintln!(" [1/4] Single-actor bombardment (5s)"); - let collector = StatsCollector::new(4); - let mut rt = Runtime::new(bench_config(4, 64, 100_000)); - rt.set_stats_hook(collector.clone()); - let addr = rt.spawn(SinkActor::new()).unwrap(); - let handle = rt.run().unwrap(); - dash.set_runtime(handle.runtime.clone(), collector); - - run_for(Duration::from_secs(5), || { - for _ in 0..100 { - let _ = handle.runtime.send_to(addr, Work); - } - thread::sleep(Duration::from_millis(10)); - }); - - handle.shutdown(); - handle.join(); -} - -fn scenario_multi_actor(dash: &dashboard::DashboardHandle) { - eprintln!(" [2/4] Multi-actor fan-out (5s)"); - let collector = StatsCollector::new(4); - let mut rt = Runtime::new(bench_config(4, 128, 10_000)); - rt.set_stats_hook(collector.clone()); - let addrs: Vec<_> = (0..50) - .map(|_| rt.spawn(SinkActor::new()).unwrap()) - .collect(); - let handle = rt.run().unwrap(); - dash.set_runtime(handle.runtime.clone(), collector); - - run_for(Duration::from_secs(5), || { - for &addr in &addrs { - for _ in 0..10 { - let _ = handle.runtime.send_to(addr, Work); - } - } - thread::sleep(Duration::from_millis(20)); - }); - - handle.shutdown(); - handle.join(); -} - -fn scenario_ring(dash: &dashboard::DashboardHandle) { - eprintln!(" [3/4] Ring topology (5s)"); - let ring_size = 100; - let collector = StatsCollector::new(4); - let mut rt = Runtime::new(bench_config(4, ring_size + 64, 1_024)); - rt.set_stats_hook(collector.clone()); - - // Build ring backwards: last spawned actor is the entry point - let mut addrs = Vec::with_capacity(ring_size); - // First actor has no valid next yet — will be the tail of the chain - let first = rt.spawn(RingActor { next: ActorAddress::default() }).unwrap(); - addrs.push(first); - let mut prev = first; - for _ in 1..ring_size { - let addr = rt.spawn(RingActor { next: prev }).unwrap(); - addrs.push(addr); - prev = addr; - } - // The first actor's "next" should be the last actor to close the ring, - // but we can't mutate it. Instead, we inject at the last actor and - // the message flows: last -> second-to-last -> ... -> first -> (dead end). - // For dashboard visualization, a chain is fine — it creates sustained cross-worker traffic. - let entry = *addrs.last().unwrap(); - - let handle = rt.run().unwrap(); - dash.set_runtime(handle.runtime.clone(), collector); - - run_for(Duration::from_secs(5), || { - let _ = handle.runtime.send_to(entry, RingMsg); - thread::sleep(Duration::from_millis(50)); - }); - - handle.shutdown(); - handle.join(); -} - -fn scenario_spawn_storm(dash: &dashboard::DashboardHandle) { - eprintln!(" [4/4] Spawn storm (5s)"); - let collector = StatsCollector::new(4); - let mut rt = Runtime::new(bench_config(4, 50_000, 1_024)); - rt.set_stats_hook(collector.clone()); - let spawner = rt.spawn(SpawnerActor::new()).unwrap(); - let handle = rt.run().unwrap(); - dash.set_runtime(handle.runtime.clone(), collector); - - run_for(Duration::from_secs(5), || { - let _ = handle.runtime.send_to(spawner, SpawnCmd); - thread::sleep(Duration::from_millis(200)); - }); - - handle.shutdown(); - handle.join(); -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -fn main() { - let dash = start_dashboard(DashboardConfig { - port: 9090, - ..Default::default() - }); - dash.install_tracing(); - - eprintln!("Dashboard at http://localhost:9090"); - eprintln!("Running 4 benchmark scenarios (~20s total)...\n"); - - scenario_single_actor(&dash); - scenario_multi_actor(&dash); - scenario_ring(&dash); - scenario_spawn_storm(&dash); - - eprintln!("\nAll scenarios complete. Shutting down."); - dash.shutdown(); -} diff --git a/crates/dashboard/examples/dashboard_demo.rs b/crates/dashboard/examples/dashboard_demo.rs deleted file mode 100644 index 0b3c9d8..0000000 --- a/crates/dashboard/examples/dashboard_demo.rs +++ /dev/null @@ -1,503 +0,0 @@ -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use std::thread; -use std::time::Duration; - -use swactor::actor::{ActorAddress, ActorInterface, Ctx}; -use swactor::config::RuntimeConfig; -use swactor::runtime::Runtime; - -use distribution::node::{DistributedNode, DistributedNodeConfig, ResolveResult}; -use distribution::snapshot::DistributionNodeSnapshot; -use distribution::swim::node::NodeAction; -use distribution::swim::probe::SwimConfig; -use distribution::types::NodeId; - -use dashboard::collector::StatsCollector; -use dashboard::distribution_collector::DistributionStatsProvider; -use dashboard::{start_dashboard, DashboardConfig}; - -// ── Demo actors ───────────────────────────────────────────────────────── - -#[derive(Clone)] -struct Ping(ActorAddress); - -struct PingActor { - count: u32, -} - -impl PingActor { - fn new() -> Self { - Self { count: 0 } - } -} - -impl ActorInterface for PingActor { - type Incoming = Ping; - type Response = (); - - fn handle(&mut self, ctx: &Ctx, msg: Ping) { - self.count += 1; - // Forward to the target — creates cross-worker traffic - if self.count < 10_000 { - let _ = ctx.send(msg.0, Ping(ctx.self_addr())); - } - } -} - -#[derive(Clone)] -struct Tick; - -struct CounterActor { - ticks: u64, -} - -impl CounterActor { - fn new() -> Self { - Self { ticks: 0 } - } -} - -impl ActorInterface for CounterActor { - type Incoming = Tick; - type Response = (); - - fn handle(&mut self, _ctx: &Ctx, _msg: Tick) { - self.ticks += 1; - } -} - -// ── Snapshot provider ─────────────────────────────────────────────────── - -struct SnapshotProvider { - snapshot: Arc>>, -} - -impl DistributionStatsProvider for SnapshotProvider { - fn snapshot(&self) -> Option { - self.snapshot.lock().unwrap().clone() - } -} - -// ── In-process action delivery ────────────────────────────────────────── - -/// Tick all live nodes and deliver their actions to other nodes. -fn tick_all_and_deliver( - nodes: &mut [Option], - node_ids: &[NodeId], -) { - let n = nodes.len(); - - // Collect tick actions from all live nodes. - let mut all_actions: Vec<(usize, Vec)> = Vec::new(); - for idx in 0..n { - if let Some(ref mut node) = nodes[idx] { - let actions = node.tick(); - if !actions.is_empty() { - all_actions.push((idx, actions)); - } - } - } - - // Deliver all actions and collect responses. - for (sender_idx, actions) in all_actions { - let tagged_responses = deliver_actions_tagged( - &actions, - node_ids[sender_idx], - nodes, - node_ids, - ); - for (responder_idx, response_actions) in tagged_responses { - deliver_actions_tagged( - &response_actions, - node_ids[responder_idx], - nodes, - node_ids, - ); - } - } -} - -/// Deliver actions to the appropriate target nodes. -/// Returns responses tagged with the index of the responding node. -/// `None` nodes (killed) silently drop actions — simulates network loss. -fn deliver_actions_tagged( - actions: &[NodeAction], - sender_id: NodeId, - nodes: &mut [Option], - node_ids: &[NodeId], -) -> Vec<(usize, Vec)> { - let mut tagged_responses: Vec<(usize, Vec)> = Vec::new(); - - for action in actions { - match action { - NodeAction::SendPing { - to, - sequence, - piggyback, - } => { - if let Some(idx) = node_ids.iter().position(|id| id == to) { - if let Some(ref mut node) = nodes[idx] { - let resp = - node.handle_ping(sender_id, *sequence, piggyback); - if !resp.is_empty() { - tagged_responses.push((idx, resp)); - } - } - } - } - NodeAction::SendAck { - to, - sequence, - piggyback, - } => { - if let Some(idx) = node_ids.iter().position(|id| id == to) { - if let Some(ref mut node) = nodes[idx] { - let resp = node.handle_ack(sender_id, *sequence, piggyback); - if !resp.is_empty() { - tagged_responses.push((idx, resp)); - } - } - } - } - NodeAction::SendJoinResponse { to, members, .. } => { - if let Some(idx) = node_ids.iter().position(|id| id == to) { - if let Some(ref mut node) = nodes[idx] { - let resp = node.handle_join_response(members.clone()); - if !resp.is_empty() { - tagged_responses.push((idx, resp)); - } - } - } - } - NodeAction::SendPingReq { - relay, - target, - sequence, - piggyback, - } => { - if let Some(idx) = node_ids.iter().position(|id| id == relay) { - if let Some(ref mut node) = nodes[idx] { - let resp = node.handle_ping_req( - sender_id, - *target, - *sequence, - piggyback, - ); - if !resp.is_empty() { - tagged_responses.push((idx, resp)); - } - } - } - } - NodeAction::ForwardAck { to, target, sequence, piggyback } => { - if let Some(idx) = node_ids.iter().position(|id| id == to) { - if let Some(ref mut node) = nodes[idx] { - let resp = node.handle_indirect_ack(*target, *sequence, piggyback); - if !resp.is_empty() { - tagged_responses.push((idx, resp)); - } - } - } - } - NodeAction::MembershipChanged { .. } => { - // Notifications — no delivery needed - } - } - } - - tagged_responses -} - -/// Simulate a join handshake: the joining node sends a join request to the -/// seed, and the seed's response is delivered back. -fn simulate_join( - joining_idx: usize, - seed_idx: usize, - nodes: &mut [Option], - node_ids: &[NodeId], -) { - let joining_id = node_ids[joining_idx]; - - // Seed handles the join request - let response_actions = if let Some(ref mut seed) = nodes[seed_idx] { - seed.handle_join_request(joining_id) - } else { - return; - }; - - // Deliver responses (SendJoinResponse) back to the joining node - let seed_id = node_ids[seed_idx]; - let tagged = deliver_actions_tagged(&response_actions, seed_id, nodes, node_ids); - for (responder_idx, response_actions) in tagged { - deliver_actions_tagged(&response_actions, node_ids[responder_idx], nodes, node_ids); - } -} - -// ── Main ──────────────────────────────────────────────────────────────── - -fn main() { - let stop = Arc::new(AtomicBool::new(false)); - - // Handle Ctrl+C gracefully - { - let stop = Arc::clone(&stop); - ctrlc::set_handler(move || { - stop.store(true, Ordering::Relaxed); - }) - .expect("failed to set Ctrl+C handler"); - } - - let dash = start_dashboard(DashboardConfig { - port: 9090, - ..Default::default() - }); - dash.install_tracing(); - - let num_threads = 4; - let collector = StatsCollector::new(num_threads); - - let mut rt = Runtime::new(RuntimeConfig { - num_threads, - max_actors: 1024, - channel_buffer_size: 2000, - ..Default::default() - }); - rt.set_stats_hook(collector.clone()); - - // Spawn ping actors for cross-worker traffic - let mut ping_addrs = Vec::new(); - for _ in 0..16 { - let addr = rt.spawn(PingActor::new()).unwrap(); - ping_addrs.push(addr); - } - - // Spawn counter actors for sustained traffic - let mut counter_addrs = Vec::new(); - for _ in 0..20 { - let addr = rt.spawn(CounterActor::new()).unwrap(); - counter_addrs.push(addr); - } - - let handle = rt.run().expect("failed to start runtime"); - dash.set_runtime(handle.runtime.clone(), collector); - - // ── Distribution cluster ──────────────────────────────────────────── - - let swim_config = SwimConfig { - probe_interval: 5, - probe_timeout: 2, - indirect_probes: 2, - suspicion_timeout: 20, - dead_reprobe_interval: 50, - }; - - let num_nodes = 9; // 1 main + 8 peers - let mut nodes: Vec> = Vec::with_capacity(num_nodes); - let mut node_ids: Vec = Vec::with_capacity(num_nodes); - - for i in 0..num_nodes { - let config = DistributedNodeConfig { - swim: swim_config.clone(), - cache_capacity: if i == 0 { 1000 } else { 100 }, - republish_interval: 500, - ..Default::default() - }; - let node = DistributedNode::new(config); - node_ids.push(node.node_id()); - nodes.push(Some(node)); - } - - // Join handshakes: nodes[1..] join via seed (node 0). - for i in 1..num_nodes { - simulate_join(i, 0, &mut nodes, &node_ids); - } - - // Settle: let SWIM converge initial membership. - for _ in 0..5 { - tick_all_and_deliver(&mut nodes, &node_ids); - } - - // Register spawned actors in the main node's directory. - for addr in ping_addrs.iter().chain(counter_addrs.iter()) { - if let Some(ref mut node) = nodes[0] { - node.register_actor(*addr, 1); - } - } - - // Snapshot provider for the dashboard. - let cached_snapshot = Arc::new(Mutex::new( - nodes[0].as_ref().map(|n| n.snapshot()), - )); - let provider = SnapshotProvider { - snapshot: Arc::clone(&cached_snapshot), - }; - dash.set_distribution(Arc::new(provider)); - - // ── Run ───────────────────────────────────────────────────────────── - - eprintln!("Dashboard at http://localhost:9090 — press Ctrl+C to stop"); - eprintln!("Distribution at http://localhost:9090/distribution"); - - // Kick off ping-pong chains - for i in 0..ping_addrs.len() { - let target = ping_addrs[(i + 1) % ping_addrs.len()]; - let _ = handle.runtime.send_to(ping_addrs[i], Ping(target)); - } - - let mut round: u64 = 0; - while !stop.load(Ordering::Relaxed) { - // Send ticks to all counter actors - for addr in &counter_addrs { - let _ = handle.runtime.send_to(*addr, Tick); - } - - // Periodically spawn more actors and register them - if round % 150 == 75 && counter_addrs.len() < 500 { - for _ in 0..8 { - match handle.runtime.spawn(CounterActor::new()) { - Ok(addr) => { - counter_addrs.push(addr); - if let Some(ref mut node) = nodes[0] { - node.register_actor(addr, 1); - } - } - Err(_) => break, - } - } - } - - // Periodically re-kick ping chains - if round % 80 == 0 && round > 0 { - for i in 0..ping_addrs.len() { - let target = ping_addrs[(i + 1) % ping_addrs.len()]; - let _ = handle.runtime.send_to(ping_addrs[i], Ping(target)); - } - } - - // Tick all distribution nodes and deliver SWIM actions - tick_all_and_deliver(&mut nodes, &node_ids); - - // Periodically resolve actors from main node - if round % 50 == 25 { - if let Some(ref mut main_node) = nodes[0] { - let actor = ping_addrs[(round as usize / 50) % ping_addrs.len()]; - match main_node.resolve_actor(&actor) { - ResolveResult::Cached(found_on) => { - tracing::info!(actor = ?&actor.0[..4], ?found_on, "resolved actor (cached)"); - } - ResolveResult::NeedsLookup { .. } => { - tracing::info!(actor = ?&actor.0[..4], "resolve: needs lookup"); - } - ResolveResult::NotFound => { - tracing::info!(actor = ?&actor.0[..4], "resolve: not found"); - } - } - } - } - - // Periodically register actors on a peer and propagate entries to main node - if round % 100 == 0 && round > 0 { - let peer_idx = 1 + ((round as usize / 100) % (num_nodes - 1)); - // Register on the peer, collect entries - let mut entries = Vec::new(); - if let Some(ref mut peer) = nodes[peer_idx] { - for _ in 0..3 { - let actor = ActorAddress::new_random(); - let entry = peer.register_actor(actor, round); - entries.push(entry); - } - } - // Propagate to main node (separate borrow) - if let Some(ref mut main_node) = nodes[0] { - for entry in entries { - main_node.store_directory_entry(entry); - } - } - } - - // ── Churn cycle (repeats every 400 rounds, starts at round 200) ── - // - // Offsets within each 400-round cycle: - // 0 → kill peer 8 (simulated crash) - // 150 → revive peer 8 (rejoin cluster) - // 200 → graceful leave for peer 7 - // 350 → rejoin peer 7 - if round >= 200 { - let churn_pos = (round - 200) % 400; - - // Kill peer 8 (simulated crash — set to None) - if churn_pos == 0 { - nodes[8] = None; - tracing::info!("killed peer 8 (simulated crash)"); - } - - // Revive peer 8 (new node + rejoin) - if churn_pos == 150 { - let config = DistributedNodeConfig { - swim: swim_config.clone(), - cache_capacity: 100, - republish_interval: 500, - ..Default::default() - }; - let revived = DistributedNode::new(config); - nodes[8] = Some(revived); - node_ids[8] = nodes[8].as_ref().unwrap().node_id(); - simulate_join(8, 0, &mut nodes, &node_ids); - tracing::info!("revived peer 8 (rejoined cluster)"); - } - - // Graceful leave for peer 7 - if churn_pos == 200 { - let leave_actions = nodes[7] - .as_mut() - .map(|n| n.leave()) - .unwrap_or_default(); - if !leave_actions.is_empty() { - let tagged_responses = deliver_actions_tagged( - &leave_actions, - node_ids[7], - &mut nodes, - &node_ids, - ); - for (responder_idx, response_actions) in tagged_responses { - deliver_actions_tagged( - &response_actions, - node_ids[responder_idx], - &mut nodes, - &node_ids, - ); - } - } - nodes[7] = None; - tracing::info!("peer 7 gracefully left the cluster"); - } - - // Rejoin peer 7 - if churn_pos == 350 { - let config = DistributedNodeConfig { - swim: swim_config.clone(), - cache_capacity: 100, - republish_interval: 500, - ..Default::default() - }; - let revived = DistributedNode::new(config); - nodes[7] = Some(revived); - node_ids[7] = nodes[7].as_ref().unwrap().node_id(); - simulate_join(7, 0, &mut nodes, &node_ids); - tracing::info!("peer 7 rejoined the cluster"); - } - } - - // Update cached snapshot for the dashboard - *cached_snapshot.lock().unwrap() = nodes[0].as_ref().map(|n| n.snapshot()); - - round += 1; - thread::sleep(Duration::from_millis(200)); - } - - eprintln!("\nShutting down..."); - handle.shutdown(); - dash.shutdown(); - handle.join(); -} diff --git a/crates/dashboard/examples/record_and_replay_demo.rs b/crates/dashboard/examples/record_and_replay_demo.rs deleted file mode 100644 index 13d5292..0000000 --- a/crates/dashboard/examples/record_and_replay_demo.rs +++ /dev/null @@ -1,162 +0,0 @@ -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::thread; -use std::time::Duration; - -use swactor::actor::{ActorAddress, ActorInterface, Ctx}; -use swactor::config::RuntimeConfig; -use swactor::runtime::Runtime; - -use dashboard::collector::StatsCollector; -use dashboard::{serve_replay, start_dashboard, DashboardConfig, ReplayConfig}; - -// ── Demo actors ───────────────────────────────────────────────────────── - -#[derive(Clone)] -struct Ping(ActorAddress); - -struct PingActor { - count: u32, -} - -impl PingActor { - fn new() -> Self { - Self { count: 0 } - } -} - -impl ActorInterface for PingActor { - type Incoming = Ping; - type Response = (); - - fn handle(&mut self, ctx: &Ctx, msg: Ping) { - self.count += 1; - if self.count < 100 { - let _ = ctx.send(msg.0, Ping(ctx.self_addr())); - } - } -} - -#[derive(Clone)] -struct Tick; - -struct CounterActor; - -impl ActorInterface for CounterActor { - type Incoming = Tick; - type Response = (); - - fn handle(&mut self, _ctx: &Ctx, _msg: Tick) {} -} - -// ── Main ──────────────────────────────────────────────────────────────── - -fn main() { - // ── Phase 1: Record ───────────────────────────────────────────────── - - let dash = start_dashboard(DashboardConfig { - port: 9090, - record: true, - ..Default::default() - }); - dash.install_tracing(); - - let num_threads = 4; - let collector = StatsCollector::new(num_threads); - - let mut rt = Runtime::new(RuntimeConfig { - num_threads, - max_actors: 512, - channel_buffer_size: 1000, - ..Default::default() - }); - rt.set_stats_hook(collector.clone()); - - let mut ping_addrs = Vec::new(); - for _ in 0..12 { - let addr = rt.spawn(PingActor::new()).unwrap(); - ping_addrs.push(addr); - } - - let mut counter_addrs = Vec::new(); - for _ in 0..16 { - let addr = rt.spawn(CounterActor).unwrap(); - counter_addrs.push(addr); - } - - let handle = rt.run().expect("failed to start runtime"); - dash.set_runtime(handle.runtime.clone(), collector); - - eprintln!("Recording trace for ~10 seconds..."); - eprintln!("Live dashboard at http://localhost:9090"); - - // Kick off ping-pong chains - for i in 0..ping_addrs.len() { - let target = ping_addrs[(i + 1) % ping_addrs.len()]; - let _ = handle.runtime.send_to(ping_addrs[i], Ping(target)); - } - - for round in 0..50 { - for addr in &counter_addrs { - let _ = handle.runtime.send_to(*addr, Tick); - } - - if round == 20 { - for _ in 0..8 { - let addr = handle.runtime.spawn(CounterActor).unwrap(); - counter_addrs.push(addr); - } - eprintln!(" Spawned 8 more actors"); - } - - if round == 25 { - for i in 0..ping_addrs.len() { - let target = ping_addrs[(i + 1) % ping_addrs.len()]; - let _ = handle.runtime.send_to(ping_addrs[i], Ping(target)); - } - } - - thread::sleep(Duration::from_millis(200)); - } - - handle.shutdown(); - dash.shutdown(); - handle.join(); - - // Save trace - let path = "runtime_trace.json"; - match dash.save_trace(path) { - Ok(()) => eprintln!("Trace saved to {path}"), - Err(e) => { - eprintln!("Failed to save trace: {e}"); - std::process::exit(1); - } - } - - // ── Phase 2: Replay ───────────────────────────────────────────────── - - let stop = Arc::new(AtomicBool::new(false)); - { - let stop = Arc::clone(&stop); - ctrlc::set_handler(move || { - stop.store(true, Ordering::Relaxed); - }) - .expect("failed to set Ctrl+C handler"); - } - - eprintln!("\nStarting replay at 2x speed — press Ctrl+C to stop"); - - // Spawn replay server in a background thread so we can check Ctrl+C - let replay_path = path.to_string(); - thread::spawn(move || { - if let Err(e) = serve_replay(&replay_path, ReplayConfig { port: 9091, speed: 2.0 }) { - eprintln!("Replay error: {e}"); - } - }); - - while !stop.load(Ordering::Relaxed) { - thread::sleep(Duration::from_millis(200)); - } - - eprintln!("Done."); -} diff --git a/crates/dashboard/src/actor_detail_html.rs b/crates/dashboard/src/actor_detail_html.rs index 4b4c41e..3d98f33 100644 --- a/crates/dashboard/src/actor_detail_html.rs +++ b/crates/dashboard/src/actor_detail_html.rs @@ -123,8 +123,8 @@ pub const ACTOR_DETAIL_HTML: &str = r##" diff --git a/crates/dashboard/src/actors_html.rs b/crates/dashboard/src/actors_html.rs index 9595175..8a07027 100644 --- a/crates/dashboard/src/actors_html.rs +++ b/crates/dashboard/src/actors_html.rs @@ -151,8 +151,8 @@ pub const ACTORS_HTML: &str = r##"
diff --git a/crates/dashboard/src/bin/swactor-node.rs b/crates/dashboard/src/bin/swactor-node.rs deleted file mode 100644 index e06df89..0000000 --- a/crates/dashboard/src/bin/swactor-node.rs +++ /dev/null @@ -1,307 +0,0 @@ -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use std::thread; -use std::time::Duration; - -use clap::Parser; - -use swactor::actor::{ActorInterface, Ctx}; -use swactor::config::RuntimeConfig; -use swactor::runtime::Runtime; - -use distribution::node::DistributedNodeConfig; -use distribution::registry::RegistryConfig; -use distribution::snapshot::DistributionNodeSnapshot; -use distribution::swim::probe::SwimConfig; - -use dashboard::collector::StatsCollector; -use dashboard::distribution_collector::DistributionStatsProvider; -use dashboard::{start_dashboard, DashboardConfig}; - -// ── CLI ────────────────────────────────────────────────────────────────── - -#[derive(Parser)] -#[command(name = "swactor-node", about = "Swactor distributed node")] -struct Args { - /// Transport to use: tcp or iroh - #[arg(long, default_value = "tcp")] - transport: String, - - /// Address to listen on for TCP transport (e.g. 10.0.1.10:7000) - #[arg(long)] - listen: Option, - - /// Seed node address to join (TCP mode: host:port) - #[arg(long)] - seed: Option, - - /// Seed node's iroh public key (iroh mode: hex-encoded 32-byte key) - #[arg(long)] - seed_node_id: Option, - - /// Dashboard HTTP port - #[arg(long, default_value = "9090")] - dashboard_port: u16, - - /// Number of dummy actors to register in the directory - #[arg(long, default_value = "0")] - actors: usize, -} - -// ── Dummy actor ────────────────────────────────────────────────────────── - -#[derive(Clone)] -struct Heartbeat; - -struct HeartbeatActor; - -impl ActorInterface for HeartbeatActor { - type Incoming = Heartbeat; - type Response = (); - - fn handle(&mut self, _ctx: &Ctx, _msg: Heartbeat) {} -} - -// ── Snapshot provider ──────────────────────────────────────────────────── - -struct SnapshotProvider { - snapshot: Arc>>, -} - -impl DistributionStatsProvider for SnapshotProvider { - fn snapshot(&self) -> Option { - self.snapshot.lock().unwrap().clone() - } -} - -// ── Main ───────────────────────────────────────────────────────────────── - -fn main() { - let args = Args::parse(); - let stop = Arc::new(AtomicBool::new(false)); - - // Handle SIGTERM / Ctrl+C - { - let stop = Arc::clone(&stop); - ctrlc::set_handler(move || { - stop.store(true, Ordering::Relaxed); - }) - .expect("failed to set signal handler"); - } - - // Start dashboard - let dash = start_dashboard(DashboardConfig { - port: args.dashboard_port, - ..Default::default() - }); - dash.install_tracing(); - dash.start_http_standalone(); - - // Create actor runtime - let num_threads = 2; - let collector = StatsCollector::new(num_threads); - let mut rt = Runtime::new(RuntimeConfig { - num_threads, - max_actors: 1024, - channel_buffer_size: 2000, - ..Default::default() - }); - rt.set_stats_hook(collector.clone()); - - let handle = rt.run().expect("failed to start runtime"); - dash.set_runtime(handle.runtime.clone(), collector); - - // Distribution config (shared between transports) - let swim_config = SwimConfig { - probe_interval: 5, - probe_timeout: 3, - indirect_probes: 2, - suspicion_timeout: 20, - dead_reprobe_interval: 50, - }; - let node_config = DistributedNodeConfig { - swim: swim_config, - cache_capacity: 1000, - republish_interval: 500, - ..Default::default() - }; - - match args.transport.as_str() { - #[cfg(feature = "tcp")] - "tcp" => run_tcp(args, node_config, &handle, &dash, &stop), - #[cfg(feature = "iroh")] - "iroh" => run_iroh(args, node_config, &handle, &dash, &stop), - other => { - eprintln!("Unknown or unavailable transport: {other}"); - eprintln!("Available transports:"); - #[cfg(feature = "tcp")] - eprintln!(" tcp"); - #[cfg(feature = "iroh")] - eprintln!(" iroh"); - std::process::exit(1); - } - } - - eprintln!("\nShutting down..."); - handle.shutdown(); - dash.shutdown(); - handle.join(); -} - -// ── TCP transport ──────────────────────────────────────────────────────── - -#[cfg(feature = "tcp")] -fn run_tcp( - args: Args, - node_config: DistributedNodeConfig, - handle: &swactor::runtime::RuntimeHandle, - dash: &dashboard::DashboardHandle, - stop: &Arc, -) { - use distribution::driver::NodeDriver; - - let listen_addr = args.listen.expect("--listen is required for TCP mode"); - let mut driver = NodeDriver::new(listen_addr, node_config).expect("failed to create node driver"); - - eprintln!( - "Node {} listening on {} (TCP)", - hex(&driver.node_id().0[..4]), - driver.listen_addr(), - ); - - // Join seed if provided - if let Some(seed) = args.seed { - let seed_addr: std::net::SocketAddr = seed.parse().expect("invalid seed address"); - eprintln!("Joining cluster via seed {seed_addr}"); - driver.join(&[seed_addr]); - } - - // Spawn and register actors - let actor_addrs = spawn_actors(args.actors, handle, driver.node_mut()); - - // Wire distribution snapshot to dashboard - let cached_snapshot: Arc>> = - Arc::new(Mutex::new(Some(driver.snapshot()))); - let provider = SnapshotProvider { - snapshot: Arc::clone(&cached_snapshot), - }; - dash.set_distribution(Arc::new(provider)); - - eprintln!("Dashboard at http://0.0.0.0:{}", args.dashboard_port); - - // Main loop - while !stop.load(Ordering::Relaxed) { - driver.recv(); - driver.tick(); - - for addr in &actor_addrs { - let _ = handle.runtime.send_to(*addr, Heartbeat); - } - - *cached_snapshot.lock().unwrap() = Some(driver.snapshot()); - thread::sleep(Duration::from_millis(100)); - } -} - -// ── iroh transport ─────────────────────────────────────────────────────── - -#[cfg(feature = "iroh")] -fn run_iroh( - args: Args, - node_config: DistributedNodeConfig, - handle: &swactor::runtime::RuntimeHandle, - dash: &dashboard::DashboardHandle, - stop: &Arc, -) { - use distribution::iroh_driver::{IrohDriver, IrohDriverConfig}; - use iroh::RelayMode; - - let iroh_config = IrohDriverConfig { - secret_key: None, - relay_mode: RelayMode::Default, - node: node_config, - peer_auth: None, - additional_alpns: vec![], - }; - let mut driver = IrohDriver::new(iroh_config).expect("failed to create iroh driver"); - - eprintln!( - "Node {} started (iroh)", - hex(&driver.node_id().0[..4]), - ); - - // Join seed if provided - if let Some(seed_hex) = args.seed_node_id { - let seed_bytes = hex_to_bytes(&seed_hex).expect("invalid seed node ID hex"); - let seed_key = iroh::PublicKey::from_bytes(&seed_bytes).expect("invalid seed public key"); - eprintln!("Joining cluster via seed {}", &seed_hex[..8]); - driver.join(&[seed_key]); - } - - // Spawn and register actors - let actor_addrs = spawn_actors(args.actors, handle, driver.node_mut()); - - // Wire distribution snapshot to dashboard - let cached_snapshot: Arc>> = - Arc::new(Mutex::new(Some(driver.snapshot()))); - let provider = SnapshotProvider { - snapshot: Arc::clone(&cached_snapshot), - }; - dash.set_distribution(Arc::new(provider)); - - eprintln!("Dashboard at http://0.0.0.0:{}", args.dashboard_port); - - // Main loop - while !stop.load(Ordering::Relaxed) { - driver.recv(); - driver.tick(); - - for addr in &actor_addrs { - let _ = handle.runtime.send_to(*addr, Heartbeat); - } - - *cached_snapshot.lock().unwrap() = Some(driver.snapshot()); - thread::sleep(Duration::from_millis(100)); - } - - driver.shutdown(); -} - -// ── Helpers ────────────────────────────────────────────────────────────── - -fn spawn_actors( - count: usize, - handle: &swactor::runtime::RuntimeHandle, - node: &mut distribution::node::DistributedNode, -) -> Vec { - let mut addrs = Vec::new(); - for _ in 0..count { - match handle.runtime.spawn(HeartbeatActor) { - Ok(addr) => { - node.register_actor(addr, 1); - addrs.push(addr); - } - Err(e) => eprintln!("failed to spawn actor: {e}"), - } - } - if !addrs.is_empty() { - eprintln!("Registered {} actors", addrs.len()); - } - addrs -} - -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() -} - -#[cfg(feature = "iroh")] -fn hex_to_bytes(hex: &str) -> Option<[u8; 32]> { - if hex.len() != 64 { - return None; - } - let mut bytes = [0u8; 32]; - for i in 0..32 { - bytes[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?; - } - Some(bytes) -} diff --git a/crates/dashboard/src/ci_collector.rs b/crates/dashboard/src/ci_collector.rs deleted file mode 100644 index 8629fa2..0000000 --- a/crates/dashboard/src/ci_collector.rs +++ /dev/null @@ -1,242 +0,0 @@ -//! CI Dashboard extension: provides HTTP API endpoints and stats for CI pipelines. - -use serde::{Deserialize, Serialize}; - -// CI types defined locally to avoid a cyclic dependency with swactor-ci. - -/// Unique identifier for a pipeline execution. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct PipelineId(pub u64); - -/// Unique identifier for a job within a pipeline. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct JobId { - pub pipeline_id: PipelineId, - pub job_name: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum JobStatus { - Pending, - WaitingForProvisioner, - Provisioning, - Running, - Passed, - Failed { reason: String }, - Skipped, - Interrupted, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum PipelineStatus { - Pending, - Running, - Passed, - Failed, - Error { reason: String }, -} - -// ─── Stats Provider ───────────────────────────────────────────────────────── - -/// Trait for providing CI snapshot data to the dashboard. -pub trait CiStatsProvider: Send + Sync { - fn snapshot(&self) -> CiSnapshot; -} - -/// Point-in-time snapshot of CI system state. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CiSnapshot { - pub active_pipelines: Vec, - pub recent_pipelines: Vec, - pub provisioner_status: ProvisionerStatus, - pub active_instances: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum ProvisionerStatus { - Online, - Offline, - Unknown, -} - -/// Snapshot of a single pipeline execution. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PipelineSnapshot { - pub pipeline_id: PipelineId, - pub pipeline_name: String, - pub repo_owner: String, - pub repo_name: String, - pub commit_sha: String, - pub branch: String, - pub status: PipelineStatus, - pub jobs: Vec, -} - -/// Snapshot of a single job within a pipeline. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JobSnapshot { - pub job_id: JobId, - pub job_name: String, - pub status: JobStatus, - pub output_line_count: usize, -} - -// ─── HTTP API Responses ───────────────────────────────────────────────────── - -/// Response for GET /api/ci/pipelines -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PipelineListResponse { - pub pipelines: Vec, -} - -/// Response for GET /api/ci/pipelines/{id} -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PipelineDetailResponse { - pub pipeline: PipelineSnapshot, -} - -/// Response for GET /api/ci/status -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SystemStatusResponse { - pub provisioner_status: ProvisionerStatus, - pub active_pipelines: usize, - pub active_instances: usize, -} - -/// Response for GET /api/ci/pipelines/{id}/jobs/{job_id}/log -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JobLogResponse { - pub job_id: JobId, - pub lines: Vec, -} - -// ─── Route Matching ───────────────────────────────────────────────────────── - -/// Parsed API route for the CI dashboard. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CiRoute { - ListPipelines, - PipelineDetail { id: u64 }, - JobLog { pipeline_id: u64, job_name: String }, - Artifact { job_id: String, path: String }, - SystemStatus, - NotFound, -} - -/// Parse a request path into a CiRoute. -pub fn parse_route(path: &str) -> CiRoute { - let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect(); - match parts.as_slice() { - ["api", "ci", "pipelines"] => CiRoute::ListPipelines, - ["api", "ci", "pipelines", id] => { - if let Ok(id) = id.parse() { - CiRoute::PipelineDetail { id } - } else { - CiRoute::NotFound - } - } - ["api", "ci", "pipelines", id, "jobs", job_name, "log"] => { - if let Ok(pipeline_id) = id.parse() { - CiRoute::JobLog { - pipeline_id, - job_name: job_name.to_string(), - } - } else { - CiRoute::NotFound - } - } - ["api", "ci", "artifacts", job_id, rest @ ..] if !rest.is_empty() => CiRoute::Artifact { - job_id: job_id.to_string(), - path: rest.join("/"), - }, - ["api", "ci", "status"] => CiRoute::SystemStatus, - _ => CiRoute::NotFound, - } -} - -/// Render a CiSnapshot into a JSON response for the given route. -pub fn handle_route(route: &CiRoute, snapshot: &CiSnapshot) -> Option { - match route { - CiRoute::ListPipelines => { - let resp = PipelineListResponse { - pipelines: snapshot - .active_pipelines - .iter() - .chain(snapshot.recent_pipelines.iter()) - .cloned() - .collect(), - }; - serde_json::to_string(&resp).ok() - } - CiRoute::PipelineDetail { id } => { - let pipeline = snapshot - .active_pipelines - .iter() - .chain(snapshot.recent_pipelines.iter()) - .find(|p| p.pipeline_id.0 == *id)?; - let resp = PipelineDetailResponse { - pipeline: pipeline.clone(), - }; - serde_json::to_string(&resp).ok() - } - CiRoute::SystemStatus => { - let resp = SystemStatusResponse { - provisioner_status: snapshot.provisioner_status.clone(), - active_pipelines: snapshot.active_pipelines.len(), - active_instances: snapshot.active_instances, - }; - serde_json::to_string(&resp).ok() - } - CiRoute::JobLog { .. } | CiRoute::Artifact { .. } => { - // These require access to stored data beyond the snapshot. - None - } - CiRoute::NotFound => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn route_parsing() { - assert_eq!(parse_route("/api/ci/pipelines"), CiRoute::ListPipelines); - assert_eq!( - parse_route("/api/ci/pipelines/42"), - CiRoute::PipelineDetail { id: 42 } - ); - assert_eq!( - parse_route("/api/ci/pipelines/1/jobs/test/log"), - CiRoute::JobLog { - pipeline_id: 1, - job_name: "test".into() - } - ); - assert_eq!( - parse_route("/api/ci/artifacts/job-1/target/release/bin"), - CiRoute::Artifact { - job_id: "job-1".into(), - path: "target/release/bin".into() - } - ); - assert_eq!(parse_route("/api/ci/status"), CiRoute::SystemStatus); - assert_eq!(parse_route("/api/ci/unknown"), CiRoute::NotFound); - } - - #[test] - fn handle_system_status() { - let snapshot = CiSnapshot { - active_pipelines: vec![], - recent_pipelines: vec![], - provisioner_status: ProvisionerStatus::Online, - active_instances: 2, - }; - - let route = CiRoute::SystemStatus; - let json = handle_route(&route, &snapshot).unwrap(); - let resp: SystemStatusResponse = serde_json::from_str(&json).unwrap(); - assert_eq!(resp.provisioner_status, ProvisionerStatus::Online); - assert_eq!(resp.active_instances, 2); - } -} diff --git a/crates/dashboard/src/collector.rs b/crates/dashboard/src/collector.rs index ecd45b7..d47750c 100644 --- a/crates/dashboard/src/collector.rs +++ b/crates/dashboard/src/collector.rs @@ -55,7 +55,7 @@ pub fn enrich_names(stats: &mut RuntimeStats, runtime: &Runtime) { Some(ext) => ext, None => return, }; - let std_ext = match ext.as_any().downcast_ref::() { + let std_ext = match ext.as_any().downcast_ref::() { Some(ext) => ext, None => return, }; diff --git a/crates/dashboard/src/command/builtins.rs b/crates/dashboard/src/command/builtins.rs index 30f09c5..c7ba5c5 100644 --- a/crates/dashboard/src/command/builtins.rs +++ b/crates/dashboard/src/command/builtins.rs @@ -1,6 +1,6 @@ //! Built-in command handlers for runtime inspection and management. //! -//! Extracted from `crates/runtime-dashboard/src/investigate.rs`. +//! Extracted from `crates/dashboard/src/investigate.rs`. use std::collections::HashMap; use std::time::{Duration, Instant}; @@ -435,11 +435,10 @@ impl CommandHandler for PhasesCommand { let mut results = Vec::new(); for (i, timings) in stats.tick_timings.iter().enumerate() { - if let Some(wid) = worker_filter { - if i != wid { + if let Some(wid) = worker_filter + && i != wid { continue; } - } let breakdown = compute_phase_breakdown(timings); results.push(serde_json::json!({ "worker_id": i, diff --git a/crates/dashboard/src/command/mod.rs b/crates/dashboard/src/command/mod.rs index 95fa287..ad1e2b3 100644 --- a/crates/dashboard/src/command/mod.rs +++ b/crates/dashboard/src/command/mod.rs @@ -153,6 +153,12 @@ pub struct CommandRouter { handlers: HashMap>, } +impl Default for CommandRouter { + fn default() -> Self { + Self::new() + } +} + impl CommandRouter { pub fn new() -> Self { Self { diff --git a/crates/dashboard/src/dashboard_html.rs b/crates/dashboard/src/dashboard_html.rs index 0aa4f7f..f777468 100644 --- a/crates/dashboard/src/dashboard_html.rs +++ b/crates/dashboard/src/dashboard_html.rs @@ -152,8 +152,8 @@ pub const DASHBOARD_HTML: &str = r##"
diff --git a/crates/dashboard/src/datastore_collector.rs b/crates/dashboard/src/datastore_collector.rs deleted file mode 100644 index b2e38b3..0000000 --- a/crates/dashboard/src/datastore_collector.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Datastore stats provider for the runtime dashboard. -//! -//! The trait returns a pre-serialized JSON string so that `runtime-dashboard` -//! has no compile-time dependency on `swactor-datastore` (which would create a -//! circular dependency since `swactor-datastore[node]` depends on us). -//! -//! The `swactor-datastore` crate implements this trait in its `node` feature. - -use std::sync::Arc; - -/// Scope filter for listing objects. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ListScope { - Local, - Swarm, -} - -/// Trait for providing datastore stats and CRUD operations to the dashboard. -/// -/// Implementations capture a point-in-time snapshot as serialized JSON. -/// The dashboard polls this every ~200ms via SSE. -/// -/// All command methods have default implementations returning `Err` so that -/// existing `DatastoreMetrics` impls continue to compile without changes. -pub trait DatastoreStatsProvider: Send + Sync { - /// Return a JSON-serialized datastore snapshot, or `None` if unavailable. - fn snapshot_json(&self) -> Option; - - /// List objects as a JSON string. `scope` selects local-only or swarm-wide. - fn list_objects(&self, _name_filter: Option<&str>, _scope: ListScope) -> Result { - Err("not supported".into()) - } - - /// Get a single object's metadata + manifest as JSON. - fn get_object(&self, _hash: &str) -> Result { - Err("not supported".into()) - } - - /// Get the raw binary data for an object. - fn get_data(&self, _hash: &str) -> Result, String> { - Err("not supported".into()) - } - - /// Store data, optionally with a name. Returns JSON with `content_hash`. - fn put_data(&self, _data: Vec, _name: Option) -> Result { - Err("not supported".into()) - } - - /// Delete an object by hash. Returns JSON confirmation. - fn delete_object(&self, _hash: &str) -> Result { - Err("not supported".into()) - } - - /// Get node status as JSON. - fn node_status(&self) -> Result { - Err("not supported".into()) - } - - /// Whether the datastore is currently running. - fn is_running(&self) -> bool { - false - } - - /// Shut down the datastore actors. - fn shutdown_datastore(&self) -> Result<(), String> { - Err("not supported".into()) - } -} - -/// Factory for creating a new datastore instance from the dashboard. -pub trait DatastoreFactory: Send + Sync { - /// Start a datastore with optional persistent storage path. - /// Returns a provider that can be installed into the dashboard. - fn start_datastore(&self, storage_path: Option) -> Result, String>; -} diff --git a/crates/dashboard/src/distribution_collector.rs b/crates/dashboard/src/distribution_collector.rs deleted file mode 100644 index 0ae3c85..0000000 --- a/crates/dashboard/src/distribution_collector.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Distribution stats provider for the runtime dashboard. -//! -//! The application implements `DistributionStatsProvider` to let the dashboard -//! read a single node's distribution state (SWIM membership, Kademlia routing, -//! LRU cache, etc.) without reaching out to other nodes. - -use std::sync::{Arc, Mutex}; - -use distribution::snapshot::DistributionNodeSnapshot; - -/// Trait for providing distribution stats to the dashboard. -/// -/// Implementations capture a point-in-time snapshot of the local -/// `DistributedNode`'s state. The dashboard polls this every ~200ms. -pub trait DistributionStatsProvider: Send + Sync { - fn snapshot(&self) -> Option; -} - -/// Simple implementation wrapping an `Arc>` where T implements -/// a `snapshot()` method (e.g. `DistributedNode`). -pub struct DistributionCollector { - inner: Arc>, -} - -impl DistributionCollector { - pub fn new(inner: Arc>) -> Self { - Self { inner } - } -} - -impl DistributionStatsProvider for DistributionCollector { - fn snapshot(&self) -> Option { - self.inner.lock().ok().map(|node| node.snapshot()) - } -} diff --git a/crates/dashboard/src/layer.rs b/crates/dashboard/src/layer.rs index df4e179..aed021c 100644 --- a/crates/dashboard/src/layer.rs +++ b/crates/dashboard/src/layer.rs @@ -212,16 +212,14 @@ where if let Some(scope) = ctx.event_scope(event) { for span in scope { let exts = span.extensions(); - if worker_id.is_none() { - if let Some(wid) = exts.get::() { + if worker_id.is_none() + && let Some(wid) = exts.get::() { worker_id = Some(wid.0); } - } - if actor_addr.is_none() { - if let Some(aa) = exts.get::() { + if actor_addr.is_none() + && let Some(aa) = exts.get::() { actor_addr = Some(aa.0.clone()); } - } if worker_id.is_some() && actor_addr.is_some() { break; } @@ -229,16 +227,14 @@ where } // Also check if worker_id or actor_addr was a field on the event itself - if worker_id.is_none() { - if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") { + if worker_id.is_none() + && let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") { worker_id = n.as_u64().map(|v| v as usize); } - } - if actor_addr.is_none() { - if let Some(serde_json::Value::String(s)) = visitor.fields.get("actor_addr") { + if actor_addr.is_none() + && let Some(serde_json::Value::String(s)) = visitor.fields.get("actor_addr") { actor_addr = Some(s.clone()); } - } let dashboard_event = DashboardEvent { seq: 0, // filled by push() @@ -259,11 +255,10 @@ where attrs.record(&mut visitor); if let Some(span) = ctx.span(id) { - if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") { - if let Some(wid) = n.as_u64() { + if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") + && let Some(wid) = n.as_u64() { span.extensions_mut().insert(WorkerIdField(wid as usize)); } - } if let Some(serde_json::Value::String(s)) = visitor.fields.get("actor_addr") { span.extensions_mut().insert(ActorAddrField(s.clone())); } diff --git a/crates/dashboard/src/lib.rs b/crates/dashboard/src/lib.rs index 9c3ce92..4deb18e 100644 --- a/crates/dashboard/src/lib.rs +++ b/crates/dashboard/src/lib.rs @@ -3,6 +3,7 @@ pub mod command; pub mod history; pub mod investigate; pub mod layer; +pub mod plugin; pub mod trace; pub mod warnings; mod actor_detail_html; @@ -15,16 +16,6 @@ mod topology_html; #[cfg(feature = "tui")] pub mod tui; -#[cfg(feature = "distribution")] -mod distribution_html; -#[cfg(feature = "distribution")] -pub mod distribution_collector; - -mod datastore_html; -pub mod datastore_collector; - -pub mod ci_collector; - use std::io; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -40,6 +31,7 @@ use tracing_subscriber::util::SubscriberInitExt; use crate::collector::StatsCollector; use crate::history::{DashboardHistory, HistoryConfig}; use crate::layer::{now_ms, DashboardLayer, EventStore}; +use crate::plugin::PluginRegistry; use crate::trace::{RuntimeTrace, TimestampedStats}; /// Peer info sent through the join channel: (public_key, optional_relay_url). @@ -99,13 +91,7 @@ pub struct DashboardHandle { history: Arc, recording: bool, port: u16, - #[cfg(feature = "distribution")] - distribution: Arc>>>, - datastore: Arc>>>, - datastore_factory: Arc>>>, - ci: Arc>>>, - peer_auth: Arc>>>>, - join_sender: Arc>>>, + plugin_registry: Arc, standalone_rt: Mutex>, } @@ -133,40 +119,9 @@ impl DashboardHandle { self.recording } - /// Attach a distribution stats provider, enabling the `/distribution` page. - #[cfg(feature = "distribution")] - pub fn set_distribution(&self, provider: Arc) { - *self.distribution.lock().unwrap() = Some(provider); - } - - /// Attach a datastore stats provider, enabling the `/datastore` page. - pub fn set_datastore(&self, provider: Arc) { - *self.datastore.lock().unwrap() = Some(provider); - } - - /// Attach a datastore factory, enabling start/stop from the dashboard. - pub fn set_datastore_factory(&self, factory: Arc) { - *self.datastore_factory.lock().unwrap() = Some(factory); - } - - /// Get the shared datastore provider mutex (for external wiring). - pub fn datastore_provider(&self) -> &Arc>>> { - &self.datastore - } - - /// Attach a CI stats provider, enabling the `/api/ci/*` endpoints. - pub fn set_ci(&self, provider: Arc) { - *self.ci.lock().unwrap() = Some(provider); - } - - /// Attach a peer allow-list for the peer management API. - pub fn set_peer_auth(&self, auth: Arc>) { - *self.peer_auth.lock().unwrap() = Some(auth); - } - - /// Set a sender that triggers `driver.join()` when a peer is added via the dashboard. - pub fn set_join_sender(&self, tx: std::sync::mpsc::Sender) { - *self.join_sender.lock().unwrap() = Some(tx); + /// Register a plugin with the dashboard. + pub fn register_plugin(&self, plugin: Arc) { + self.plugin_registry.register(plugin); } /// Access the time-series history store (for TUI sparklines, etc.). @@ -212,13 +167,7 @@ impl DashboardHandle { shutdown_notify: Arc::clone(&self.shutdown_notify), history: Arc::clone(&self.history), cmd_router: Arc::new(crate::command::CommandRouter::with_builtins()), - #[cfg(feature = "distribution")] - distribution: Arc::clone(&self.distribution), - datastore: Arc::clone(&self.datastore), - datastore_factory: Arc::clone(&self.datastore_factory), - ci: Arc::clone(&self.ci), - peer_auth: Arc::clone(&self.peer_auth), - join_sender: Arc::clone(&self.join_sender), + plugins: Arc::clone(&self.plugin_registry), } } @@ -228,8 +177,7 @@ impl DashboardHandle { /// This drains the recording buffers — each call consumes the buffered data. pub fn save_trace(&self, path: &str) -> io::Result<()> { let events = self.store.all_events().ok_or_else(|| { - io::Error::new( - io::ErrorKind::Other, + io::Error::other( "recording not enabled (set DashboardConfig::record = true)", ) })?; @@ -242,7 +190,7 @@ impl DashboardHandle { stats_timeline, }; let json = serde_json::to_string(&trace) - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + .map_err(|e| io::Error::other(e))?; std::fs::write(path, json) } } @@ -266,25 +214,6 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle { let stats_timeline = Arc::new(ArrayQueue::new(config.record_stats_capacity.max(1))); let history = Arc::new(DashboardHistory::new(HistoryConfig::default())); - #[cfg(feature = "distribution")] - let distribution: Arc>>> = - Arc::new(Mutex::new(None)); - - let datastore: Arc>>> = - Arc::new(Mutex::new(None)); - - let datastore_factory: Arc>>> = - Arc::new(Mutex::new(None)); - - let ci: Arc>>> = - Arc::new(Mutex::new(None)); - - let peer_auth: Arc>>>> = - Arc::new(Mutex::new(None)); - - let join_sender: Arc>>> = - Arc::new(Mutex::new(None)); - // Start stats recorder thread when recording is enabled if config.record { let rt_ref = Arc::clone(&runtime); @@ -314,6 +243,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle { } let port = config.port; + let plugin_registry = Arc::new(PluginRegistry::new()); DashboardHandle { store, @@ -325,13 +255,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle { history, recording: config.record, port, - #[cfg(feature = "distribution")] - distribution, - datastore, - datastore_factory, - ci, - peer_auth, - join_sender, + plugin_registry, standalone_rt: Mutex::new(None), } } @@ -368,7 +292,7 @@ pub fn serve_replay(path: &str, config: ReplayConfig) -> io::Result<()> { .worker_threads(1) .enable_all() .build() - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + .map_err(|e| io::Error::other(e))?; let state = server::ReplayState { trace: Arc::new(trace), diff --git a/crates/dashboard/src/plugin.rs b/crates/dashboard/src/plugin.rs new file mode 100644 index 0000000..3a2cb31 --- /dev/null +++ b/crates/dashboard/src/plugin.rs @@ -0,0 +1,87 @@ +//! Dashboard plugin system. +//! +//! Plugins provide subsystem-specific metrics, API endpoints, and UI pages +//! to the dashboard without the dashboard knowing about the subsystem. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +/// Response from a plugin's request handler. +pub enum PluginResponse { + Json(String), + Binary { content_type: String, data: Vec }, + Error { status: u16, message: String }, + NotFound, +} + +impl PluginResponse { + pub fn not_found() -> Self { + Self::NotFound + } + + pub fn json(s: String) -> Self { + Self::Json(s) + } + + pub fn error(status: u16, msg: impl Into) -> Self { + Self::Error { + status, + message: msg.into(), + } + } +} + +/// A composable dashboard plugin. +/// +/// Plugins provide subsystem-specific metrics, API endpoints, and UI pages +/// to the dashboard without the dashboard knowing about the subsystem. +pub trait DashboardPlugin: Send + Sync { + /// Unique name — used as SSE event type and API route prefix `/api/plugin/{name}/...` + fn name(&self) -> &str; + + /// JSON snapshot polled every ~200ms via SSE. Return None if no data available. + fn snapshot_json(&self) -> Option; + + /// Handle an API request to `/api/plugin/{name}/{path}`. + fn handle_request( + &self, + _method: &str, + _path: &str, + _query: &HashMap, + _body: &[u8], + ) -> PluginResponse { + PluginResponse::not_found() + } + + /// Optional HTML page content. Dashboard will serve at `/plugin/{name}`. + fn html_page(&self) -> Option<&str> { + None + } +} + +/// Thread-safe registry of plugins. +pub struct PluginRegistry { + plugins: Mutex>>, +} + +impl Default for PluginRegistry { + fn default() -> Self { + Self::new() + } +} + +impl PluginRegistry { + pub fn new() -> Self { + Self { + plugins: Mutex::new(Vec::new()), + } + } + + pub fn register(&self, plugin: Arc) { + self.plugins.lock().unwrap().push(plugin); + } + + pub fn snapshot(&self) -> Vec> { + self.plugins.lock().unwrap().clone() + } +} diff --git a/crates/dashboard/src/pool_html.rs b/crates/dashboard/src/pool_html.rs deleted file mode 100644 index 71c7a53..0000000 --- a/crates/dashboard/src/pool_html.rs +++ /dev/null @@ -1,392 +0,0 @@ -pub const POOL_HTML: &str = r##" - - - - -Swactor Runtime – Pool - - - -
-
-

- Swactor Runtime Dashboard - -

- -
-
- - -
-
- - - - - -
- - - - -"##; diff --git a/crates/dashboard/src/server.rs b/crates/dashboard/src/server.rs index b1a83c3..79588c5 100644 --- a/crates/dashboard/src/server.rs +++ b/crates/dashboard/src/server.rs @@ -9,7 +9,7 @@ use axum::extract::{Path, Query, State}; use axum::http::{header, StatusCode}; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::{IntoResponse, Response}; -use axum::routing::{get, post}; +use axum::routing::get; use axum::Router; use tokio_stream::wrappers::ReceiverStream; use tokio_stream::StreamExt; @@ -21,20 +21,13 @@ use crate::actors_html::ACTORS_HTML; use crate::collector::StatsCollector; use crate::command::CommandRouter; use crate::dashboard_html::DASHBOARD_HTML; -use crate::datastore_collector::{DatastoreFactory, DatastoreStatsProvider, ListScope}; -use crate::datastore_html::DATASTORE_HTML; use crate::history::DashboardHistory; use crate::layer::EventStore; use crate::topology; use crate::topology_html::TOPOLOGY_HTML; use crate::warnings::{WarningConfig, WarningDetector}; -#[cfg(feature = "distribution")] -use crate::distribution_collector::DistributionStatsProvider; -#[cfg(feature = "distribution")] -use crate::distribution_html::DISTRIBUTION_HTML; - -use crate::ci_collector::CiStatsProvider; +use crate::plugin::PluginRegistry; use crate::trace::RuntimeTrace; @@ -54,13 +47,7 @@ pub(crate) struct AppState { pub shutdown_notify: Arc, pub history: Arc, pub cmd_router: Arc, - #[cfg(feature = "distribution")] - pub distribution: Arc>>>, - pub datastore: Arc>>>, - pub datastore_factory: Arc>>>, - pub ci: Arc>>>, - pub peer_auth: Arc>>>>, - pub join_sender: Arc>>>, + pub plugins: Arc, } // ── Router builders ───────────────────────────────────────────────────── @@ -70,34 +57,16 @@ pub(crate) fn build_live_router(state: AppState) -> Router { .route("/", get(page_dashboard)) .route("/actors", get(page_actors)) .route("/topology", get(page_topology)) - .route("/datastore", get(page_datastore)) .route("/events", get(handle_live_sse)) .route("/api/stats", get(handle_stats_api)) .route("/api/history", get(handle_history_api)) .route("/api/topology", get(handle_topology_api)) .route("/api/investigate", get(handle_investigate_api)) - .route("/api/datastore", get(handle_datastore_api)) .route("/api/logs", get(handle_logs_api)) - .route("/api/datastore/list", get(handle_ds_list)) - .route("/api/datastore/get", get(handle_ds_get)) - .route("/api/datastore/data", get(handle_ds_data)) - .route("/api/datastore/status", get(handle_ds_status)) - .route("/api/datastore/put", post(handle_ds_put)) - .route("/api/datastore/delete", post(handle_ds_delete)) - .route("/api/datastore/start", post(handle_ds_start)) - .route("/api/datastore/shutdown", post(handle_ds_shutdown)) - .route("/api/peers", get(handle_peers_list)) - .route("/api/peers/add", post(handle_peers_add)) - .route("/api/peers/sync", post(handle_peers_sync)) - .route("/api/peers/remove", post(handle_peers_remove)) - .route("/actor/{hex}", get(handle_actor_detail)); - - #[cfg(feature = "distribution")] - let router = router - .route("/distribution", get(page_distribution)) - .route("/api/distribution", get(handle_distribution_api)); - - let router = router.route("/api/ci/{*rest}", get(handle_ci_api)); + .route("/actor/{hex}", get(handle_actor_detail)) + // Plugin routes + .route("/api/plugin/{name}/{*rest}", get(handle_plugin_get).post(handle_plugin_post)) + .route("/plugin/{name}", get(handle_plugin_page)); router.with_state(state) } @@ -151,15 +120,6 @@ async fn page_topology() -> Response { html_response(TOPOLOGY_HTML, "live") } -async fn page_datastore() -> Response { - html_response(DATASTORE_HTML, "live") -} - -#[cfg(feature = "distribution")] -async fn page_distribution() -> Response { - html_response(DISTRIBUTION_HTML, "live") -} - async fn handle_actor_detail(Path(hex_addr): Path) -> Response { let html = ACTOR_DETAIL_HTML .replace("__DASHBOARD_MODE__", "live") @@ -201,13 +161,11 @@ async fn handle_live_sse( // Run warning detection let warnings = warning_detector.check(&stats); - if !warnings.is_empty() { - if let Ok(wjson) = serde_json::to_string(&warnings) { - if tx.send(format_sse("warnings", &wjson)).await.is_err() { + if !warnings.is_empty() + && let Ok(wjson) = serde_json::to_string(&warnings) + && tx.send(format_sse("warnings", &wjson)).await.is_err() { return; } - } - } let json = serde_json::to_string(&stats).unwrap(); if tx.send(format_sse("stats", &json)).await.is_err() { @@ -216,67 +174,22 @@ async fn handle_live_sse( // Send topology every 5th tick (~1/sec) tick_count += 1; - if tick_count % 5 == 0 { + if tick_count.is_multiple_of(5) { let topo = topology::worker_topology(&stats); - if let Ok(tjson) = serde_json::to_string(&topo) { - if tx.send(format_sse("topology", &tjson)).await.is_err() { + if let Ok(tjson) = serde_json::to_string(&topo) + && tx.send(format_sse("topology", &tjson)).await.is_err() { return; } - } } } } - // Send distribution snapshot if provider is attached - #[cfg(feature = "distribution")] - { - let maybe_dist = state.distribution.lock().unwrap().clone(); - if let Some(provider) = maybe_dist { - if let Some(snapshot) = provider.snapshot() { - if let Ok(json) = serde_json::to_string(&snapshot) { - if tx.send(format_sse("distribution", &json)).await.is_err() { - return; - } - } + // Poll all registered plugins + for plugin in state.plugins.snapshot() { + if let Some(json) = plugin.snapshot_json() + && tx.send(format_sse(plugin.name(), &json)).await.is_err() { + return; } - } - } - - // Send datastore snapshot if provider is attached - { - let maybe_ds = state.datastore.lock().unwrap().clone(); - match maybe_ds { - Some(provider) => { - let is_running = provider.is_running(); - let snap_json = provider.snapshot_json().unwrap_or_else(|| "null".into()); - let envelope = format!( - r#"{{"is_running":{},"snapshot":{}}}"#, - is_running, snap_json - ); - if tx.send(format_sse("datastore", &envelope)).await.is_err() { - return; - } - } - None => { - let envelope = r#"{"is_running":false,"snapshot":null}"#; - if tx.send(format_sse("datastore", envelope)).await.is_err() { - return; - } - } - } - } - - // Send CI snapshot if provider is attached - { - let maybe_ci = state.ci.lock().unwrap().clone(); - if let Some(provider) = maybe_ci { - let snapshot = provider.snapshot(); - if let Ok(json) = serde_json::to_string(&snapshot) { - if tx.send(format_sse("ci", &json)).await.is_err() { - return; - } - } - } } // Send new activity events @@ -361,54 +274,6 @@ async fn handle_investigate_api( json_response(json) } -#[cfg(feature = "distribution")] -async fn handle_distribution_api(State(state): State) -> Response { - let json = match state.distribution.lock().unwrap().as_ref() { - Some(provider) => match provider.snapshot() { - Some(snapshot) => serde_json::to_string(&snapshot).unwrap_or_else(|_| "{}".into()), - None => "{}".to_string(), - }, - None => serde_json::json!({ - "error": "distribution provider not attached" - }) - .to_string(), - }; - json_response(json) -} - -async fn handle_datastore_api(State(state): State) -> Response { - let json = match state.datastore.lock().unwrap().as_ref() { - Some(provider) => provider.snapshot_json().unwrap_or_else(|| "{}".into()), - None => serde_json::json!({ - "error": "datastore provider not attached" - }) - .to_string(), - }; - json_response(json) -} - -async fn handle_ci_api( - State(state): State, - axum::extract::OriginalUri(uri): axum::extract::OriginalUri, -) -> Response { - use crate::ci_collector; - - let path = uri.path(); - let route = ci_collector::parse_route(path); - let json = match state.ci.lock().unwrap().as_ref() { - Some(provider) => { - let snapshot = provider.snapshot(); - ci_collector::handle_route(&route, &snapshot) - .unwrap_or_else(|| r#"{"error":"not found"}"#.to_string()) - } - None => serde_json::json!({ - "error": "CI provider not attached" - }) - .to_string(), - }; - json_response(json) -} - async fn handle_topology_api(State(state): State) -> Response { let maybe_rt = state.runtime.lock().unwrap().clone(); let json = match maybe_rt { @@ -453,397 +318,77 @@ async fn handle_history_api(State(state): State) -> Response { json_response(json) } -// ── Datastore CRUD API handlers ───────────────────────────────────────── +// ── Plugin handlers ───────────────────────────────────────────────────── -fn get_ds_provider( - datastore: &Arc>>>, -) -> Option> { - datastore.lock().unwrap().clone() -} - -async fn handle_ds_list( +async fn handle_plugin_get( State(state): State, + Path((name, rest)): Path<(String, String)>, Query(params): Query>, ) -> Response { - let provider = match get_ds_provider(&state.datastore) { + let plugins = state.plugins.snapshot(); + let plugin = match plugins.iter().find(|p| p.name() == name) { Some(p) => p, - None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"), + None => return json_error(StatusCode::NOT_FOUND, &format!("plugin '{name}' not found")), }; - let scope = match params.get("scope").map(|s| s.as_str()) { - Some("local") => ListScope::Local, - _ => ListScope::Swarm, - }; - let name_filter = params.get("name").map(|s| s.as_str()); - match provider.list_objects(name_filter, scope) { - Ok(json) => json_response(json), - Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e), - } -} - -async fn handle_ds_get( - State(state): State, - Query(params): Query>, -) -> Response { - let provider = match get_ds_provider(&state.datastore) { - Some(p) => p, - None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"), - }; - let hash = match params.get("hash") { - Some(h) => h.as_str(), - None => return json_error(StatusCode::BAD_REQUEST, "missing ?hash= parameter"), - }; - match provider.get_object(hash) { - Ok(json) => json_response(json), - Err(e) if e.contains("not found") => json_error(StatusCode::NOT_FOUND, &e), - Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e), - } -} - -async fn handle_ds_data( - State(state): State, - Query(params): Query>, -) -> Response { - let provider = match get_ds_provider(&state.datastore) { - Some(p) => p, - None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"), - }; - let hash = match params.get("hash") { - Some(h) => h.as_str(), - None => return json_error(StatusCode::BAD_REQUEST, "missing ?hash= parameter"), - }; - match provider.get_data(hash) { - Ok(data) => { - ([(header::CONTENT_TYPE, "application/octet-stream")], data).into_response() + match plugin.handle_request("GET", &rest, ¶ms, &[]) { + crate::plugin::PluginResponse::Json(json) => json_response(json), + crate::plugin::PluginResponse::Binary { content_type, data } => { + ([(header::CONTENT_TYPE, content_type)], data).into_response() + } + crate::plugin::PluginResponse::Error { status, message } => { + let code = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + json_error(code, &message) + } + crate::plugin::PluginResponse::NotFound => { + json_error(StatusCode::NOT_FOUND, "not found") } - Err(e) if e.contains("not found") => json_error(StatusCode::NOT_FOUND, &e), - Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e), } } -async fn handle_ds_status(State(state): State) -> Response { - let provider = match get_ds_provider(&state.datastore) { - Some(p) => p, - None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"), - }; - match provider.node_status() { - Ok(json) => json_response(json), - Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e), - } -} - -async fn handle_ds_put( +async fn handle_plugin_post( State(state): State, + Path((name, rest)): Path<(String, String)>, Query(params): Query>, body: Bytes, ) -> Response { - let provider = match get_ds_provider(&state.datastore) { + let plugins = state.plugins.snapshot(); + let plugin = match plugins.iter().find(|p| p.name() == name) { Some(p) => p, - None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"), + None => return json_error(StatusCode::NOT_FOUND, &format!("plugin '{name}' not found")), }; - let name = params.get("name").cloned(); - match provider.put_data(body.to_vec(), name) { - Ok(json) => json_response(json), - Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e), + match plugin.handle_request("POST", &rest, ¶ms, &body) { + crate::plugin::PluginResponse::Json(json) => json_response(json), + crate::plugin::PluginResponse::Binary { content_type, data } => { + ([(header::CONTENT_TYPE, content_type)], data).into_response() + } + crate::plugin::PluginResponse::Error { status, message } => { + let code = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + json_error(code, &message) + } + crate::plugin::PluginResponse::NotFound => { + json_error(StatusCode::NOT_FOUND, "not found") + } } } -async fn handle_ds_delete( +async fn handle_plugin_page( State(state): State, - Query(params): Query>, + Path(name): Path, ) -> Response { - let provider = match get_ds_provider(&state.datastore) { + let plugins = state.plugins.snapshot(); + let plugin = match plugins.iter().find(|p| p.name() == name) { Some(p) => p, - None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"), - }; - let hash = match params.get("hash") { - Some(h) => h.as_str(), - None => return json_error(StatusCode::BAD_REQUEST, "missing ?hash= parameter"), - }; - match provider.delete_object(hash) { - Ok(json) => json_response(json), - Err(e) if e.contains("not found") => json_error(StatusCode::NOT_FOUND, &e), - Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e), - } -} - -async fn handle_ds_start( - State(state): State, - Query(params): Query>, -) -> Response { - // Check if already running - { - let ds = state.datastore.lock().unwrap(); - if ds.is_some() { - return json_error(StatusCode::CONFLICT, "datastore already running"); - } - } - - let fac = match state.datastore_factory.lock().unwrap().clone() { - Some(f) => f, - None => return json_error(StatusCode::NOT_IMPLEMENTED, "no datastore factory configured"), - }; - - let storage_path = params.get("storage_path").cloned(); - - match fac.start_datastore(storage_path) { - Ok(provider) => { - *state.datastore.lock().unwrap() = Some(provider); - json_response(r#"{"ok":true}"#.to_string()) - } - Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e), - } -} - -async fn handle_ds_shutdown(State(state): State) -> Response { - let provider = match get_ds_provider(&state.datastore) { - Some(p) => p, - None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"), - }; - - match provider.shutdown_datastore() { - Ok(()) => { - *state.datastore.lock().unwrap() = None; - json_response(r#"{"ok":true}"#.to_string()) - } - Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e), - } -} - -// ── Peer Management API ───────────────────────────────────────────────── - -async fn handle_peers_list(State(state): State) -> Response { - let maybe_auth = state.peer_auth.lock().unwrap().clone(); - match maybe_auth { - Some(auth) => { - let list = auth.lock().unwrap(); - let is_open = list.is_open(); - let peers: Vec = list - .list_peers() - .iter() - .map(|p| { - serde_json::json!({ - "node_id": p.node_id, - "label": p.label, - }) - }) - .collect(); - let json = serde_json::json!({ - "mode": if is_open { "open" } else { "allow-list" }, - "peers": peers, - }) - .to_string(); - json_response(json) - } None => { - let json = serde_json::json!({ - "mode": "open", - "peers": [], - }) - .to_string(); - json_response(json) - } - } -} - -async fn handle_peers_add(State(state): State, body: String) -> Response { - let maybe_auth = state.peer_auth.lock().unwrap().clone(); - let auth = match maybe_auth { - Some(a) => a, - None => return json_error(StatusCode::BAD_REQUEST, "peer auth not configured"), - }; - - let parsed: serde_json::Value = match serde_json::from_str(&body) { - Ok(v) => v, - Err(e) => return json_error(StatusCode::BAD_REQUEST, &format!("invalid JSON: {e}")), - }; - - let node_id_str = match parsed.get("node_id").and_then(|v| v.as_str()) { - Some(s) => s, - None => return json_error(StatusCode::BAD_REQUEST, "missing node_id field"), - }; - let label = parsed - .get("label") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - // Accept hex (64 chars) or base58 (~44 chars) - let bytes: [u8; 32] = if let Some(b) = distribution::identity::hex_decode(node_id_str) { - match b.try_into() { - Ok(arr) => arr, - Err(_) => { - return json_error( - StatusCode::BAD_REQUEST, - "invalid node_id (hex decoded to wrong length)", - ); - } - } - } else if let Some(arr) = distribution::identity::base58_decode(node_id_str) { - arr - } else { - return json_error( - StatusCode::BAD_REQUEST, - "invalid node_id (expected 64-char hex or base58)", - ); - }; - - let relay_url = parsed - .get("relay_url") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let node_id = distribution::types::NodeId(bytes); - let mut list = auth.lock().unwrap(); - list.add_peer(node_id, label); - if let Err(e) = list.save() { - eprintln!("warning: failed to persist peers.json: {e}"); - } - drop(list); - - // Trigger a SWIM join for the newly added peer - if let Some(tx) = state.join_sender.lock().unwrap().as_ref() { - let _ = tx.send((bytes, relay_url)); - } - - json_response(r#"{"ok":true}"#.to_string()) -} - -async fn handle_peers_sync(State(state): State, body: String) -> Response { - let maybe_auth = state.peer_auth.lock().unwrap().clone(); - let auth = match maybe_auth { - Some(a) => a, - None => return json_error(StatusCode::BAD_REQUEST, "peer auth not configured"), - }; - - let parsed: serde_json::Value = match serde_json::from_str(&body) { - Ok(v) => v, - Err(e) => return json_error(StatusCode::BAD_REQUEST, &format!("invalid JSON: {e}")), - }; - - let peers = match parsed.get("peers").and_then(|v| v.as_array()) { - Some(arr) => arr, - None => return json_error(StatusCode::BAD_REQUEST, "missing peers array"), - }; - - // Parse all peers first, bail on any error - let mut parsed_peers: Vec<(distribution::types::NodeId, String)> = Vec::new(); - for peer in peers { - let node_id_str = match peer.get("node_id").and_then(|v| v.as_str()) { - Some(s) => s, - None => return json_error(StatusCode::BAD_REQUEST, "peer missing node_id"), - }; - let label = peer - .get("label") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - let bytes: [u8; 32] = if let Some(b) = distribution::identity::hex_decode(node_id_str) { - match b.try_into() { - Ok(arr) => arr, - Err(_) => { - return json_error( - StatusCode::BAD_REQUEST, - &format!("invalid node_id hex length for {node_id_str}"), - ); - } - } - } else if let Some(arr) = distribution::identity::base58_decode(node_id_str) { - arr - } else { - return json_error( - StatusCode::BAD_REQUEST, - &format!("invalid node_id: {node_id_str}"), - ); - }; - - parsed_peers.push((distribution::types::NodeId(bytes), label)); - } - - // Add all peers in a single lock acquisition - { - let mut list = auth.lock().unwrap(); - for (node_id, label) in &parsed_peers { - list.add_peer(*node_id, label.clone()); - } - if let Err(e) = list.save() { - eprintln!("warning: failed to persist peers.json: {e}"); - } - } - - // Trigger a SWIM join to the seed peer if specified - let join_seed = parsed.get("join_seed").and_then(|v| v.as_str()); - if let Some(seed_str) = join_seed { - let seed_bytes: Option<[u8; 32]> = - if let Some(b) = distribution::identity::hex_decode(seed_str) { - b.try_into().ok() - } else { - distribution::identity::base58_decode(seed_str) - }; - - if let Some(bytes) = seed_bytes { - // Find the relay_url for the seed from the peers array - let relay_url = peers.iter().find_map(|p| { - let nid = p.get("node_id").and_then(|v| v.as_str())?; - // Match by checking if this peer's node_id resolves to the same bytes - let peer_bytes: [u8; 32] = - if let Some(b) = distribution::identity::hex_decode(nid) { - b.try_into().ok()? - } else { - distribution::identity::base58_decode(nid)? - }; - if peer_bytes == bytes { - p.get("relay_url").and_then(|v| v.as_str()).map(|s| s.to_string()) - } else { - None - } - }); - - if let Some(tx) = state.join_sender.lock().unwrap().as_ref() { - let _ = tx.send((bytes, relay_url)); - } - } - } - - let added = parsed_peers.len(); - json_response(format!(r#"{{"ok":true,"added":{added}}}"#)) -} - -async fn handle_peers_remove(State(state): State, body: String) -> Response { - let maybe_auth = state.peer_auth.lock().unwrap().clone(); - let auth = match maybe_auth { - Some(a) => a, - None => return json_error(StatusCode::BAD_REQUEST, "peer auth not configured"), - }; - - let parsed: serde_json::Value = match serde_json::from_str(&body) { - Ok(v) => v, - Err(e) => return json_error(StatusCode::BAD_REQUEST, &format!("invalid JSON: {e}")), - }; - - let node_id_hex = match parsed.get("node_id").and_then(|v| v.as_str()) { - Some(s) => s, - None => return json_error(StatusCode::BAD_REQUEST, "missing node_id field"), - }; - - let bytes = match distribution::identity::hex_decode(node_id_hex) { - Some(b) if b.len() == 32 => b, - _ => { - return json_error( - StatusCode::BAD_REQUEST, - "invalid node_id hex (must be 64 hex chars)", - ); + return (StatusCode::NOT_FOUND, "plugin not found").into_response(); } }; - - let node_id = distribution::types::NodeId(bytes.try_into().unwrap()); - let mut list = auth.lock().unwrap(); - list.remove_peer(&node_id); - if let Err(e) = list.save() { - eprintln!("warning: failed to persist peers.json: {e}"); + match plugin.html_page() { + Some(html) => { + let rendered = html.replace("__DASHBOARD_MODE__", "live"); + ([(header::CONTENT_TYPE, "text/html; charset=utf-8")], rendered).into_response() + } + None => (StatusCode::NOT_FOUND, "no page for this plugin").into_response(), } - - json_response(r#"{"ok":true}"#.to_string()) } // ── Replay server ─────────────────────────────────────────────────────── diff --git a/crates/dashboard/src/topology_html.rs b/crates/dashboard/src/topology_html.rs index 123937b..9f5b9da 100644 --- a/crates/dashboard/src/topology_html.rs +++ b/crates/dashboard/src/topology_html.rs @@ -46,8 +46,8 @@ pub const TOPOLOGY_HTML: &str = r##" Overview Actors Topology - Distribution - Datastore + Distribution + Datastore
diff --git a/crates/dashboard/src/tui/app.rs b/crates/dashboard/src/tui/app.rs index 844345a..60ed44c 100644 --- a/crates/dashboard/src/tui/app.rs +++ b/crates/dashboard/src/tui/app.rs @@ -79,8 +79,6 @@ pub enum ViewMode { Overview, WorkerDetail, ActorDetail, - #[cfg(feature = "distribution")] - Distribution, } pub struct App { @@ -104,11 +102,6 @@ pub struct App { pub search_locked: bool, pub warnings: Vec, - #[cfg(feature = "distribution")] - pub distribution: Option, - #[cfg(feature = "distribution")] - pub dist_member_selected: usize, - prev_messages: Vec, prev_time: Instant, /// Rolling msg rates (smoothed) @@ -152,10 +145,6 @@ impl App { search_query: String::new(), search_locked: false, warnings: Vec::new(), - #[cfg(feature = "distribution")] - distribution: None, - #[cfg(feature = "distribution")] - dist_member_selected: 0, prev_messages: Vec::new(), prev_time: Instant::now(), msg_rates: Vec::new(), @@ -175,13 +164,6 @@ impl App { self.event_store = Some(store); } - #[cfg(feature = "distribution")] - pub fn update_distribution(&mut self, snapshot: distribution::snapshot::DistributionNodeSnapshot) { - let max = if snapshot.members.is_empty() { 0 } else { snapshot.members.len() - 1 }; - self.dist_member_selected = self.dist_member_selected.min(max); - self.distribution = Some(snapshot); - } - /// Get visible actor rows (filtered by search query if active). pub fn visible_actor_rows(&self) -> Vec<&ActorRow> { if self.search_query.is_empty() { @@ -451,12 +433,7 @@ impl App { self.view_mode = match self.view_mode { ViewMode::Overview => ViewMode::WorkerDetail, ViewMode::ActorDetail => ViewMode::Overview, - #[cfg(feature = "distribution")] - ViewMode::WorkerDetail => ViewMode::Distribution, - #[cfg(not(feature = "distribution"))] ViewMode::WorkerDetail => ViewMode::Overview, - #[cfg(feature = "distribution")] - ViewMode::Distribution => ViewMode::Overview, }; return; } @@ -467,8 +444,6 @@ impl App { ViewMode::Overview => self.handle_key_overview(key), ViewMode::WorkerDetail => self.handle_key_worker_detail(key), ViewMode::ActorDetail => self.handle_key_actor_detail(key), - #[cfg(feature = "distribution")] - ViewMode::Distribution => self.handle_key_distribution(key), } } @@ -560,35 +535,6 @@ impl App { } } - #[cfg(feature = "distribution")] - fn handle_key_distribution(&mut self, key: KeyEvent) { - let max = self - .distribution - .as_ref() - .map(|d| if d.members.is_empty() { 0 } else { d.members.len() - 1 }) - .unwrap_or(0); - match key.code { - KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => { - self.view_mode = ViewMode::Overview; - } - KeyCode::Up | KeyCode::Char('k') => { - self.dist_member_selected = self.dist_member_selected.saturating_sub(1); - } - KeyCode::Down | KeyCode::Char('j') => { - self.dist_member_selected = (self.dist_member_selected + 1).min(max); - } - KeyCode::PageUp => { - self.dist_member_selected = self.dist_member_selected.saturating_sub(20); - } - KeyCode::PageDown => { - self.dist_member_selected = (self.dist_member_selected + 20).min(max); - } - KeyCode::Home => { self.dist_member_selected = 0; } - KeyCode::End => { self.dist_member_selected = max; } - _ => {} - } - } - pub fn visible_table_height(&self) -> usize { // Will be set by the UI based on actual chunk size 20 diff --git a/crates/dashboard/src/tui/event.rs b/crates/dashboard/src/tui/event.rs index 37129c4..62569f5 100644 --- a/crates/dashboard/src/tui/event.rs +++ b/crates/dashboard/src/tui/event.rs @@ -14,10 +14,6 @@ pub enum AppEvent { source: RuntimeEndpoint, stats: Box, }, - #[cfg(feature = "distribution")] - DistributionUpdate { - snapshot: Box, - }, } pub struct EventLoop { diff --git a/crates/dashboard/src/tui/mod.rs b/crates/dashboard/src/tui/mod.rs index b3297d3..f6671f8 100644 --- a/crates/dashboard/src/tui/mod.rs +++ b/crates/dashboard/src/tui/mod.rs @@ -16,8 +16,6 @@ use ratatui::widgets::TableState; use swactor::runtime::Runtime; use crate::collector::StatsCollector; -#[cfg(feature = "distribution")] -use crate::distribution_collector::DistributionStatsProvider; use crate::layer::EventStore; use self::app::App; use self::event::{AppEvent, EventLoop}; @@ -45,8 +43,6 @@ impl Default for TuiConfig { pub fn start_tui( runtime: Arc, collector: Arc, - #[cfg(feature = "distribution")] - distribution: Option>, config: TuiConfig, event_store: Option>, ) -> io::Result<()> { @@ -71,8 +67,6 @@ pub fn start_tui( &mut terminal, runtime, collector, - #[cfg(feature = "distribution")] - distribution, config, event_store, ); @@ -92,8 +86,6 @@ fn run_loop( terminal: &mut Terminal>, runtime: Arc, collector: Arc, - #[cfg(feature = "distribution")] - distribution: Option>, config: TuiConfig, event_store: Option>, ) -> io::Result<()> { @@ -119,12 +111,6 @@ fn run_loop( collector.enrich(&mut stats); crate::collector::enrich_names(&mut stats, &runtime); app.update(stats); - #[cfg(feature = "distribution")] - if let Some(ref provider) = distribution { - if let Some(snapshot) = provider.snapshot() { - app.update_distribution(snapshot); - } - } } Ok(AppEvent::Key(key)) => { app.handle_key(key); @@ -132,10 +118,6 @@ fn run_loop( Ok(AppEvent::StatsUpdate { stats, .. }) => { app.update(*stats); } - #[cfg(feature = "distribution")] - Ok(AppEvent::DistributionUpdate { snapshot }) => { - app.update_distribution(*snapshot); - } Err(_) => { // Channel closed, exit break; @@ -209,10 +191,6 @@ fn run_loop_remote( Ok(AppEvent::Key(key)) => { app.handle_key(key); } - #[cfg(feature = "distribution")] - Ok(AppEvent::DistributionUpdate { snapshot }) => { - app.update_distribution(*snapshot); - } Err(_) => { break; } diff --git a/crates/dashboard/src/tui/sse_client.rs b/crates/dashboard/src/tui/sse_client.rs index 4a13a8d..003a80d 100644 --- a/crates/dashboard/src/tui/sse_client.rs +++ b/crates/dashboard/src/tui/sse_client.rs @@ -151,27 +151,6 @@ fn parse_sse_events( debug_log("received done event"); return Ok(()); } - #[cfg(feature = "distribution")] - if current_event == "distribution" && !data_buf.is_empty() { - match serde_json::from_str::(&data_buf) { - Ok(snapshot) => { - let event = AppEvent::DistributionUpdate { - snapshot: Box::new(snapshot), - }; - if tx.send(event).is_err() { - debug_log("channel closed, exiting"); - return Ok(()); - } - } - Err(e) => { - debug_log(&format!( - "distribution JSON parse error: {} data={}", - e, - &data_buf[..data_buf.len().min(200)] - )); - } - } - } current_event.clear(); data_buf.clear(); } else if let Some(event_type) = trimmed.strip_prefix("event: ") { diff --git a/crates/dashboard/src/tui/ui.rs b/crates/dashboard/src/tui/ui.rs index adfbc2a..8a5b151 100644 --- a/crates/dashboard/src/tui/ui.rs +++ b/crates/dashboard/src/tui/ui.rs @@ -12,8 +12,6 @@ pub fn draw(f: &mut Frame, app: &App, table_state: &mut TableState) { ViewMode::Overview => draw_overview(f, app, table_state), ViewMode::WorkerDetail => draw_worker_detail(f, app, table_state), ViewMode::ActorDetail => draw_actor_detail(f, app), - #[cfg(feature = "distribution")] - ViewMode::Distribution => draw_distribution(f, app, table_state), } } @@ -776,248 +774,6 @@ fn draw_actor_logs(f: &mut Frame, app: &App, area: Rect) { f.render_widget(paragraph, area); } -// ─── Distribution View ────────────────────────────────────────────────────── - -#[cfg(feature = "distribution")] -fn draw_distribution(f: &mut Frame, app: &App, table_state: &mut TableState) { - let chunks = Layout::vertical([ - Constraint::Length(3), // Summary bar - Constraint::Fill(1), // Members table - Constraint::Length(10), // Bottom panels: cache + routing - Constraint::Length(1), // Help bar - ]) - .split(f.area()); - - draw_dist_summary(f, app, chunks[0]); - draw_dist_members(f, app, table_state, chunks[1]); - - let bottom = Layout::horizontal([ - Constraint::Percentage(40), - Constraint::Percentage(60), - ]) - .split(chunks[2]); - - draw_dist_cache(f, app, bottom[0]); - draw_dist_routing(f, app, bottom[1]); - draw_dist_help(f, chunks[3]); -} - -#[cfg(feature = "distribution")] -fn draw_dist_summary(f: &mut Frame, app: &App, area: Rect) { - let (node_id, listen_addr, alive, suspect, dead, cache, dir, rt_size, rt_buckets, repair) = - match &app.distribution { - Some(d) => ( - &d.node_id[..d.node_id.len().min(16)], - d.listen_addr.as_deref().unwrap_or("—"), - d.alive_count, - d.suspect_count, - d.dead_count, - d.cache_size, - d.directory_entry_count, - d.routing_table_size, - d.routing_buckets.len(), - d.repair_queue_size, - ), - None => ("—", "—", 0, 0, 0, 0, 0, 0, 0, 0), - }; - - let lines = vec![ - Line::from(vec![ - Span::styled(" Node: ", Style::default().fg(Color::DarkGray)), - Span::styled( - node_id.to_string(), - Style::default().fg(Color::White).add_modifier(Modifier::BOLD), - ), - Span::styled(" Addr: ", Style::default().fg(Color::DarkGray)), - Span::styled(listen_addr.to_string(), Style::default().fg(Color::Cyan)), - ]), - Line::from(vec![ - Span::styled(" Members: ", Style::default().fg(Color::DarkGray)), - Span::styled(format!("{alive}"), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), - Span::styled(" alive, ", Style::default().fg(Color::DarkGray)), - Span::styled( - format!("{suspect}"), - if suspect > 0 { Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }, - ), - Span::styled(" suspect, ", Style::default().fg(Color::DarkGray)), - Span::styled( - format!("{dead}"), - if dead > 0 { Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }, - ), - Span::styled(" dead", Style::default().fg(Color::DarkGray)), - Span::styled(format!(" Cache: {cache}"), Style::default().fg(Color::DarkGray)), - Span::styled(format!(" Directory: {dir}"), Style::default().fg(Color::DarkGray)), - ]), - Line::from(vec![ - Span::styled(format!(" Routing: {rt_size} nodes, {rt_buckets} buckets"), Style::default().fg(Color::DarkGray)), - Span::styled(format!(" Repair queue: {repair}"), Style::default().fg(Color::DarkGray)), - ]), - ]; - - let block = Block::default().borders(Borders::ALL).title(" Distribution "); - let paragraph = Paragraph::new(lines).block(block); - f.render_widget(paragraph, area); -} - -#[cfg(feature = "distribution")] -fn draw_dist_members(f: &mut Frame, app: &App, table_state: &mut TableState, area: Rect) { - let header_cells = ["STATE", "NODE ID", "ADDRESS", "INCARNATION"].iter().map(|&h| { - Cell::from(h).style( - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) - }); - let header = Row::new(header_cells).height(1); - - let rows: Vec = match &app.distribution { - Some(d) => d - .members - .iter() - .map(|m| { - let state_style = match m.state.as_str() { - "alive" => Style::default().fg(Color::Green), - "suspect" => Style::default().fg(Color::Yellow), - "dead" => Style::default().fg(Color::Red), - _ => Style::default(), - }; - let id_short = if m.node_id.len() > 16 { - format!("{}...", &m.node_id[..14]) - } else { - m.node_id.clone() - }; - Row::new(vec![ - Cell::from(m.state.clone()).style(state_style), - Cell::from(id_short), - Cell::from(m.addr.clone().unwrap_or_default()), - Cell::from(format!("{}", m.incarnation)), - ]) - }) - .collect(), - None => vec![], - }; - - table_state.select(Some(app.dist_member_selected)); - - let table = Table::new( - rows, - [ - Constraint::Length(10), - Constraint::Min(18), - Constraint::Length(22), - Constraint::Length(12), - ], - ) - .header(header) - .block( - Block::default() - .borders(Borders::ALL) - .title(" Members "), - ) - .row_highlight_style( - Style::default() - .bg(Color::DarkGray) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol("> "); - - f.render_stateful_widget(table, area, table_state); -} - -#[cfg(feature = "distribution")] -fn draw_dist_cache(f: &mut Frame, app: &App, area: Rect) { - let rows: Vec = match &app.distribution { - Some(d) => d - .cache_entries - .iter() - .take(area.height.saturating_sub(2) as usize) - .map(|e| { - let actor_short = if e.actor_addr.len() > 16 { - format!("{}...", &e.actor_addr[..14]) - } else { - e.actor_addr.clone() - }; - let node_short = if e.node_id.len() > 12 { - format!("{}...", &e.node_id[..10]) - } else { - e.node_id.clone() - }; - Row::new(vec![ - Cell::from(actor_short), - Cell::from(node_short), - ]) - }) - .collect(), - None => vec![], - }; - - let header = Row::new(vec![ - Cell::from("ACTOR").style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), - Cell::from("NODE").style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), - ]) - .height(1); - - let table = Table::new( - rows, - [Constraint::Percentage(55), Constraint::Percentage(45)], - ) - .header(header) - .block(Block::default().borders(Borders::ALL).title(" Cache ")); - - f.render_widget(table, area); -} - -#[cfg(feature = "distribution")] -fn draw_dist_routing(f: &mut Frame, app: &App, area: Rect) { - let buckets: Vec<(usize, usize)> = match &app.distribution { - Some(d) => d.routing_buckets.clone(), - None => vec![], - }; - - let max_count = buckets.iter().map(|(_, c)| *c).max().unwrap_or(1).max(1); - let bar_max_width = area.width.saturating_sub(16) as usize; // space for "[NNN] " + " N" - - let lines: Vec = buckets - .iter() - .take(area.height.saturating_sub(2) as usize) - .map(|(idx, count)| { - let bar_len = (*count as f64 / max_count as f64 * bar_max_width as f64).round() as usize; - let bar_len = bar_len.max(1); - Line::from(vec![ - Span::styled( - format!(" [{:>3}] ", idx), - Style::default().fg(Color::DarkGray), - ), - Span::styled( - "\u{2588}".repeat(bar_len), - Style::default().fg(Color::Cyan), - ), - Span::styled( - format!(" {}", count), - Style::default().fg(Color::White), - ), - ]) - }) - .collect(); - - let block = Block::default() - .borders(Borders::ALL) - .title(" Routing Buckets "); - let paragraph = Paragraph::new(lines).block(block); - f.render_widget(paragraph, area); -} - -#[cfg(feature = "distribution")] -fn draw_dist_help(f: &mut Frame, area: Rect) { - let help = Line::from(vec![ - Span::styled( - " Tab: views \u{2191}\u{2193}: scroll Esc: overview q: quit", - Style::default().fg(Color::DarkGray), - ), - ]); - f.render_widget(Paragraph::new(help), area); -} - // ─── Helpers ───────────────────────────────────────────────────────────────── fn short_type_name(full: Option<&str>) -> String { diff --git a/crates/datastore/Cargo.toml b/crates/datastore/Cargo.toml index d4c2d38..cfa67e5 100644 --- a/crates/datastore/Cargo.toml +++ b/crates/datastore/Cargo.toml @@ -8,17 +8,19 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] } [dependencies] swactor = { path = "../..", features = ["serde", "transport"] } -distribution = { path = "../distribution" } -shared-types = { path = "../shared-types" } +ed25519-dalek = { version = "2", features = ["rand_core"] } +rand_core = { version = "0.6", features = ["getrandom"] } serde = { version = "1", features = ["derive"] } serde_json = "1" blake3 = "1" +crossbeam-queue = "0.3.12" +tokio = { version = "1", features = ["rt", "sync", "io-util"] } +iroh = "0.96" +getrandom = "0.2" tiny_http = { version = "0.12", optional = true } clap = { version = "4", features = ["derive"], optional = true } ureq = { version = "2", features = ["json"], optional = true } -getrandom = { version = "0.2", optional = true } ctrlc = { version = "3", optional = true } -runtime-dashboard = { path = "../runtime-dashboard", optional = true } toml = { version = "0.8", optional = true } [dev-dependencies] @@ -26,22 +28,13 @@ serde_json = "1" proptest = "1" proptest-state-machine = "0.3" tempfile = "3" -distribution = { path = "../distribution" } swactor = { path = "../.." } -swactor-std = { path = "../std" } ureq = { version = "2", features = ["json"] } -tiny_http = "0.12" -runtime-dashboard = { path = "../runtime-dashboard" } stateright = "0.31" [features] -node = ["dep:tiny_http", "dep:clap", "dep:ctrlc", "dep:runtime-dashboard", "dep:toml"] -cli = ["dep:clap", "dep:ureq", "dep:getrandom"] - -[[bin]] -name = "swactor-store-node" -path = "src/bin/store_node.rs" -required-features = ["node"] +node = ["dep:tiny_http", "dep:clap", "dep:ctrlc", "dep:toml"] +cli = ["dep:clap", "dep:ureq"] [[bin]] name = "swactor-store" diff --git a/crates/datastore/src/actors/datastore_node.rs b/crates/datastore/src/actors/datastore_node.rs index a358884..01067f2 100644 --- a/crates/datastore/src/actors/datastore_node.rs +++ b/crates/datastore/src/actors/datastore_node.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use swactor::actor::{ActorAddress, ActorInterface, Ctx}; use swactor::runtime::Runtime; -use distribution::types::NodeId; +use swactor::transport::NodeId; use crate::actors::stream_downloader::StreamDownloader; use crate::actors::stream_server::StreamServer; @@ -310,7 +310,7 @@ impl DatastoreNode { fn handle_stream_offer( &self, ctx: &Ctx, - stream_id: swactor_streams::types::StreamId, + stream_id: crate::streams::types::StreamId, content_hash: ContentHash, _from_node: [u8; 32], stream_manager: ActorAddress, diff --git a/crates/datastore/src/actors/gateway.rs b/crates/datastore/src/actors/gateway.rs index 2b19929..647617a 100644 --- a/crates/datastore/src/actors/gateway.rs +++ b/crates/datastore/src/actors/gateway.rs @@ -17,7 +17,7 @@ use swactor::actor::{ActorAddress, ActorInterface, Ctx}; use crate::auth::{AccessRequestInfo, AuthzEngine, AuthzResult, DatastoreAction, DeniedReason}; use crate::messages::{DatastoreNodeMsg, DatastoreResponse, GatewayMsg}; -use distribution::types::NodeId; +use swactor::transport::NodeId; /// The auth gateway actor wrapping an `AuthzEngine`. pub struct GatewayActor { diff --git a/crates/datastore/src/actors/metadata.rs b/crates/datastore/src/actors/metadata.rs index f15e29e..df50824 100644 --- a/crates/datastore/src/actors/metadata.rs +++ b/crates/datastore/src/actors/metadata.rs @@ -11,7 +11,7 @@ use std::collections::{HashMap, HashSet}; use swactor::actor::{ActorAddress, ActorInterface, Ctx}; -use distribution::types::NodeId; +use swactor::transport::NodeId; use crate::messages::{BlobStoreMsg, DatastoreResponse, MetadataMsg}; use crate::types::{ContentHash, DatastoreConfig, ObjectEntry, ObjectManifest}; @@ -120,7 +120,7 @@ impl MetadataActor { /// `GcUnreferenced` to BlobStoreActor to delete orphaned chunks. fn gc_tick(&mut self, ctx: &Ctx) { self.tick_count += 1; - if self.tick_count % self.gc_interval != 0 { + if !self.tick_count.is_multiple_of(self.gc_interval) { return; } diff --git a/crates/datastore/src/actors/stream_downloader.rs b/crates/datastore/src/actors/stream_downloader.rs index 2fc2459..772e6e2 100644 --- a/crates/datastore/src/actors/stream_downloader.rs +++ b/crates/datastore/src/actors/stream_downloader.rs @@ -10,8 +10,8 @@ use std::sync::Arc; use swactor::actor::{ActorAddress, ActorInterface, Ctx}; use swactor::runtime::Runtime; -use swactor_streams::messages::{StreamManagerMsg, StreamNotification}; -use swactor_streams::types::{StreamConfig, StreamMode}; +use crate::streams::messages::{StreamManagerMsg, StreamNotification}; +use crate::streams::types::{StreamConfig, StreamMode}; use crate::blob_transfer::{encode_metadata, recv_blob, BlobTransferMetadata}; use crate::messages::{BlobStoreMsg, DatastoreNodeMsg}; diff --git a/crates/datastore/src/actors/stream_listener.rs b/crates/datastore/src/actors/stream_listener.rs index 326ade6..f04b808 100644 --- a/crates/datastore/src/actors/stream_listener.rs +++ b/crates/datastore/src/actors/stream_listener.rs @@ -3,8 +3,8 @@ use swactor::actor::{ActorAddress, ActorInterface, Ctx}; -use swactor_streams::messages::{StreamManagerMsg, StreamNotification}; -use swactor_streams::types::StreamMode; +use crate::streams::messages::{StreamManagerMsg, StreamNotification}; +use crate::streams::types::StreamMode; use crate::blob_transfer::parse_metadata; use crate::messages::DatastoreNodeMsg; diff --git a/crates/datastore/src/actors/stream_server.rs b/crates/datastore/src/actors/stream_server.rs index 5575413..00266f9 100644 --- a/crates/datastore/src/actors/stream_server.rs +++ b/crates/datastore/src/actors/stream_server.rs @@ -11,8 +11,8 @@ use std::time::Duration; use swactor::actor::{ActorAddress, ActorInterface, Ctx}; use swactor::runtime::Runtime; -use swactor_streams::messages::{StreamManagerMsg, StreamNotification}; -use swactor_streams::types::StreamId; +use crate::streams::messages::{StreamManagerMsg, StreamNotification}; +use crate::streams::types::StreamId; use crate::blob_transfer::{poll_inbox, send_blob, BlobTransferError}; use crate::messages::{BlobStoreMsg, DatastoreResponse}; diff --git a/crates/datastore/src/actors/transfer.rs b/crates/datastore/src/actors/transfer.rs index 774f1b0..3e0f2a2 100644 --- a/crates/datastore/src/actors/transfer.rs +++ b/crates/datastore/src/actors/transfer.rs @@ -12,7 +12,7 @@ use std::collections::HashSet; use swactor::actor::{ActorAddress, ActorInterface, Ctx}; -use distribution::types::NodeId; +use swactor::transport::NodeId; use crate::messages::{DatastoreResponse, TransferMsg}; use crate::types::{ContentHash, ObjectManifest, TransferStatus}; diff --git a/crates/datastore/src/api.rs b/crates/datastore/src/api.rs index ceb05e0..27b44e5 100644 --- a/crates/datastore/src/api.rs +++ b/crates/datastore/src/api.rs @@ -12,7 +12,7 @@ use std::time::{Duration, Instant}; use swactor::actor::ActorAddress; use swactor::runtime::{Inbox, Runtime}; -use distribution::types::NodeId; +use swactor::transport::NodeId; use crate::auth::SignedRequest; use crate::chunking::reassemble_blob; @@ -753,7 +753,7 @@ fn try_remote_get( let _ = state.runtime.send_to( peer.metadata, MetadataMsg::HandleFindObject { - from: distribution::types::NodeId([0; 32]), // placeholder + from: swactor::transport::NodeId([0; 32]), // placeholder content_hash, reply_to: *find_inbox.addr(), }, diff --git a/crates/datastore/src/auth.rs b/crates/datastore/src/auth.rs index 33cf6ed..8c31fe1 100644 --- a/crates/datastore/src/auth.rs +++ b/crates/datastore/src/auth.rs @@ -12,9 +12,10 @@ use std::path::Path; use serde::{Deserialize, Serialize}; -use distribution::crypto; -use distribution::types::{NodeId, Signature}; -use shared_types::ContentHash; +use crate::crypto; +use swactor::transport::NodeId; +use crate::crypto::Signature; +use crate::content_hash::ContentHash; // ─── Access Request / Authorized Key Info ────────────────────────────────── @@ -132,7 +133,7 @@ impl AccessControlList { std::fs::create_dir_all(parent)?; } let json = serde_json::to_string_pretty(self) - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + .map_err(|e| io::Error::other(e))?; std::fs::write(path, json) } } @@ -228,7 +229,7 @@ impl AuthzEngine { // 2. Timestamp freshness let ts = request.payload.timestamp; - let diff = if now >= ts { now - ts } else { ts - now }; + let diff = now.abs_diff(ts); if diff > self.timestamp_window { return AuthzResult::Denied(DeniedReason::RequestExpired); } @@ -257,7 +258,7 @@ impl AuthzEngine { // 2. Timestamp freshness let ts = request.payload.timestamp; - let diff = if now >= ts { now - ts } else { ts - now }; + let diff = now.abs_diff(ts); if diff > self.timestamp_window { return AuthzResult::Denied(DeniedReason::RequestExpired); } @@ -320,7 +321,7 @@ impl AuthzEngine { /// Evict nonces whose timestamps fall outside the current window. pub fn gc_nonces(&mut self, now: u64) { self.seen_nonces.retain(|_nonce, ts| { - let diff = if now >= *ts { now - *ts } else { *ts - now }; + let diff = now.abs_diff(*ts); diff <= self.timestamp_window }); } diff --git a/crates/datastore/src/bin/store_cli.rs b/crates/datastore/src/bin/store_cli.rs index 37ded32..a1a2568 100644 --- a/crates/datastore/src/bin/store_cli.rs +++ b/crates/datastore/src/bin/store_cli.rs @@ -10,8 +10,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use clap::{Parser, Subcommand}; -use distribution::crypto::Keypair; -use shared_types::ContentHash; +use swactor_datastore::crypto::Keypair; +use swactor_datastore::content_hash::ContentHash; use swactor_datastore::auth::{sign_request, DatastoreAction, SignedRequestPayload}; #[derive(Parser)] diff --git a/crates/datastore/src/blob_transfer.rs b/crates/datastore/src/blob_transfer.rs index 4cf8e59..7fdb1fd 100644 --- a/crates/datastore/src/blob_transfer.rs +++ b/crates/datastore/src/blob_transfer.rs @@ -18,7 +18,7 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; use swactor::runtime::Inbox; use swactor::actor::Message; -use swactor_streams::handle::{RecvHalf, SendHalf}; +use crate::streams::handle::{RecvHalf, SendHalf}; use crate::types::{ContentHash, ObjectManifest}; @@ -264,8 +264,8 @@ pub async fn poll_inbox(inbox: &Inbox, timeout: Duration) -> Opti mod tests { use super::*; use crate::chunking::chunk_blob; - use swactor_streams::handle::create_stream_handle; - use swactor_streams::types::{StreamConfig, StreamId}; + use crate::streams::handle::create_stream_handle; + use crate::streams::types::{StreamConfig, StreamId}; /// Helper: create a pair of (SendHalf, RecvHalf) connected via tokio tasks /// that relay data through a DuplexStream. @@ -299,39 +299,39 @@ mod tests { } fn spawn_send_task( - mut cmd_rx: tokio::sync::mpsc::Receiver, - evt_tx: tokio::sync::mpsc::Sender, - pool: swactor_streams::BufferPool, + mut cmd_rx: tokio::sync::mpsc::Receiver, + evt_tx: tokio::sync::mpsc::Sender, + pool: crate::streams::BufferPool, mut writer: tokio::io::WriteHalf, ) { use tokio::io::AsyncWriteExt; tokio::spawn(async move { while let Some(cmd) = cmd_rx.recv().await { match cmd { - swactor_streams::channel::SendCommand::Data(buf) => { + crate::streams::channel::SendCommand::Data(buf) => { let data = buf.written(); // Write length-prefixed frame let len = (data.len() as u32).to_be_bytes(); if writer.write_all(&len).await.is_err() { pool.checkin(buf); - let _ = evt_tx.send(swactor_streams::channel::SendEvent::Error( - swactor_streams::StreamError::Disconnected, + let _ = evt_tx.send(crate::streams::channel::SendEvent::Error( + crate::streams::StreamError::Disconnected, )).await; return; } if writer.write_all(data).await.is_err() { pool.checkin(buf); - let _ = evt_tx.send(swactor_streams::channel::SendEvent::Error( - swactor_streams::StreamError::Disconnected, + let _ = evt_tx.send(crate::streams::channel::SendEvent::Error( + crate::streams::StreamError::Disconnected, )).await; return; } pool.checkin(buf); } - swactor_streams::channel::SendCommand::Flush => { + crate::streams::channel::SendCommand::Flush => { let _ = writer.flush().await; } - swactor_streams::channel::SendCommand::Close => { + crate::streams::channel::SendCommand::Close => { let _ = writer.shutdown().await; break; } @@ -342,8 +342,8 @@ mod tests { fn spawn_recv_task( mut reader: tokio::io::ReadHalf, - evt_tx: tokio::sync::mpsc::Sender, - pool: swactor_streams::BufferPool, + evt_tx: tokio::sync::mpsc::Sender, + pool: crate::streams::BufferPool, ) { use tokio::io::AsyncReadExt; tokio::spawn(async move { @@ -353,7 +353,7 @@ mod tests { match reader.read_exact(&mut len_buf).await { Ok(_) => {} Err(_) => { - let _ = evt_tx.send(swactor_streams::channel::RecvEvent::Closed).await; + let _ = evt_tx.send(crate::streams::channel::RecvEvent::Closed).await; return; } } @@ -362,7 +362,7 @@ mod tests { match reader.read_exact(&mut data).await { Ok(_) => {} Err(_) => { - let _ = evt_tx.send(swactor_streams::channel::RecvEvent::Closed).await; + let _ = evt_tx.send(crate::streams::channel::RecvEvent::Closed).await; return; } } @@ -373,15 +373,15 @@ mod tests { let mut buf = match pool.checkout() { Some(b) => b, None => { - let _ = evt_tx.send(swactor_streams::channel::RecvEvent::Error( - swactor_streams::StreamError::BufferExhausted, + let _ = evt_tx.send(crate::streams::channel::RecvEvent::Error( + crate::streams::StreamError::BufferExhausted, )).await; return; } }; let written = buf.write(&data[offset..]); offset += written; - let _ = evt_tx.send(swactor_streams::channel::RecvEvent::Data(buf)).await; + let _ = evt_tx.send(crate::streams::channel::RecvEvent::Data(buf)).await; } } }); diff --git a/crates/datastore/src/bridge.rs b/crates/datastore/src/bridge.rs index 0b23127..92f8c33 100644 --- a/crates/datastore/src/bridge.rs +++ b/crates/datastore/src/bridge.rs @@ -1,364 +1,23 @@ -//! Bridge between the runtime dashboard's `DatastoreStatsProvider` trait and -//! the datastore actor system. Allows the dashboard to perform CRUD operations -//! and lifecycle management without depending on `swactor-datastore` types. +//! Datastore actor group lifecycle management. +//! +//! `DatastoreGroup` owns the full lifecycle of a datastore actor group: +//! BlobStore, Metadata, DatastoreNode, and optional GatewayActor. -use std::collections::BTreeMap; use std::path::PathBuf; use std::sync::Arc; -use std::thread; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use swactor::actor::ActorAddress; -use swactor::runtime::{Inbox, Runtime}; -use swactor_std::RuntimeNaming; +use swactor::runtime::Runtime; +use swactor::std::RuntimeNaming; -use distribution::types::NodeId; -use dashboard::datastore_collector::{ - DatastoreFactory, DatastoreStatsProvider, ListScope, -}; +use swactor::transport::NodeId; use crate::actors::{BlobStoreActor, DatastoreNode, GatewayActor, MetadataActor}; use crate::auth::{AccessControlList, AuthzEngine}; -use crate::chunking::reassemble_blob; -use crate::messages::{DatastoreNodeMsg, DatastoreResponse, GatewayMsg, MetadataMsg}; +use crate::messages::{DatastoreNodeMsg, GatewayMsg, MetadataMsg}; use crate::metrics::DatastoreMetrics; use crate::storage::{FilesystemBackend, InMemoryBackend}; -use crate::types::{ContentHash, DatastoreConfig}; - -const POLL_TIMEOUT: Duration = Duration::from_secs(5); -const POLL_INTERVAL: Duration = Duration::from_millis(1); - -fn poll_response(inbox: &Inbox, timeout: Duration) -> Option { - let start = Instant::now(); - loop { - if let Some(resp) = inbox.try_recv() { - return Some(resp); - } - if start.elapsed() > timeout { - return None; - } - thread::sleep(POLL_INTERVAL); - } -} - -fn entry_to_json(entry: &crate::types::ObjectEntry) -> serde_json::Value { - let node_hex: String = entry.node_id.0.iter().map(|b| format!("{b:02x}")).collect(); - serde_json::json!({ - "content_hash": entry.content_hash.to_hex(), - "name": entry.name, - "node_id": node_hex, - "tags": entry.tags, - "size_bytes": entry.size_bytes, - "created_at": entry.created_at, - }) -} - -fn manifest_to_json(manifest: &crate::types::ObjectManifest) -> serde_json::Value { - let chunks: Vec = manifest - .chunks - .iter() - .map(|c| { - serde_json::json!({ - "hash": c.hash.to_hex(), - "offset": c.offset, - "size": c.size, - }) - }) - .collect(); - serde_json::json!({ - "content_hash": manifest.content_hash.to_hex(), - "chunks": chunks, - "total_size": manifest.total_size, - "chunk_size": manifest.chunk_size, - "content_type": manifest.content_type, - }) -} - -fn entries_to_json(entries: &[crate::types::ObjectEntry]) -> Vec { - entries.iter().map(entry_to_json).collect() -} - -/// Bridges the dashboard trait to the datastore actor system. -pub struct DatastoreBridge { - metrics: Arc, - runtime: Arc, - datastore_addr: ActorAddress, - metadata_addr: ActorAddress, - #[allow(dead_code)] - blob_store_addr: ActorAddress, -} - -impl DatastoreBridge { - pub fn new( - metrics: Arc, - runtime: Arc, - datastore_addr: ActorAddress, - metadata_addr: ActorAddress, - blob_store_addr: ActorAddress, - ) -> Self { - Self { - metrics, - runtime, - datastore_addr, - metadata_addr, - blob_store_addr, - } - } -} - -impl DatastoreStatsProvider for DatastoreBridge { - fn snapshot_json(&self) -> Option { - let snap = self.metrics.snapshot(); - serde_json::to_string(&snap).ok() - } - - fn is_running(&self) -> bool { - true - } - - fn list_objects(&self, name_filter: Option<&str>, scope: ListScope) -> Result { - let inbox = self.runtime.new_inbox::() - .map_err(|e| format!("failed to create inbox: {e}"))?; - - match scope { - ListScope::Local => { - let _ = self.runtime.send_to( - self.datastore_addr, - DatastoreNodeMsg::List { - name_filter: name_filter.map(|s| s.to_string()), - all: false, - reply_to: *inbox.addr(), - }, - ); - } - ListScope::Swarm => { - let _ = self.runtime.send_to( - self.metadata_addr, - MetadataMsg::ListLocal { - name_filter: name_filter.map(|s| s.to_string()), - reply_to: *inbox.addr(), - }, - ); - } - } - - match poll_response(&inbox, POLL_TIMEOUT) { - Some(DatastoreResponse::ListOk { entries }) => { - let json = serde_json::json!({ "entries": entries_to_json(&entries) }).to_string(); - Ok(json) - } - Some(DatastoreResponse::Error { reason }) => Err(reason), - _ => Err("timeout".into()), - } - } - - fn get_object(&self, hash: &str) -> Result { - let content_hash = ContentHash::from_hex(hash) - .ok_or_else(|| "invalid content hash hex".to_string())?; - - let inbox = self.runtime.new_inbox::() - .map_err(|e| format!("failed to create inbox: {e}"))?; - - let _ = self.runtime.send_to( - self.datastore_addr, - DatastoreNodeMsg::Get { - content_hash, - reply_to: *inbox.addr(), - }, - ); - - match poll_response(&inbox, POLL_TIMEOUT) { - Some(DatastoreResponse::GetOk { entry, manifest }) => { - self.metrics.record_get(&content_hash.to_hex()); - let json = serde_json::json!({ - "entry": entry_to_json(&entry), - "manifest": manifest_to_json(&manifest), - }) - .to_string(); - Ok(json) - } - Some(DatastoreResponse::NotFound) => Err("not found".into()), - Some(DatastoreResponse::Error { reason }) => Err(reason), - _ => Err("timeout".into()), - } - } - - fn get_data(&self, hash: &str) -> Result, String> { - let content_hash = ContentHash::from_hex(hash) - .ok_or_else(|| "invalid content hash hex".to_string())?; - - // Get manifest - let inbox = self.runtime.new_inbox::() - .map_err(|e| format!("failed to create inbox: {e}"))?; - - let _ = self.runtime.send_to( - self.datastore_addr, - DatastoreNodeMsg::Get { - content_hash, - reply_to: *inbox.addr(), - }, - ); - - self.metrics.record_get(&content_hash.to_hex()); - - let manifest = match poll_response(&inbox, POLL_TIMEOUT) { - Some(DatastoreResponse::GetOk { manifest, .. }) => manifest, - Some(DatastoreResponse::NotFound) => return Err("not found".into()), - Some(DatastoreResponse::Error { reason }) => return Err(reason), - _ => return Err("timeout".into()), - }; - - // Read chunks - let mut chunk_data = Vec::new(); - for chunk_ref in &manifest.chunks { - let chunk_inbox = self.runtime.new_inbox::() - .map_err(|e| format!("failed to create inbox: {e}"))?; - - let _ = self.runtime.send_to( - self.datastore_addr, - DatastoreNodeMsg::ReadChunk { - hash: chunk_ref.hash, - reply_to: *chunk_inbox.addr(), - }, - ); - - match poll_response(&chunk_inbox, POLL_TIMEOUT) { - Some(DatastoreResponse::ChunkOk { hash, data }) => { - chunk_data.push((hash, data)); - } - _ => return Err("failed to read chunk".into()), - } - } - - reassemble_blob(&manifest, &chunk_data) - .map_err(|e| format!("reassembly failed: {e:?}")) - } - - fn put_data(&self, data: Vec, name: Option) -> Result { - let body_len = data.len(); - - let inbox = self.runtime.new_inbox::() - .map_err(|e| format!("failed to create inbox: {e}"))?; - - let _ = self.runtime.send_to( - self.datastore_addr, - DatastoreNodeMsg::Put { - data, - name: name.clone(), - tags: BTreeMap::new(), - reply_to: *inbox.addr(), - }, - ); - - match poll_response(&inbox, POLL_TIMEOUT) { - Some(DatastoreResponse::PutOk { content_hash }) => { - let hex = content_hash.to_hex(); - self.metrics.record_put(&hex, name.as_deref(), body_len as u64); - let json = serde_json::json!({ "content_hash": hex }).to_string(); - Ok(json) - } - Some(DatastoreResponse::Error { reason }) => Err(reason), - _ => Err("timeout waiting for put response".into()), - } - } - - fn delete_object(&self, hash: &str) -> Result { - let content_hash = ContentHash::from_hex(hash) - .ok_or_else(|| "invalid content hash hex".to_string())?; - - let inbox = self.runtime.new_inbox::() - .map_err(|e| format!("failed to create inbox: {e}"))?; - - let _ = self.runtime.send_to( - self.datastore_addr, - DatastoreNodeMsg::Delete { - content_hash, - reply_to: *inbox.addr(), - }, - ); - - match poll_response(&inbox, POLL_TIMEOUT) { - Some(DatastoreResponse::DeleteOk { content_hash }) => { - let hex = content_hash.to_hex(); - self.metrics.record_delete(&hex, 0); - let json = serde_json::json!({ "content_hash": hex }).to_string(); - Ok(json) - } - Some(DatastoreResponse::NotFound) => Err("not found".into()), - Some(DatastoreResponse::Error { reason }) => Err(reason), - _ => Err("timeout".into()), - } - } - - fn node_status(&self) -> Result { - let inbox = self.runtime.new_inbox::() - .map_err(|e| format!("failed to create inbox: {e}"))?; - - let _ = self.runtime.send_to( - self.datastore_addr, - DatastoreNodeMsg::Status { - reply_to: *inbox.addr(), - }, - ); - - match poll_response(&inbox, POLL_TIMEOUT) { - Some(DatastoreResponse::NodeStatus { node_id }) => { - let hex: String = node_id.0.iter().map(|b| format!("{b:02x}")).collect(); - let json = serde_json::json!({ "node_id": hex }).to_string(); - Ok(json) - } - _ => Err("timeout".into()), - } - } - - fn shutdown_datastore(&self) -> Result<(), String> { - // We can't actually stop the actors from here without a runtime handle, - // but we can signal shutdown. The caller (server handler) clears the - // provider reference which effectively disables the datastore. - Ok(()) - } -} - -/// Factory that can spawn a new set of datastore actors on a shared runtime. -pub struct DatastoreNodeFactory { - runtime: Arc, - default_chunk_size: u32, -} - -impl DatastoreNodeFactory { - pub fn new(runtime: Arc, default_chunk_size: u32) -> Self { - Self { - runtime, - default_chunk_size, - } - } -} - -impl DatastoreFactory for DatastoreNodeFactory { - fn start_datastore( - &self, - storage_path: Option, - ) -> Result, String> { - // Generate a unique node ID - let node_id = generate_node_id(); - let node_hex: String = node_id.0.iter().map(|b| format!("{b:02x}")).collect(); - - let group = DatastoreGroup::spawn( - Arc::clone(&self.runtime), - DatastoreGroupConfig { - node_id, - node_id_hex: node_hex, - chunk_size: self.default_chunk_size, - storage_path, - auth: None, - gc_interval: 1000, - disseminate_interval: 50, - }, - )?; - - Ok(group.bridge().clone()) - } -} +use crate::types::DatastoreConfig; // ─── DatastoreGroup ───────────────────────────────────────────────────────── @@ -384,7 +43,7 @@ pub struct DatastoreGroup { datastore_addr: ActorAddress, metadata_addr: ActorAddress, gateway_addr: Option, - bridge: Arc, + metrics: Arc, gc_interval: u64, disseminate_interval: u64, runtime: Arc, @@ -455,14 +114,6 @@ impl DatastoreGroup { let metrics = Arc::new(DatastoreMetrics::new()); metrics.set_node_id(config.node_id_hex.clone()); - let bridge: Arc = Arc::new(DatastoreBridge::new( - metrics, - Arc::clone(&runtime), - datastore_addr, - metadata_addr, - blob_store_addr, - )); - if config.storage_path.is_some() { eprintln!( "Datastore: persistent ({})", @@ -476,7 +127,7 @@ impl DatastoreGroup { datastore_addr, metadata_addr, gateway_addr, - bridge, + metrics, gc_interval: config.gc_interval, disseminate_interval: config.disseminate_interval, runtime, @@ -485,22 +136,32 @@ impl DatastoreGroup { /// Send periodic ticks to the datastore actors based on the current round. pub fn tick(&self, round: u64) { - if round % self.gc_interval == 0 { + if round.is_multiple_of(self.gc_interval) { let _ = self.runtime.send_to(self.metadata_addr, MetadataMsg::GcTick); if let Some(gw) = self.gateway_addr { let _ = self.runtime.send_to(gw, GatewayMsg::NonceGcTick); } } - if round % self.disseminate_interval == 0 { + if round.is_multiple_of(self.disseminate_interval) { let _ = self .runtime .send_to(self.metadata_addr, MetadataMsg::DisseminateTick); } } - /// Access the bridge (as a trait object for the dashboard). - pub fn bridge(&self) -> &Arc { - &self.bridge + /// Access the metrics accumulator. + pub fn metrics(&self) -> &Arc { + &self.metrics + } + + /// Address of the DatastoreNode actor. + pub fn datastore_addr(&self) -> ActorAddress { + self.datastore_addr + } + + /// Address of the MetadataActor. + pub fn metadata_addr(&self) -> ActorAddress { + self.metadata_addr } /// Configure stream support: sends ConfigureStreams to DatastoreNode and @@ -521,25 +182,9 @@ impl DatastoreGroup { // Spawn StreamListener use crate::actors::stream_listener::StreamListener; - use swactor_std::RuntimeNaming; + use swactor::std::RuntimeNaming; if let Ok(addr) = self.runtime.spawn(StreamListener::new(self.datastore_addr, stream_manager)) { let _ = self.runtime.register_name("StreamListener", addr); } } } - -fn generate_node_id() -> NodeId { - let mut bytes = [0u8; 32]; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - for (i, b) in nanos.to_le_bytes().iter().enumerate() { - bytes[i % 32] ^= *b; - } - let pid = std::process::id(); - for (i, b) in pid.to_le_bytes().iter().enumerate() { - bytes[i + 16] ^= *b; - } - NodeId(bytes) -} diff --git a/crates/datastore/src/cli.rs b/crates/datastore/src/cli.rs index 1dbc058..8c126c7 100644 --- a/crates/datastore/src/cli.rs +++ b/crates/datastore/src/cli.rs @@ -6,7 +6,7 @@ use std::collections::BTreeMap; use std::path::PathBuf; -use distribution::types::NodeId; +use swactor::transport::NodeId; /// Top-level CLI commands for `swactor-store`. #[derive(Debug, Clone)] diff --git a/crates/shared-types/src/lib.rs b/crates/datastore/src/content_hash.rs similarity index 87% rename from crates/shared-types/src/lib.rs rename to crates/datastore/src/content_hash.rs index c56d2f8..91da127 100644 --- a/crates/shared-types/src/lib.rs +++ b/crates/datastore/src/content_hash.rs @@ -1,14 +1,9 @@ -//! Shared types used across the swactor crate ecosystem. -//! -//! Contains `ContentHash` — the blake3-based content address used by -//! both the datastore and distribution layers. +//! Blake3-based content hash for blob addressing. use std::fmt; use serde::{Deserialize, Serialize}; -// ─── ContentHash ──────────────────────────────────────────────────────────── - /// A blake3 content hash (32 bytes). /// /// The primary identifier for blobs and the DHT key. XOR distance for DHT diff --git a/crates/datastore/src/crypto.rs b/crates/datastore/src/crypto.rs new file mode 100644 index 0000000..2a49f0b --- /dev/null +++ b/crates/datastore/src/crypto.rs @@ -0,0 +1,96 @@ +//! Ed25519 cryptographic primitives for the datastore. + +use std::fmt; + +use ed25519_dalek::{Signer, Verifier}; +use serde::{Deserialize, Serialize}; + +use swactor::transport::NodeId; + +// ─── Signature ────────────────────────────────────────────────────────────── + +/// An ed25519 signature (64 bytes). +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct Signature(pub [u8; 64]); + +impl Serialize for Signature { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_bytes(&self.0) + } +} + +impl<'de> Deserialize<'de> for Signature { + fn deserialize>(deserializer: D) -> Result { + let bytes: Vec = Deserialize::deserialize(deserializer)?; + if bytes.len() != 64 { + return Err(serde::de::Error::custom(format!( + "expected 64 bytes for Signature, got {}", + bytes.len() + ))); + } + let mut arr = [0u8; 64]; + arr.copy_from_slice(&bytes); + Ok(Signature(arr)) + } +} + +impl fmt::Debug for Signature { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Sig(")?; + for b in &self.0[..4] { + write!(f, "{:02x}", b)?; + } + write!(f, "\u{2026})") + } +} + +// ─── Keypair ──────────────────────────────────────────────────────────────── + +/// Node identity keypair — wraps ed25519-dalek. +pub struct Keypair { + inner: ed25519_dalek::SigningKey, +} + +impl Keypair { + /// Generate a new random keypair. + pub fn generate() -> Self { + let mut csprng = rand_core::OsRng; + Self { + inner: ed25519_dalek::SigningKey::generate(&mut csprng), + } + } + + /// Reconstruct from raw secret key bytes (32 bytes). + pub fn from_bytes(secret: &[u8; 32]) -> Self { + Self { + inner: ed25519_dalek::SigningKey::from_bytes(secret), + } + } + + /// The public key as a `NodeId`. + pub fn node_id(&self) -> NodeId { + NodeId(self.inner.verifying_key().to_bytes()) + } + + /// Raw secret key bytes. + pub fn secret_bytes(&self) -> [u8; 32] { + self.inner.to_bytes() + } + + /// Sign arbitrary bytes. + pub fn sign(&self, msg: &[u8]) -> Signature { + let sig = self.inner.sign(msg); + Signature(sig.to_bytes()) + } +} + +// ─── Verification ─────────────────────────────────────────────────────────── + +/// Verify a signature against a `NodeId` (public key) and message bytes. +pub fn verify(node_id: &NodeId, msg: &[u8], sig: &Signature) -> bool { + let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(&node_id.0) else { + return false; + }; + let signature = ed25519_dalek::Signature::from_bytes(&sig.0); + vk.verify(msg, &signature).is_ok() +} diff --git a/crates/datastore/src/lib.rs b/crates/datastore/src/lib.rs index dd140d6..31f6846 100644 --- a/crates/datastore/src/lib.rs +++ b/crates/datastore/src/lib.rs @@ -1,11 +1,16 @@ +pub mod content_hash; +pub mod crypto; pub mod types; pub mod messages; pub mod chunking; pub mod storage; pub mod actors; pub mod auth; +pub mod blob_transfer; +pub mod bridge; pub mod cli; pub mod metrics; +pub mod streams; #[cfg(feature = "node")] pub mod api; #[cfg(feature = "node")] @@ -18,3 +23,4 @@ pub use messages::{BlobStoreMsg, DatastoreNodeMsg, DatastoreResponse, MetadataMs pub use chunking::{chunk_blob, reassemble_blob, verify_integrity, ChunkingError}; pub use storage::{StorageBackend, FilesystemBackend, InMemoryBackend}; pub use actors::{BlobStoreActor, DatastoreNode, MetadataActor, TransferActor}; +pub use bridge::{DatastoreAuthConfig, DatastoreGroup, DatastoreGroupConfig}; diff --git a/crates/datastore/src/messages.rs b/crates/datastore/src/messages.rs index 10f7d9b..e6a8bbd 100644 --- a/crates/datastore/src/messages.rs +++ b/crates/datastore/src/messages.rs @@ -16,8 +16,8 @@ use swactor::actor::ActorAddress; use swactor::runtime::Runtime; use swactor::transport::NetworkMessage; -use distribution::types::NodeId; -use swactor_streams::types::StreamId; +use swactor::transport::NodeId; +use crate::streams::types::StreamId; use crate::auth::{AccessRequestInfo, AuthorizedKeyInfo, DeniedReason, SignedRequest}; use crate::types::{ContentHash, ObjectEntry, ObjectManifest}; diff --git a/crates/datastore/src/metrics.rs b/crates/datastore/src/metrics.rs index 9db347e..e013fdc 100644 --- a/crates/datastore/src/metrics.rs +++ b/crates/datastore/src/metrics.rs @@ -1,8 +1,8 @@ -//! Thread-safe metrics for the datastore, consumed by the runtime dashboard. +//! Thread-safe metrics for the datastore. //! //! `DatastoreMetrics` accumulates counters and event history from any thread //! (API handlers run on `tiny_http` worker threads). The dashboard polls -//! `snapshot()` every ~200ms via the `DatastoreStatsProvider` trait. +//! `snapshot()` every ~200ms via the plugin system. use std::collections::VecDeque; use std::sync::atomic::{AtomicU64, Ordering}; @@ -71,6 +71,12 @@ pub struct DatastoreMetrics { transfers: Mutex>, } +impl Default for DatastoreMetrics { + fn default() -> Self { + Self::new() + } +} + impl DatastoreMetrics { pub fn new() -> Self { Self { @@ -186,11 +192,3 @@ fn now_ms() -> u64 { .as_millis() as u64 } -// ── Dashboard integration ──────────────────────────────────────────────────── - -impl dashboard::datastore_collector::DatastoreStatsProvider for DatastoreMetrics { - fn snapshot_json(&self) -> Option { - let snap = self.snapshot(); - serde_json::to_string(&snap).ok() - } -} diff --git a/crates/datastore/src/pool/coordinator.rs b/crates/datastore/src/pool/coordinator.rs deleted file mode 100644 index e9f7dd5..0000000 --- a/crates/datastore/src/pool/coordinator.rs +++ /dev/null @@ -1,274 +0,0 @@ -//! Pool coordinator actor — placement-aware CRUD facade. -//! -//! Owns an `Arc>` for query access and delegates -//! storage operations to the co-located `DatastoreNode` actor. - -use std::collections::BTreeMap; -use std::sync::{Arc, Mutex}; - -use swactor::actor::{ActorAddress, ActorInterface, Ctx}; - -use distribution::types::NodeId; -use shared_types::ContentHash; -use shared_types::pool::PoolConfig; - -use crate::messages::{DatastoreNodeMsg, DatastoreResponse}; -use super::disseminator::PoolDisseminator; -use super::messages::PoolCoordinatorMsg; - -/// The pool coordinator actor. -pub struct PoolCoordinator { - node_id: NodeId, - pool_config: PoolConfig, - disseminator: Arc>, - - // Co-located actor addresses - datastore_addr: ActorAddress, - - tick_count: u64, -} - -impl PoolCoordinator { - pub fn new( - node_id: NodeId, - pool_config: PoolConfig, - disseminator: Arc>, - datastore_addr: ActorAddress, - ) -> Self { - Self { - node_id, - pool_config, - disseminator, - datastore_addr, - tick_count: 0, - } - } - - /// Access the shared disseminator. - pub fn disseminator(&self) -> &Arc> { - &self.disseminator - } - - fn cluster_size(&self) -> usize { - let d = self.disseminator.lock().unwrap(); - d.member_count().max(1) - } - - // ─── Message handlers ────────────────────────────────────────────── - - fn handle_pool_put( - &self, - ctx: &Ctx, - data: Vec, - name: Option, - tags: BTreeMap, - reply_to: ActorAddress, - ) { - // Delegate to local DatastoreNode for now. - // Future: check capacity and redirect to best node. - let _ = ctx.send( - self.datastore_addr, - DatastoreNodeMsg::Put { - data, - name, - tags, - reply_to, - }, - ); - - // Note: content announcement happens after PutOk is received. - // For now, caller is responsible for announcing content via PoolTick - // or a future PutOk callback. - } - - fn handle_pool_get( - &self, - ctx: &Ctx, - content_hash: ContentHash, - reply_to: ActorAddress, - ) { - // Try local first via DatastoreNode - let _ = ctx.send( - self.datastore_addr, - DatastoreNodeMsg::Get { - content_hash, - reply_to, - }, - ); - - // Future: if local not found, use disseminator.locate_content() - // to fetch from a specific peer instead of fan-out. - } - - fn handle_pool_delete( - &self, - ctx: &Ctx, - content_hash: ContentHash, - reply_to: ActorAddress, - ) { - // Delete locally - let _ = ctx.send( - self.datastore_addr, - DatastoreNodeMsg::Delete { - content_hash, - reply_to, - }, - ); - - // Announce tombstone via gossip - let cluster_size = self.cluster_size(); - self.disseminator - .lock() - .unwrap() - .remove_content(content_hash, cluster_size); - } - - fn handle_pool_list( - &self, - ctx: &Ctx, - name_filter: Option, - reply_to: ActorAddress, - ) { - let _ = ctx.send( - self.datastore_addr, - DatastoreNodeMsg::List { - name_filter, - all: false, - reply_to, - }, - ); - } - - fn handle_pool_status(&self, ctx: &Ctx, reply_to: ActorAddress) { - let d = self.disseminator.lock().unwrap(); - let (total_bytes, used_bytes) = d.pool_capacity_summary(); - let members: Vec = d - .active_members() - .iter() - .map(|id| id.0.iter().map(|b| format!("{b:02x}")).collect()) - .collect(); - - let member_count = d.member_count(); - let content_count = d.content_count(); - drop(d); - - let json = serde_json::json!({ - "pool_name": self.pool_config.pool_name, - "pool_id": self.pool_config.pool_id.to_hex(), - "member_count": member_count, - "content_count": content_count, - "total_bytes": total_bytes, - "used_bytes": used_bytes, - "members": members, - }); - - let _ = ctx.send( - reply_to, - DatastoreResponse::PoolStatus { - json: json.to_string(), - }, - ); - } - - fn handle_join_pool(&mut self, ctx: &Ctx, reply_to: ActorAddress) { - let cluster_size = self.cluster_size(); - let mut d = self.disseminator.lock().unwrap(); - - if !d.is_node_authorized(&self.node_id) { - let _ = ctx.send( - reply_to, - DatastoreResponse::Error { - reason: "not authorized to join pool".into(), - }, - ); - return; - } - - d.join(cluster_size); - d.announce_capacity(self.pool_config.capacity_bytes, 0, cluster_size); - drop(d); - - let _ = ctx.send(reply_to, DatastoreResponse::Bool(true)); - } - - fn handle_leave_pool(&mut self, ctx: &Ctx, reply_to: ActorAddress) { - let cluster_size = self.cluster_size(); - self.disseminator.lock().unwrap().leave(cluster_size); - let _ = ctx.send(reply_to, DatastoreResponse::Bool(true)); - } - - fn handle_grant_access(&mut self, ctx: &Ctx, target: NodeId, reply_to: ActorAddress) { - let cluster_size = self.cluster_size(); - self.disseminator - .lock() - .unwrap() - .grant_access(target, cluster_size); - let _ = ctx.send(reply_to, DatastoreResponse::Bool(true)); - } - - fn handle_revoke_access(&mut self, ctx: &Ctx, target: NodeId, reply_to: ActorAddress) { - let cluster_size = self.cluster_size(); - self.disseminator - .lock() - .unwrap() - .revoke_access(target, cluster_size); - let _ = ctx.send(reply_to, DatastoreResponse::Bool(true)); - } - - fn handle_pool_tick(&mut self) { - self.tick_count += 1; - // Periodic capacity re-announcement (every 100 ticks) - if self.tick_count % 100 == 0 { - let cluster_size = self.cluster_size(); - self.disseminator.lock().unwrap().announce_capacity( - self.pool_config.capacity_bytes, - 0, // TODO: query actual usage from BlobStore - cluster_size, - ); - } - } -} - -impl ActorInterface for PoolCoordinator { - type Incoming = PoolCoordinatorMsg; - type Response = DatastoreResponse; - - fn handle(&mut self, ctx: &Ctx, msg: PoolCoordinatorMsg) { - match msg { - PoolCoordinatorMsg::PoolPut { - data, - name, - tags, - reply_to, - } => self.handle_pool_put(ctx, data, name, tags, reply_to), - PoolCoordinatorMsg::PoolGet { - content_hash, - reply_to, - } => self.handle_pool_get(ctx, content_hash, reply_to), - PoolCoordinatorMsg::PoolDelete { - content_hash, - reply_to, - } => self.handle_pool_delete(ctx, content_hash, reply_to), - PoolCoordinatorMsg::PoolList { - name_filter, - reply_to, - } => self.handle_pool_list(ctx, name_filter, reply_to), - PoolCoordinatorMsg::PoolStatus { reply_to } => { - self.handle_pool_status(ctx, reply_to) - } - PoolCoordinatorMsg::JoinPool { reply_to } => { - self.handle_join_pool(ctx, reply_to) - } - PoolCoordinatorMsg::LeavePool { reply_to } => { - self.handle_leave_pool(ctx, reply_to) - } - PoolCoordinatorMsg::GrantPoolAccess { target, reply_to } => { - self.handle_grant_access(ctx, target, reply_to) - } - PoolCoordinatorMsg::RevokePoolAccess { target, reply_to } => { - self.handle_revoke_access(ctx, target, reply_to) - } - PoolCoordinatorMsg::PoolTick => self.handle_pool_tick(), - } - } -} diff --git a/crates/datastore/src/pool/disseminator.rs b/crates/datastore/src/pool/disseminator.rs deleted file mode 100644 index 8af5040..0000000 --- a/crates/datastore/src/pool/disseminator.rs +++ /dev/null @@ -1,716 +0,0 @@ -//! Pool disseminator — gossip-converged state for pool membership, -//! capacity, content locations, and ACL. -//! -//! Implements `GossipChannel` to plug into the generic gossip system -//! via `DistributedNode::register_channel()`. - -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -use distribution::gossip_channel::{DisseminationBuffer, GossipChannel, deserialize_each, serialize_each}; -use distribution::types::NodeId; -use shared_types::ContentHash; -use shared_types::pool::*; - -// ─── PoolDisseminator ────────────────────────────────────────────────────── - -/// Configuration for the pool disseminator. -#[derive(Debug, Clone)] -pub struct PoolDisseminatorConfig { - pub tombstone_ttl: u64, - pub gc_interval: u64, -} - -impl Default for PoolDisseminatorConfig { - fn default() -> Self { - Self { - tombstone_ttl: 3600, - gc_interval: 1000, - } - } -} - -/// Manages converged pool state via gossip dissemination. -#[derive(Debug)] -pub struct PoolDisseminator { - pool_id: PoolId, - pool_name: String, - local_node_id: NodeId, - - // Converged state maps - members: HashMap<[u8; 32], PoolMemberEntry>, - capacity: HashMap<[u8; 32], PoolCapacityEntry>, - content_locations: HashMap<(ContentHash, [u8; 32]), ContentLocationEntry>, - acl: HashMap<[u8; 32], PoolACLEntry>, - - // Dissemination buffer - buffer: DisseminationBuffer, - - // Local generation counters - local_member_gen: u64, - local_capacity_gen: u64, - - config: PoolDisseminatorConfig, - tick_count: u64, -} - -impl PoolDisseminator { - pub fn new(pool_id: PoolId, pool_name: String, local_node_id: NodeId, lambda: usize) -> Self { - Self { - pool_id, - pool_name, - local_node_id, - members: HashMap::new(), - capacity: HashMap::new(), - content_locations: HashMap::new(), - acl: HashMap::new(), - buffer: DisseminationBuffer::new(lambda), - local_member_gen: 0, - local_capacity_gen: 0, - config: PoolDisseminatorConfig::default(), - tick_count: 0, - } - } - - pub fn with_config(mut self, config: PoolDisseminatorConfig) -> Self { - self.config = config; - self - } - - pub fn pool_id(&self) -> PoolId { - self.pool_id - } - - // ─── Lifecycle methods ────────────────────────────────────────────── - - /// Join the pool. Announces Active membership. - pub fn join(&mut self, cluster_size: usize) { - self.local_member_gen += 1; - let entry = PoolMemberEntry { - pool_id: self.pool_id, - node_id: self.local_node_id.0, - state: PoolMemberState::Active, - generation: self.local_member_gen, - }; - self.merge_membership(entry.clone()); - self.buffer.enqueue(PoolEntry::Membership(entry), cluster_size); - } - - /// Leave the pool. Announces Left membership. - pub fn leave(&mut self, cluster_size: usize) { - self.local_member_gen += 1; - let entry = PoolMemberEntry { - pool_id: self.pool_id, - node_id: self.local_node_id.0, - state: PoolMemberState::Left, - generation: self.local_member_gen, - }; - self.merge_membership(entry.clone()); - self.buffer.enqueue(PoolEntry::Membership(entry), cluster_size); - } - - /// Announce storage capacity. - pub fn announce_capacity(&mut self, total: u64, used: u64, cluster_size: usize) { - self.local_capacity_gen += 1; - let entry = PoolCapacityEntry { - pool_id: self.pool_id, - node_id: self.local_node_id.0, - total_bytes: total, - used_bytes: used, - generation: self.local_capacity_gen, - }; - self.merge_capacity(entry.clone()); - self.buffer.enqueue(PoolEntry::Capacity(entry), cluster_size); - } - - /// Announce that this node has a piece of content. - pub fn announce_content(&mut self, hash: ContentHash, cluster_size: usize) { - let key = (hash, self.local_node_id.0); - let next_gen = self.content_locations.get(&key).map_or(1, |e| e.generation + 1); - let entry = ContentLocationEntry { - pool_id: self.pool_id, - content_hash: hash, - node_id: self.local_node_id.0, - generation: next_gen, - tombstone: false, - }; - self.merge_content_location(entry.clone()); - self.buffer.enqueue(PoolEntry::ContentLocation(entry), cluster_size); - } - - /// Remove content announcement (tombstone). - pub fn remove_content(&mut self, hash: ContentHash, cluster_size: usize) { - let key = (hash, self.local_node_id.0); - let next_gen = self.content_locations.get(&key).map_or(1, |e| e.generation + 1); - let entry = ContentLocationEntry { - pool_id: self.pool_id, - content_hash: hash, - node_id: self.local_node_id.0, - generation: next_gen, - tombstone: true, - }; - self.merge_content_location(entry.clone()); - self.buffer.enqueue(PoolEntry::ContentLocation(entry), cluster_size); - } - - /// Grant access to a node. - pub fn grant_access(&mut self, target: NodeId, cluster_size: usize) { - let next_gen = self.acl.get(&target.0).map_or(1, |e| e.generation + 1); - let entry = PoolACLEntry { - pool_id: self.pool_id, - node_id: target.0, - granted_by: self.local_node_id.0, - generation: next_gen, - revoked: false, - }; - self.merge_acl(entry.clone()); - self.buffer.enqueue(PoolEntry::ACL(entry), cluster_size); - } - - /// Revoke access from a node. - pub fn revoke_access(&mut self, target: NodeId, cluster_size: usize) { - let next_gen = self.acl.get(&target.0).map_or(1, |e| e.generation + 1); - let entry = PoolACLEntry { - pool_id: self.pool_id, - node_id: target.0, - granted_by: self.local_node_id.0, - generation: next_gen, - revoked: true, - }; - self.merge_acl(entry.clone()); - self.buffer.enqueue(PoolEntry::ACL(entry), cluster_size); - } - - // ─── Query API ────────────────────────────────────────────────────── - - /// All active pool members. - pub fn active_members(&self) -> Vec { - self.members - .values() - .filter(|m| m.state == PoolMemberState::Active) - .map(|m| NodeId(m.node_id)) - .collect() - } - - /// Total and used capacity across the pool. - pub fn pool_capacity_summary(&self) -> (u64, u64) { - let mut total = 0u64; - let mut used = 0u64; - for cap in self.capacity.values() { - // Only count active members - if let Some(m) = self.members.get(&cap.node_id) { - if m.state == PoolMemberState::Active { - total = total.saturating_add(cap.total_bytes); - used = used.saturating_add(cap.used_bytes); - } - } - } - (total, used) - } - - /// Find which nodes have a given content hash. - pub fn locate_content(&self, hash: &ContentHash) -> Vec { - self.content_locations - .iter() - .filter(|((h, _), entry)| h == hash && !entry.tombstone) - .map(|((_, node_id), _)| NodeId(*node_id)) - .collect() - } - - /// Find the node with the most free space. - pub fn node_with_most_free_space(&self) -> Option { - self.capacity - .values() - .filter(|cap| { - self.members - .get(&cap.node_id) - .is_some_and(|m| m.state == PoolMemberState::Active) - }) - .max_by_key(|cap| cap.total_bytes.saturating_sub(cap.used_bytes)) - .map(|cap| NodeId(cap.node_id)) - } - - /// Check if a node is authorized to join this pool. - pub fn is_node_authorized(&self, node_id: &NodeId) -> bool { - // If no ACL entries exist, the pool is open - if self.acl.is_empty() { - return true; - } - self.acl - .get(&node_id.0) - .is_some_and(|entry| !entry.revoked) - } - - /// Number of active members. - pub fn member_count(&self) -> usize { - self.members - .values() - .filter(|m| m.state == PoolMemberState::Active) - .count() - } - - /// Number of live content location entries (non-tombstone). - pub fn content_count(&self) -> usize { - self.content_locations - .values() - .filter(|e| !e.tombstone) - .count() - } - - /// Serialize the current pool state to a JSON string for the dashboard. - pub fn snapshot_json(&self) -> String { - fn hex(bytes: &[u8; 32]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() - } - - let (total_bytes, used_bytes) = self.pool_capacity_summary(); - - let members: Vec = self - .members - .values() - .filter(|m| m.state == PoolMemberState::Active) - .map(|m| { - let cap = self.capacity.get(&m.node_id); - serde_json::json!({ - "node_id": hex(&m.node_id), - "state": format!("{:?}", m.state), - "generation": m.generation, - "total_bytes": cap.map_or(0, |c| c.total_bytes), - "used_bytes": cap.map_or(0, |c| c.used_bytes), - }) - }) - .collect(); - - // Group content locations by hash - let mut by_hash: HashMap> = HashMap::new(); - for ((hash, _), entry) in &self.content_locations { - if !entry.tombstone { - by_hash.entry(*hash).or_default().push(entry.node_id); - } - } - let content_locations: Vec = by_hash - .iter() - .map(|(hash, nodes)| { - serde_json::json!({ - "content_hash": hash.to_hex(), - "nodes": nodes.iter().map(hex).collect::>(), - "replica_count": nodes.len(), - }) - }) - .collect(); - - let acl: Vec = self - .acl - .values() - .map(|a| { - serde_json::json!({ - "node_id": hex(&a.node_id), - "granted_by": hex(&a.granted_by), - "revoked": a.revoked, - }) - }) - .collect(); - - let acl_mode = if self.acl.is_empty() { "open" } else { "allow-list" }; - - serde_json::json!({ - "pool_name": self.pool_name, - "pool_id": self.pool_id.to_hex(), - "member_count": self.member_count(), - "content_count": self.content_count(), - "total_bytes": total_bytes, - "used_bytes": used_bytes, - "members": members, - "content_locations": content_locations, - "acl": acl, - "acl_mode": acl_mode, - }) - .to_string() - } - - // ─── Internal merge logic ─────────────────────────────────────────── - - fn merge_membership(&mut self, entry: PoolMemberEntry) -> bool { - let key = entry.node_id; - if let Some(existing) = self.members.get(&key) { - if entry.generation <= existing.generation { - return false; - } - } - self.members.insert(key, entry); - true - } - - fn merge_capacity(&mut self, entry: PoolCapacityEntry) -> bool { - let key = entry.node_id; - if let Some(existing) = self.capacity.get(&key) { - if entry.generation <= existing.generation { - return false; - } - } - self.capacity.insert(key, entry); - true - } - - fn merge_content_location(&mut self, entry: ContentLocationEntry) -> bool { - let key = (entry.content_hash, entry.node_id); - if let Some(existing) = self.content_locations.get(&key) { - if entry.generation <= existing.generation { - return false; - } - } - self.content_locations.insert(key, entry); - true - } - - fn merge_acl(&mut self, entry: PoolACLEntry) -> bool { - let key = entry.node_id; - if let Some(existing) = self.acl.get(&key) { - if entry.generation <= existing.generation { - return false; - } - } - self.acl.insert(key, entry); - true - } - - /// Merge a single pool entry and return whether state changed. - fn merge_entry(&mut self, entry: PoolEntry) -> bool { - match entry { - PoolEntry::Membership(m) => self.merge_membership(m), - PoolEntry::Capacity(c) => self.merge_capacity(c), - PoolEntry::ContentLocation(cl) => self.merge_content_location(cl), - PoolEntry::ACL(a) => self.merge_acl(a), - } - } - - /// Take pending entries (internal, typed). - fn take_pending_inner(&mut self, max_count: usize) -> Vec { - self.buffer.take(max_count) - } - - /// Apply incoming entries (internal, typed). - fn apply_incoming_inner(&mut self, entries: Vec, cluster_size: usize) { - for entry in entries { - if self.merge_entry(entry.clone()) { - self.buffer.enqueue(entry, cluster_size); - } - } - } - - /// Re-enqueue all state (internal). - fn re_disseminate_all_inner(&mut self, cluster_size: usize) { - let mut all_entries: Vec = Vec::new(); - - for m in self.members.values().cloned() { - all_entries.push(PoolEntry::Membership(m)); - } - for c in self.capacity.values().cloned() { - all_entries.push(PoolEntry::Capacity(c)); - } - for cl in self.content_locations.values().cloned() { - all_entries.push(PoolEntry::ContentLocation(cl)); - } - for a in self.acl.values().cloned() { - all_entries.push(PoolEntry::ACL(a)); - } - - self.buffer.re_enqueue_all(all_entries, cluster_size); - } - - /// GC: evict tombstones past TTL. - fn gc_tick_inner(&mut self) { - self.tick_count += 1; - if self.tick_count % self.config.gc_interval != 0 { - return; - } - - let ttl = self.config.tombstone_ttl; - let tick = self.tick_count; - - // GC left members - self.members.retain(|_, m| { - if m.state == PoolMemberState::Left { - m.generation + ttl > tick - } else { - true - } - }); - - // GC tombstoned content locations - self.content_locations.retain(|_, cl| { - if cl.tombstone { - cl.generation + ttl > tick - } else { - true - } - }); - - // GC revoked ACL entries - self.acl.retain(|_, a| { - if a.revoked { - a.generation + ttl > tick - } else { - true - } - }); - } -} - -// ─── SharedPoolChannel ───────────────────────────────────────────────────── - -/// Wrapper around `Arc>` that implements `GossipChannel`. -/// -/// This enables shared ownership between the `PoolCoordinator` actor -/// (which needs query/lifecycle access) and `DistributedNode` (which -/// drives gossip piggyback). -pub struct SharedPoolChannel { - inner: Arc>, -} - -impl SharedPoolChannel { - pub fn new(disseminator: Arc>) -> Self { - Self { inner: disseminator } - } - - /// Consume the channel and return the underlying `Arc>`. - pub fn into_inner(self) -> Arc> { - self.inner - } -} - -impl GossipChannel for SharedPoolChannel { - fn topic_tag(&self) -> &'static str { - "pool" - } - - fn take_pending_bytes(&mut self, max_entries: usize) -> Vec> { - let entries = self.inner.lock().unwrap().take_pending_inner(max_entries); - serialize_each(&entries) - } - - fn apply_incoming_bytes(&mut self, entries: &[Vec], cluster_size: usize) { - let parsed: Vec = deserialize_each(entries); - self.inner.lock().unwrap().apply_incoming_inner(parsed, cluster_size); - } - - fn re_disseminate_all(&mut self, cluster_size: usize) { - self.inner.lock().unwrap().re_disseminate_all_inner(cluster_size); - } - - fn on_node_death(&mut self, _node_id: &NodeId) { - // Pool membership is explicit (join/leave), not auto-removed on node death. - // Capacity becomes unreliable but we don't remove it. - } - - fn gc_tick(&mut self) { - self.inner.lock().unwrap().gc_tick_inner(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn node_id(b: u8) -> NodeId { - NodeId([b; 32]) - } - - fn make_disseminator(b: u8) -> PoolDisseminator { - PoolDisseminator::new( - PoolId::from_name("test-pool"), - "test-pool".into(), - node_id(b), - 3, - ) - } - - #[test] - fn join_and_query_members() { - let mut d = make_disseminator(1); - d.join(2); - - assert_eq!(d.member_count(), 1); - assert_eq!(d.active_members(), vec![node_id(1)]); - } - - #[test] - fn leave_removes_from_active() { - let mut d = make_disseminator(1); - d.join(2); - d.leave(2); - - assert_eq!(d.member_count(), 0); - assert!(d.active_members().is_empty()); - } - - #[test] - fn announce_and_locate_content() { - let mut d = make_disseminator(1); - d.join(2); - let hash = ContentHash::of(b"test-data"); - d.announce_content(hash, 2); - - let locations = d.locate_content(&hash); - assert_eq!(locations, vec![node_id(1)]); - } - - #[test] - fn remove_content_tombstones() { - let mut d = make_disseminator(1); - d.join(2); - let hash = ContentHash::of(b"test-data"); - d.announce_content(hash, 2); - d.remove_content(hash, 2); - - assert!(d.locate_content(&hash).is_empty()); - } - - #[test] - fn capacity_summary() { - let mut d = make_disseminator(1); - d.join(2); - d.announce_capacity(1000, 300, 2); - - let (total, used) = d.pool_capacity_summary(); - assert_eq!(total, 1000); - assert_eq!(used, 300); - } - - #[test] - fn acl_grant_and_check() { - let mut d = make_disseminator(1); - d.grant_access(node_id(2), 2); - - assert!(d.is_node_authorized(&node_id(2))); - assert!(!d.is_node_authorized(&node_id(3))); - } - - #[test] - fn acl_revoke() { - let mut d = make_disseminator(1); - d.grant_access(node_id(2), 2); - d.revoke_access(node_id(2), 2); - - assert!(!d.is_node_authorized(&node_id(2))); - } - - #[test] - fn empty_acl_means_open() { - let d = make_disseminator(1); - assert!(d.is_node_authorized(&node_id(99))); - } - - #[test] - fn higher_generation_wins_merge() { - let mut d1 = make_disseminator(1); - let mut d2 = make_disseminator(2); - - // d1 joins - d1.join(2); - // d1 leaves - d1.leave(2); - - // Gossip d1's entries to d2 out of order: - // First send the Active (gen 1), then the Left (gen 2) - let active_entry = PoolEntry::Membership(PoolMemberEntry { - pool_id: PoolId::from_name("test-pool"), - node_id: [1u8; 32], - state: PoolMemberState::Active, - generation: 1, - }); - let left_entry = PoolEntry::Membership(PoolMemberEntry { - pool_id: PoolId::from_name("test-pool"), - node_id: [1u8; 32], - state: PoolMemberState::Left, - generation: 2, - }); - - // Apply Left first (gen 2), then Active (gen 1) — Active should be rejected - d2.merge_entry(left_entry); - let changed = d2.merge_entry(active_entry); - assert!(!changed, "lower generation should not win"); - - // d2 should see node_id(1) as Left - assert_eq!(d2.member_count(), 0); // Active count is 0 - } - - #[test] - fn two_disseminators_converge_via_gossip_exchange() { - let mut d1 = make_disseminator(1); - let mut d2 = make_disseminator(2); - - // d1 joins and announces content - d1.join(2); - let hash = ContentHash::of(b"shared-file"); - d1.announce_content(hash, 2); - - // d2 joins - d2.join(2); - - // Simulate gossip: d1 → d2 - let pending = d1.take_pending_inner(100); - let bytes = serialize_each(&pending); - let parsed: Vec = deserialize_each(&bytes); - d2.apply_incoming_inner(parsed, 2); - - // d2 should now see d1 as a member and know about the content - assert_eq!(d2.member_count(), 2); - assert_eq!(d2.locate_content(&hash), vec![node_id(1)]); - - // Simulate gossip: d2 → d1 - let pending = d2.take_pending_inner(100); - let bytes = serialize_each(&pending); - let parsed: Vec = deserialize_each(&bytes); - d1.apply_incoming_inner(parsed, 2); - - // d1 should now see d2 as a member - assert_eq!(d1.member_count(), 2); - } - - #[test] - fn three_node_convergence_loop() { - let pool = PoolId::from_name("test-pool"); - let mut nodes: Vec = (0..3) - .map(|i| PoolDisseminator::new(pool, "test-pool".into(), node_id(i as u8), 3)) - .collect(); - - // Each node joins - for n in &mut nodes { - n.join(3); - } - - // Node 0 announces content - let hash = ContentHash::of(b"convergence-test"); - nodes[0].announce_content(hash, 3); - - // Run 5 gossip rounds where each node exchanges with all others - for _ in 0..5 { - // Collect pending from each node - let pending_bytes: Vec>> = nodes - .iter_mut() - .map(|n| serialize_each(&n.take_pending_inner(100))) - .collect(); - - // Apply each node's pending to all other nodes - for (sender_idx, bytes) in pending_bytes.iter().enumerate() { - for (receiver_idx, node) in nodes.iter_mut().enumerate() { - if sender_idx != receiver_idx { - let parsed: Vec = deserialize_each(bytes); - node.apply_incoming_inner(parsed, 3); - } - } - } - } - - // All nodes should agree on membership and content locations - for (i, node) in nodes.iter().enumerate() { - assert_eq!(node.member_count(), 3, "node {i} should see 3 members"); - assert_eq!( - node.locate_content(&hash), - vec![node_id(0)], - "node {i} should know content is on node 0" - ); - } - } -} diff --git a/crates/datastore/src/pool/messages.rs b/crates/datastore/src/pool/messages.rs deleted file mode 100644 index de3fd7e..0000000 --- a/crates/datastore/src/pool/messages.rs +++ /dev/null @@ -1,79 +0,0 @@ -//! Messages for the `PoolCoordinator` actor. - -use std::collections::BTreeMap; - -use distribution::types::NodeId; -use shared_types::ContentHash; - -/// Messages handled by the `PoolCoordinator` actor. -#[derive(Debug, Clone)] -pub enum PoolCoordinatorMsg { - // ── User-facing operations ────────────────────────────────────────── - /// Store data in the pool (placement-aware). - PoolPut { - data: Vec, - name: Option, - tags: BTreeMap, - reply_to: swactor::actor::ActorAddress, - }, - /// Retrieve data from the pool (location-aware). - PoolGet { - content_hash: ContentHash, - reply_to: swactor::actor::ActorAddress, - }, - /// Delete data from the pool. - PoolDelete { - content_hash: ContentHash, - reply_to: swactor::actor::ActorAddress, - }, - /// List objects in the pool. - PoolList { - name_filter: Option, - reply_to: swactor::actor::ActorAddress, - }, - /// Pool status (members, capacity, content count). - PoolStatus { - reply_to: swactor::actor::ActorAddress, - }, - - // ── Pool lifecycle ────────────────────────────────────────────────── - /// Join the pool. - JoinPool { - reply_to: swactor::actor::ActorAddress, - }, - /// Leave the pool. - LeavePool { - reply_to: swactor::actor::ActorAddress, - }, - - // ── Auth management ───────────────────────────────────────────────── - /// Grant a node access to the pool. - GrantPoolAccess { - target: NodeId, - reply_to: swactor::actor::ActorAddress, - }, - /// Revoke a node's access to the pool. - RevokePoolAccess { - target: NodeId, - reply_to: swactor::actor::ActorAddress, - }, - - // ── Periodic ──────────────────────────────────────────────────────── - /// Periodic tick: announce capacity, drive dissemination. - PoolTick, -} - -/// Pool-specific response variants. -#[derive(Debug, Clone)] -pub enum PoolResponse { - /// Pool status snapshot. - PoolStatus { - pool_name: String, - pool_id_hex: String, - member_count: usize, - content_count: usize, - total_bytes: u64, - used_bytes: u64, - members: Vec, - }, -} diff --git a/crates/datastore/src/pool/mod.rs b/crates/datastore/src/pool/mod.rs deleted file mode 100644 index e9d8ae3..0000000 --- a/crates/datastore/src/pool/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Pooled datastore protocol. -//! -//! A shared storage pool where multiple nodes contribute storage capacity -//! and converge on a shared view of what content lives where. - -pub mod disseminator; -pub mod messages; -pub mod coordinator; diff --git a/crates/datastore/src/storage/mod.rs b/crates/datastore/src/storage/mod.rs index ca27b63..3a55359 100644 --- a/crates/datastore/src/storage/mod.rs +++ b/crates/datastore/src/storage/mod.rs @@ -98,11 +98,10 @@ impl FilesystemBackend { continue; }; for file in files.flatten() { - if let Some(name) = file.file_name().to_str() { - if let Some(hash) = ContentHash::from_hex(name) { + if let Some(name) = file.file_name().to_str() + && let Some(hash) = ContentHash::from_hex(name) { self.chunk_index.insert(hash); } - } } } } diff --git a/crates/streams/src/accept.rs b/crates/datastore/src/streams/accept.rs similarity index 96% rename from crates/streams/src/accept.rs rename to crates/datastore/src/streams/accept.rs index 93ab644..35b62f3 100644 --- a/crates/streams/src/accept.rs +++ b/crates/datastore/src/streams/accept.rs @@ -5,8 +5,8 @@ use iroh::endpoint::Connection; use swactor::actor::ActorAddress; use swactor::runtime::Runtime; -use crate::messages::{OneShot, StreamManagerMsg}; -use crate::wire; +use crate::streams::messages::{OneShot, StreamManagerMsg}; +use crate::streams::wire; /// Spawn a bridge task that processes incoming stream connections and /// forwards them to the StreamManager actor. diff --git a/crates/streams/src/buffer.rs b/crates/datastore/src/streams/buffer.rs similarity index 100% rename from crates/streams/src/buffer.rs rename to crates/datastore/src/streams/buffer.rs diff --git a/crates/streams/src/channel.rs b/crates/datastore/src/streams/channel.rs similarity index 93% rename from crates/streams/src/channel.rs rename to crates/datastore/src/streams/channel.rs index fcdee38..744515b 100644 --- a/crates/streams/src/channel.rs +++ b/crates/datastore/src/streams/channel.rs @@ -1,5 +1,5 @@ -use crate::buffer::FrameBuf; -use crate::types::StreamError; +use crate::streams::buffer::FrameBuf; +use crate::streams::types::StreamError; /// Commands sent from the actor to the send-side data-plane task. pub enum SendCommand { diff --git a/crates/streams/src/connection.rs b/crates/datastore/src/streams/connection.rs similarity index 91% rename from crates/streams/src/connection.rs rename to crates/datastore/src/streams/connection.rs index 36ec1f6..b5cd974 100644 --- a/crates/streams/src/connection.rs +++ b/crates/datastore/src/streams/connection.rs @@ -3,8 +3,8 @@ use std::collections::HashMap; use iroh::endpoint::Connection; use iroh::{Endpoint, PublicKey}; -use crate::types::StreamError; -use crate::wire::ALPN; +use crate::streams::types::StreamError; +use crate::streams::wire::ALPN; /// Cache of QUIC connections used for stream data transfer. /// @@ -14,6 +14,12 @@ pub struct StreamConnectionCache { connections: HashMap<[u8; 32], Connection>, } +impl Default for StreamConnectionCache { + fn default() -> Self { + Self::new() + } +} + impl StreamConnectionCache { pub fn new() -> Self { StreamConnectionCache { diff --git a/crates/streams/src/ctx_ext.rs b/crates/datastore/src/streams/ctx_ext.rs similarity index 87% rename from crates/streams/src/ctx_ext.rs rename to crates/datastore/src/streams/ctx_ext.rs index f68f072..06e870e 100644 --- a/crates/streams/src/ctx_ext.rs +++ b/crates/datastore/src/streams/ctx_ext.rs @@ -8,11 +8,11 @@ use swactor::actor::{ActorAddress, Ctx}; use swactor::runtime::Runtime; -use swactor_std::CtxNaming; -use swactor_std::RuntimeNaming; +use swactor::std::CtxNaming; +use swactor::std::RuntimeNaming; -use crate::messages::StreamManagerMsg; -use crate::types::{StreamConfig, StreamError, StreamId, StreamMode}; +use crate::streams::messages::StreamManagerMsg; +use crate::streams::types::{StreamConfig, StreamError, StreamId, StreamMode}; fn mgr_not_found() -> StreamError { StreamError::BrokenPipe("StreamManager not found in name registry".into()) @@ -51,7 +51,7 @@ impl CtxStreams for Ctx<'_> { config: StreamConfig, ) -> Result<(), StreamError> { let mgr = self - .where_is(crate::manager::STREAM_MANAGER_NAME) + .where_is(crate::streams::manager::STREAM_MANAGER_NAME) .ok_or_else(mgr_not_found)?; self.send( mgr, @@ -67,7 +67,7 @@ impl CtxStreams for Ctx<'_> { fn stream_listen(&self, mode: StreamMode) -> Result<(), StreamError> { let mgr = self - .where_is(crate::manager::STREAM_MANAGER_NAME) + .where_is(crate::streams::manager::STREAM_MANAGER_NAME) .ok_or_else(mgr_not_found)?; self.send( mgr, @@ -81,7 +81,7 @@ impl CtxStreams for Ctx<'_> { fn stream_accept(&self, stream_id: StreamId) -> Result<(), StreamError> { let mgr = self - .where_is(crate::manager::STREAM_MANAGER_NAME) + .where_is(crate::streams::manager::STREAM_MANAGER_NAME) .ok_or_else(mgr_not_found)?; self.send( mgr, @@ -95,7 +95,7 @@ impl CtxStreams for Ctx<'_> { fn stream_reject(&self, stream_id: StreamId) -> Result<(), StreamError> { let mgr = self - .where_is(crate::manager::STREAM_MANAGER_NAME) + .where_is(crate::streams::manager::STREAM_MANAGER_NAME) .ok_or_else(mgr_not_found)?; self.send(mgr, StreamManagerMsg::Reject { stream_id }) .map_err(|e| StreamError::BrokenPipe(e.to_string())) @@ -103,7 +103,7 @@ impl CtxStreams for Ctx<'_> { fn stream_close(&self, stream_id: StreamId) -> Result<(), StreamError> { let mgr = self - .where_is(crate::manager::STREAM_MANAGER_NAME) + .where_is(crate::streams::manager::STREAM_MANAGER_NAME) .ok_or_else(mgr_not_found)?; self.send(mgr, StreamManagerMsg::Close { stream_id }) .map_err(|e| StreamError::BrokenPipe(e.to_string())) @@ -149,7 +149,7 @@ impl RuntimeStreams for Runtime { reply_to: ActorAddress, ) -> Result<(), StreamError> { let mgr = self - .where_is(crate::manager::STREAM_MANAGER_NAME) + .where_is(crate::streams::manager::STREAM_MANAGER_NAME) .ok_or_else(mgr_not_found)?; self.send_to( mgr, @@ -165,7 +165,7 @@ impl RuntimeStreams for Runtime { fn stream_listen(&self, mode: StreamMode, listener: ActorAddress) -> Result<(), StreamError> { let mgr = self - .where_is(crate::manager::STREAM_MANAGER_NAME) + .where_is(crate::streams::manager::STREAM_MANAGER_NAME) .ok_or_else(mgr_not_found)?; self.send_to(mgr, StreamManagerMsg::Listen { mode, listener }) .map_err(|e| StreamError::BrokenPipe(e.to_string())) @@ -177,7 +177,7 @@ impl RuntimeStreams for Runtime { reply_to: ActorAddress, ) -> Result<(), StreamError> { let mgr = self - .where_is(crate::manager::STREAM_MANAGER_NAME) + .where_is(crate::streams::manager::STREAM_MANAGER_NAME) .ok_or_else(mgr_not_found)?; self.send_to(mgr, StreamManagerMsg::Accept { stream_id, reply_to }) .map_err(|e| StreamError::BrokenPipe(e.to_string())) @@ -185,7 +185,7 @@ impl RuntimeStreams for Runtime { fn stream_reject(&self, stream_id: StreamId) -> Result<(), StreamError> { let mgr = self - .where_is(crate::manager::STREAM_MANAGER_NAME) + .where_is(crate::streams::manager::STREAM_MANAGER_NAME) .ok_or_else(mgr_not_found)?; self.send_to(mgr, StreamManagerMsg::Reject { stream_id }) .map_err(|e| StreamError::BrokenPipe(e.to_string())) @@ -193,7 +193,7 @@ impl RuntimeStreams for Runtime { fn stream_close(&self, stream_id: StreamId) -> Result<(), StreamError> { let mgr = self - .where_is(crate::manager::STREAM_MANAGER_NAME) + .where_is(crate::streams::manager::STREAM_MANAGER_NAME) .ok_or_else(mgr_not_found)?; self.send_to(mgr, StreamManagerMsg::Close { stream_id }) .map_err(|e| StreamError::BrokenPipe(e.to_string())) diff --git a/crates/streams/src/data_plane.rs b/crates/datastore/src/streams/data_plane.rs similarity index 97% rename from crates/streams/src/data_plane.rs rename to crates/datastore/src/streams/data_plane.rs index 437669f..8c954c9 100644 --- a/crates/streams/src/data_plane.rs +++ b/crates/datastore/src/streams/data_plane.rs @@ -1,11 +1,11 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::mpsc; -use crate::buffer::BufferPool; -use crate::channel::{RecvEvent, SendCommand}; -use crate::notify::NotifySink; -use crate::types::StreamError; -use crate::wire; +use crate::streams::buffer::BufferPool; +use crate::streams::channel::{RecvEvent, SendCommand}; +use crate::streams::notify::NotifySink; +use crate::streams::types::StreamError; +use crate::streams::wire; /// A send-side data-plane task for a single stripe. /// @@ -215,8 +215,8 @@ where #[cfg(test)] mod tests { use super::*; - use crate::buffer::BufferPool; - use crate::types::StreamId; + use crate::streams::buffer::BufferPool; + use crate::streams::types::StreamId; fn make_test_pool(count: usize, capacity: usize) -> BufferPool { BufferPool::new(count, capacity) @@ -425,7 +425,7 @@ mod tests { /// Notification coalescing through NotifySink. #[tokio::test] async fn notification_coalescing() { - use crate::notify::*; + use crate::streams::notify::*; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; @@ -459,8 +459,8 @@ mod tests { /// Backpressure: when channel and active buffer are saturated, try_write returns 0. #[tokio::test] async fn send_backpressure() { - use crate::handle::create_stream_handle; - use crate::types::StreamConfig; + use crate::streams::handle::create_stream_handle; + use crate::streams::types::StreamConfig; let config = StreamConfig { stripe_count: 1, diff --git a/crates/streams/src/handle.rs b/crates/datastore/src/streams/handle.rs similarity index 95% rename from crates/streams/src/handle.rs rename to crates/datastore/src/streams/handle.rs index 17caef3..db5b4aa 100644 --- a/crates/streams/src/handle.rs +++ b/crates/datastore/src/streams/handle.rs @@ -1,8 +1,8 @@ use tokio::sync::mpsc; -use crate::buffer::{BufferPool, FrameBuf}; -use crate::channel::{RecvCommand, RecvEvent, SendCommand, SendEvent}; -use crate::types::{StreamConfig, StreamError, StreamId}; +use crate::streams::buffer::{BufferPool, FrameBuf}; +use crate::streams::channel::{RecvCommand, RecvEvent, SendCommand, SendEvent}; +use crate::streams::types::{StreamConfig, StreamError, StreamId}; /// Sending half of a stream. Owned by the actor that sends data. /// @@ -67,7 +67,7 @@ impl SendHalf { /// Flush any partially-filled buffer to the data-plane task. pub fn flush(&mut self) -> Result<(), StreamError> { if let Some(buf) = self.active_buf.take() { - if buf.remaining() > 0 || buf.written().len() > 0 { + if buf.remaining() > 0 || !buf.written().is_empty() { self.cmd_tx .try_send(SendCommand::Data(buf)) .map_err(|_| StreamError::Disconnected)?; @@ -84,7 +84,7 @@ impl SendHalf { /// Close the send side of the stream. pub fn close(&mut self) -> Result<(), StreamError> { if let Some(buf) = self.active_buf.take() { - if buf.written().len() > 0 { + if !buf.written().is_empty() { let _ = self.cmd_tx.try_send(SendCommand::Data(buf)); } else { self.pool.checkin(buf); @@ -160,11 +160,10 @@ impl RecvHalf { /// Check if data is available without consuming it. pub fn has_data(&self) -> bool { - if let Some(buf) = &self.active_buf { - if buf.remaining() > 0 { + if let Some(buf) = &self.active_buf + && buf.remaining() > 0 { return true; } - } !self.evt_rx.is_empty() } diff --git a/crates/streams/src/manager.rs b/crates/datastore/src/streams/manager.rs similarity index 94% rename from crates/streams/src/manager.rs rename to crates/datastore/src/streams/manager.rs index 04b834d..18d8d3b 100644 --- a/crates/streams/src/manager.rs +++ b/crates/datastore/src/streams/manager.rs @@ -7,12 +7,12 @@ use tokio::io::AsyncWriteExt; use swactor::actor::{ActorAddress, ActorInterface, Ctx, Down}; use swactor::runtime::Runtime; -use crate::connection::StreamConnectionCache; -use crate::data_plane; -use crate::handle::{create_stream_handle, StreamHandle}; -use crate::messages::{OneShot, StreamManagerMsg, StreamNotification}; -use crate::types::{StreamConfig, StreamError, StreamId, StreamMode}; -use crate::wire; +use crate::streams::connection::StreamConnectionCache; +use crate::streams::data_plane; +use crate::streams::handle::{create_stream_handle, StreamHandle}; +use crate::streams::messages::{OneShot, StreamManagerMsg, StreamNotification}; +use crate::streams::types::{StreamConfig, StreamError, StreamId, StreamMode}; +use crate::streams::wire; /// Well-known name for the StreamManager actor in the name registry. pub const STREAM_MANAGER_NAME: &str = "StreamManager"; @@ -250,7 +250,7 @@ impl StreamManager { fn handle_listen(&mut self, mode: StreamMode, listener: ActorAddress) { self.listeners .entry(mode) - .or_insert_with(Vec::new) + .or_default() .push(listener); } @@ -398,7 +398,7 @@ async fn open_stream_async( Ok(send_stream) => writers.push(send_stream), Err(e) => { let _ = evt_tx - .send(crate::channel::SendEvent::Error(StreamError::BrokenPipe( + .send(crate::streams::channel::SendEvent::Error(StreamError::BrokenPipe( format!("open_uni failed: {e}"), ))) .await; @@ -411,13 +411,13 @@ async fn open_stream_async( let mut stripe_idx = 0; while let Some(cmd) = cmd_rx.recv().await { match cmd { - crate::channel::SendCommand::Data(buf) => { + crate::streams::channel::SendCommand::Data(buf) => { let frame = wire::encode_data_frame(buf.written()); let writer = &mut writers[stripe_idx]; if let Err(e) = writer.write_all(&frame).await { pool.checkin(buf); let _ = evt_tx - .send(crate::channel::SendEvent::Error( + .send(crate::streams::channel::SendEvent::Error( StreamError::BrokenPipe(e.to_string()), )) .await; @@ -426,12 +426,12 @@ async fn open_stream_async( pool.checkin(buf); stripe_idx = (stripe_idx + 1) % sc; } - crate::channel::SendCommand::Flush => { + crate::streams::channel::SendCommand::Flush => { for writer in &mut writers { let _ = writer.flush().await; } } - crate::channel::SendCommand::Close => { + crate::streams::channel::SendCommand::Close => { let sentinel = wire::encode_end_of_stripe(); for writer in &mut writers { let _ = writer.write_all(&sentinel).await; @@ -532,7 +532,7 @@ async fn accept_stream_async( Ok(send_stream) => writers.push(send_stream), Err(e) => { let _ = evt_tx - .send(crate::channel::SendEvent::Error(StreamError::BrokenPipe( + .send(crate::streams::channel::SendEvent::Error(StreamError::BrokenPipe( format!("open_uni failed: {e}"), ))) .await; @@ -544,13 +544,13 @@ async fn accept_stream_async( let mut stripe_idx = 0; while let Some(cmd) = cmd_rx.recv().await { match cmd { - crate::channel::SendCommand::Data(buf) => { + crate::streams::channel::SendCommand::Data(buf) => { let frame = wire::encode_data_frame(buf.written()); let writer = &mut writers[stripe_idx]; if let Err(e) = writer.write_all(&frame).await { pool.checkin(buf); let _ = evt_tx - .send(crate::channel::SendEvent::Error( + .send(crate::streams::channel::SendEvent::Error( StreamError::BrokenPipe(e.to_string()), )) .await; @@ -559,12 +559,12 @@ async fn accept_stream_async( pool.checkin(buf); stripe_idx = (stripe_idx + 1) % sc; } - crate::channel::SendCommand::Flush => { + crate::streams::channel::SendCommand::Flush => { for writer in &mut writers { let _ = writer.flush().await; } } - crate::channel::SendCommand::Close => { + crate::streams::channel::SendCommand::Close => { let sentinel = wire::encode_end_of_stripe(); for writer in &mut writers { let _ = writer.write_all(&sentinel).await; diff --git a/crates/streams/src/messages.rs b/crates/datastore/src/streams/messages.rs similarity index 97% rename from crates/streams/src/messages.rs rename to crates/datastore/src/streams/messages.rs index 907ae21..09f8a13 100644 --- a/crates/streams/src/messages.rs +++ b/crates/datastore/src/streams/messages.rs @@ -4,8 +4,8 @@ use std::sync::{Arc, Mutex}; use iroh::endpoint::Connection; use swactor::actor::ActorAddress; -use crate::handle::StreamHandle; -use crate::types::{StreamConfig, StreamError, StreamId, StreamMode}; +use crate::streams::handle::StreamHandle; +use crate::streams::types::{StreamConfig, StreamError, StreamId, StreamMode}; // ─── OneShot ──────────────────────────────────────────────────────────── diff --git a/crates/streams/src/lib.rs b/crates/datastore/src/streams/mod.rs similarity index 100% rename from crates/streams/src/lib.rs rename to crates/datastore/src/streams/mod.rs diff --git a/crates/streams/src/notify.rs b/crates/datastore/src/streams/notify.rs similarity index 96% rename from crates/streams/src/notify.rs rename to crates/datastore/src/streams/notify.rs index bd20a07..de47484 100644 --- a/crates/streams/src/notify.rs +++ b/crates/datastore/src/streams/notify.rs @@ -65,7 +65,7 @@ pub enum StreamEventKind { /// A lightweight notification message injected into an actor's mailbox. #[derive(Debug, Clone)] pub struct StreamEvent { - pub stream_id: crate::types::StreamId, + pub stream_id: crate::streams::types::StreamId, pub kind: StreamEventKind, } @@ -77,13 +77,13 @@ pub struct StreamEvent { pub struct NotifySink { flag: Arc, inject: Box, - stream_id: crate::types::StreamId, + stream_id: crate::streams::types::StreamId, } impl NotifySink { pub fn new( flag: Arc, - stream_id: crate::types::StreamId, + stream_id: crate::streams::types::StreamId, inject: impl Fn(StreamEvent) + Send + Sync + 'static, ) -> Self { NotifySink { diff --git a/crates/streams/src/types.rs b/crates/datastore/src/streams/types.rs similarity index 100% rename from crates/streams/src/types.rs rename to crates/datastore/src/streams/types.rs diff --git a/crates/streams/src/wire.rs b/crates/datastore/src/streams/wire.rs similarity index 99% rename from crates/streams/src/wire.rs rename to crates/datastore/src/streams/wire.rs index ff84c7b..6f33ea0 100644 --- a/crates/streams/src/wire.rs +++ b/crates/datastore/src/streams/wire.rs @@ -1,4 +1,4 @@ -use crate::types::{StreamConfig, StreamError, StreamId, StreamMode}; +use crate::streams::types::{StreamConfig, StreamError, StreamId, StreamMode}; /// Magic bytes identifying the swactor stream protocol. pub const MAGIC: [u8; 2] = [0x53, 0x57]; diff --git a/crates/datastore/src/types.rs b/crates/datastore/src/types.rs index 6a3f66f..d41c12b 100644 --- a/crates/datastore/src/types.rs +++ b/crates/datastore/src/types.rs @@ -8,10 +8,9 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; -use distribution::types::NodeId; +use swactor::transport::NodeId; -// Re-export the shared ContentHash — single canonical type across the crate ecosystem. -pub use shared_types::ContentHash; +pub use crate::content_hash::ContentHash; // ─── ObjectEntry ──────────────────────────────────────────────────────────── diff --git a/crates/datastore/tests/acl_persistence_tests.rs b/crates/datastore/tests/acl_persistence_tests.rs index 574cab6..21f48f2 100644 --- a/crates/datastore/tests/acl_persistence_tests.rs +++ b/crates/datastore/tests/acl_persistence_tests.rs @@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet}; -use distribution::crypto::Keypair; +use swactor_datastore::crypto::Keypair; use swactor_datastore::auth::AccessControlList; // ═══════════════════════════════════════════════════════════════════════════ diff --git a/crates/datastore/tests/auth_scenario_tests.rs b/crates/datastore/tests/auth_scenario_tests.rs index d3250cd..d63a16a 100644 --- a/crates/datastore/tests/auth_scenario_tests.rs +++ b/crates/datastore/tests/auth_scenario_tests.rs @@ -2,8 +2,8 @@ use std::collections::{HashMap, HashSet}; -use distribution::crypto::Keypair; -use shared_types::ContentHash; +use swactor_datastore::crypto::Keypair; +use swactor_datastore::content_hash::ContentHash; use swactor_datastore::auth::{ sign_request, AccessControlList, AuthzEngine, AuthzResult, DatastoreAction, DeniedReason, SignedRequestPayload, diff --git a/crates/datastore/tests/auth_state_machine.rs b/crates/datastore/tests/auth_state_machine.rs index e090e94..1d0eba9 100644 --- a/crates/datastore/tests/auth_state_machine.rs +++ b/crates/datastore/tests/auth_state_machine.rs @@ -18,7 +18,7 @@ use std::collections::{HashMap, HashSet}; use proptest::prelude::*; use proptest_state_machine::{prop_state_machine, ReferenceStateMachine, StateMachineTest}; -use distribution::crypto::Keypair; +use swactor_datastore::crypto::Keypair; use swactor_datastore::auth::{ sign_request, AccessControlList, AuthzEngine, AuthzResult, DatastoreAction, DeniedReason, SignedRequestPayload, diff --git a/crates/datastore/tests/common/mod.rs b/crates/datastore/tests/common/mod.rs index 0c7331a..4a6ea4d 100644 --- a/crates/datastore/tests/common/mod.rs +++ b/crates/datastore/tests/common/mod.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use swactor::actor::{ActorAddress, Message}; use swactor::runtime::{Inbox, Runtime, RuntimeConfig}; -use swactor_std::StdExtension; +use swactor::std::StdExtension; use swactor_datastore::chunking::chunk_blob; use swactor_datastore::messages::{BlobStoreMsg, DatastoreResponse, MetadataMsg}; @@ -15,7 +15,7 @@ use swactor_datastore::storage::InMemoryBackend; use swactor_datastore::types::{ChunkRef, ContentHash, DatastoreConfig, ObjectEntry, ObjectManifest}; use swactor_datastore::{BlobStoreActor, DatastoreNode, MetadataActor, TransferActor}; -use distribution::types::NodeId; +use swactor::transport::NodeId; /// Create a single-threaded runtime with StdExtension. pub fn test_runtime() -> Runtime { diff --git a/crates/datastore/tests/dashboard_integration_test.rs b/crates/datastore/tests/dashboard_integration_test.rs deleted file mode 100644 index 8d89d8e..0000000 --- a/crates/datastore/tests/dashboard_integration_test.rs +++ /dev/null @@ -1,205 +0,0 @@ -//! End-to-end integration test: spins up a datastore node with an HTTP API -//! **and** a runtime dashboard, performs CRUD over HTTP, then verifies: -//! -//! - `DatastoreMetrics::snapshot()` reflects the operations -//! - Dashboard `/datastore` serves HTML -//! - Dashboard `/api/datastore` returns a JSON snapshot matching the metrics - -use std::sync::Arc; -use std::thread; -use std::time::Duration; - -use swactor::config::RuntimeConfig; -use swactor::runtime::Runtime; - -use swactor_datastore::actors::{BlobStoreActor, DatastoreNode, MetadataActor}; -use swactor_datastore::api::start_api_server; -use swactor_datastore::metrics::DatastoreMetrics; -use swactor_datastore::storage::InMemoryBackend; -use swactor_datastore::DatastoreConfig; - -use distribution::types::NodeId; - -fn find_free_port() -> u16 { - std::net::TcpListener::bind("127.0.0.1:0") - .unwrap() - .local_addr() - .unwrap() - .port() -} - -/// Scenario: datastore CRUD → dashboard reflects live operation metrics. -/// -/// Story: -/// Two files are uploaded. One is fetched (metadata + data). The other -/// is deleted. Afterwards we check that the metrics snapshot, the -/// dashboard HTML page, and the dashboard JSON API all agree on what -/// happened. -#[test] -fn dashboard_reflects_datastore_operations() { - let api_port = find_free_port(); - let dash_port = find_free_port(); - let api_base = format!("http://127.0.0.1:{api_port}"); - let dash_base = format!("http://127.0.0.1:{dash_port}"); - - // ── Infrastructure: runtime + actors + dashboard + API ────────────── - - let dash = dashboard::start_dashboard(dashboard::DashboardConfig { - port: dash_port, - ..Default::default() - }); - dash.start_http_standalone(); - - let collector = dashboard::collector::StatsCollector::new(2); - let mut rt = Runtime::new(RuntimeConfig { - num_threads: 2, - max_actors: 1024, - channel_buffer_size: 2000, - ..Default::default() - }); - rt.set_stats_hook(collector.clone()); - - let node_id = NodeId([0xBB; 32]); - let config = DatastoreConfig { - chunk_size: 1_048_576, - gc_interval: 1000, - ..Default::default() - }; - - let backend: Box = Box::new(InMemoryBackend::new()); - let blob_store_addr = rt.spawn(BlobStoreActor::new(backend)).unwrap(); - - let mut metadata = MetadataActor::new(node_id, &config); - metadata.set_blob_store(blob_store_addr); - let metadata_addr = rt.spawn(metadata).unwrap(); - - let datastore_node = DatastoreNode::new(node_id, blob_store_addr, metadata_addr, config); - let datastore_addr = rt.spawn(datastore_node).unwrap(); - - let handle = rt.run().expect("failed to start runtime"); - - let metrics = Arc::new(DatastoreMetrics::new()); - let node_hex: String = node_id.0.iter().map(|b| format!("{b:02x}")).collect(); - metrics.set_node_id(node_hex); - - dash.set_runtime(handle.runtime.clone(), collector); - dash.set_datastore( - Arc::clone(&metrics) - as Arc, - ); - - let (api_shutdown, _peers) = start_api_server( - handle.runtime.clone(), - datastore_addr, - metadata_addr, - blob_store_addr, - None, - api_port, - Arc::clone(&metrics), - ); - - thread::sleep(Duration::from_millis(300)); - - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - run_dashboard_scenario(&api_base, &dash_base, &metrics); - })); - - // Cleanup - api_shutdown.store(true, std::sync::atomic::Ordering::Relaxed); - dash.shutdown(); - handle.shutdown(); - handle.join(); - - if let Err(e) = result { - std::panic::resume_unwind(e); - } -} - -fn run_dashboard_scenario(api: &str, dash: &str, metrics: &Arc) { - // ── 1. Upload two objects ─────────────────────────────────────────── - - let put_a: serde_json::Value = ureq::post(&format!("{api}/api/put?name=alpha")) - .send_bytes(b"payload-alpha") - .expect("put A failed") - .into_json() - .unwrap(); - let hash_a = put_a["content_hash"].as_str().unwrap().to_string(); - - let _put_b: serde_json::Value = ureq::post(&format!("{api}/api/put?name=bravo")) - .send_bytes(b"payload-bravo") - .expect("put B failed") - .into_json() - .unwrap(); - let hash_b = _put_b["content_hash"].as_str().unwrap().to_string(); - - // ── 2. GET object A (metadata + raw data → 2 get ops) ────────────── - - let _: serde_json::Value = ureq::get(&format!("{api}/api/get?hash={hash_a}")) - .call() - .unwrap() - .into_json() - .unwrap(); - - let data_resp = ureq::get(&format!("{api}/api/data?hash={hash_a}")) - .call() - .unwrap(); - let mut body = Vec::new(); - data_resp.into_reader().read_to_end(&mut body).unwrap(); - assert_eq!(body, b"payload-alpha", "downloaded data should match"); - - // ── 3. DELETE object B ────────────────────────────────────────────── - - let _: serde_json::Value = ureq::post(&format!("{api}/api/delete?hash={hash_b}")) - .call() - .unwrap() - .into_json() - .unwrap(); - - // ── 4. Assert: in-process metrics snapshot ────────────────────────── - - let snap = metrics.snapshot(); - - assert_eq!(snap.put_ops, 2, "two puts recorded"); - assert_eq!(snap.get_ops, 2, "metadata-get + data-get recorded"); - assert_eq!(snap.delete_ops, 1, "one delete recorded"); - assert_eq!(snap.objects.len(), 1, "only alpha remains after deleting bravo"); - assert_eq!(snap.objects[0].hash, hash_a); - assert!( - snap.recent_events.len() >= 5, - "at least 5 events (2 put + 2 get + 1 delete), got {}", - snap.recent_events.len() - ); - - // ── 5. Assert: dashboard /datastore serves HTML ───────────────────── - - let page = ureq::get(&format!("{dash}/datastore")).call().unwrap(); - assert_eq!(page.status(), 200); - assert!( - page.header("Content-Type") - .unwrap_or("") - .contains("text/html"), - ); - let html = page.into_string().unwrap(); - assert!(html.contains("Datastore"), "page should mention Datastore"); - - // ── 6. Assert: /api/datastore JSON matches metrics ────────────────── - - let ds: serde_json::Value = ureq::get(&format!("{dash}/api/datastore")) - .call() - .unwrap() - .into_json() - .unwrap(); - - assert_eq!(ds["put_ops"].as_u64().unwrap(), 2); - assert_eq!(ds["get_ops"].as_u64().unwrap(), 2); - assert_eq!(ds["delete_ops"].as_u64().unwrap(), 1); - - let objects = ds["objects"].as_array().expect("objects should be array"); - assert_eq!(objects.len(), 1); - assert_eq!(objects[0]["hash"].as_str().unwrap(), hash_a); - - let events = ds["recent_events"] - .as_array() - .expect("recent_events should be array"); - assert!(events.len() >= 5); -} diff --git a/crates/datastore/tests/datastore_node_tests.rs b/crates/datastore/tests/datastore_node_tests.rs index 01d75ae..0b7c73a 100644 --- a/crates/datastore/tests/datastore_node_tests.rs +++ b/crates/datastore/tests/datastore_node_tests.rs @@ -14,7 +14,7 @@ use swactor_datastore::types::ContentHash; use swactor_datastore::messages::{DatastoreResponse, GetChunkRequest}; use swactor_datastore::{reassemble_blob, verify_integrity}; -use distribution::types::NodeId; +use swactor::transport::NodeId; // ═══════════════════════════════════════════════════════════════════════════ // Put & Retrieve diff --git a/crates/datastore/tests/datastore_tests.rs b/crates/datastore/tests/datastore_tests.rs index cb87f66..02d4160 100644 --- a/crates/datastore/tests/datastore_tests.rs +++ b/crates/datastore/tests/datastore_tests.rs @@ -9,7 +9,7 @@ use swactor_datastore::types::{ ChunkRef, ContentHash, DatastoreConfig, ObjectEntry, ObjectManifest, }; -use distribution::types::NodeId; +use swactor::transport::NodeId; // ═══════════════════════════════════════════════════════════════════════════ // Scenario: Content-addressing round-trip diff --git a/crates/datastore/tests/gateway_tests.rs b/crates/datastore/tests/gateway_tests.rs index 881f87b..b246a6a 100644 --- a/crates/datastore/tests/gateway_tests.rs +++ b/crates/datastore/tests/gateway_tests.rs @@ -10,8 +10,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use common::{spawn_blob_store, spawn_metadata, test_runtime, tick_until_recv}; -use distribution::crypto::Keypair; -use shared_types::ContentHash; +use swactor_datastore::crypto::Keypair; +use swactor_datastore::content_hash::ContentHash; use swactor_datastore::actors::{DatastoreNode, GatewayActor}; use swactor_datastore::auth::{ sign_request, AccessControlList, AuthzEngine, DatastoreAction, DeniedReason, diff --git a/crates/datastore/tests/http_auth_integration.rs b/crates/datastore/tests/http_auth_integration.rs index aa0aba6..49d567f 100644 --- a/crates/datastore/tests/http_auth_integration.rs +++ b/crates/datastore/tests/http_auth_integration.rs @@ -3,13 +3,14 @@ //! Spins up a full actor runtime with GatewayActor, starts the HTTP API server, //! and uses ureq to prove that authorized requests succeed while unauthorized //! ones get 403 and missing-auth requests get 401. +#![cfg(feature = "node")] use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::atomic::Ordering; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use distribution::crypto::Keypair; -use shared_types::ContentHash; +use swactor_datastore::crypto::Keypair; +use swactor_datastore::content_hash::ContentHash; use swactor::config::RuntimeConfig; use swactor::runtime::Runtime; use swactor_datastore::actors::{BlobStoreActor, DatastoreNode, GatewayActor, MetadataActor}; diff --git a/crates/datastore/tests/metadata_tests.rs b/crates/datastore/tests/metadata_tests.rs index b1d2477..3281da5 100644 --- a/crates/datastore/tests/metadata_tests.rs +++ b/crates/datastore/tests/metadata_tests.rs @@ -7,7 +7,7 @@ use std::collections::{BTreeMap, HashSet}; use swactor_datastore::messages::{DatastoreResponse, MetadataMsg}; use swactor_datastore::types::{ContentHash, ObjectEntry}; -use distribution::types::NodeId; +use swactor::transport::NodeId; use common::{ make_entry, make_manifest, spawn_metadata, test_node_id, test_runtime, tick_n, tick_until_recv, diff --git a/crates/datastore/tests/multi_node_tests.rs b/crates/datastore/tests/multi_node_tests.rs index 09141bb..7d126d9 100644 --- a/crates/datastore/tests/multi_node_tests.rs +++ b/crates/datastore/tests/multi_node_tests.rs @@ -11,7 +11,7 @@ use swactor_datastore::messages::{MetadataMsg, TransferMsg}; use swactor_datastore::types::ContentHash; use swactor_datastore::TransferActor; -use distribution::types::NodeId; +use swactor::transport::NodeId; // ═══════════════════════════════════════════════════════════════════════════ // Phase 3: Metadata dissemination tests diff --git a/crates/datastore/tests/pool_tests.rs b/crates/datastore/tests/pool_tests.rs deleted file mode 100644 index 9607c65..0000000 --- a/crates/datastore/tests/pool_tests.rs +++ /dev/null @@ -1,341 +0,0 @@ -//! Pool protocol integration tests. -//! -//! Tests the pool disseminator's convergence behavior, the gossip channel -//! integration, and the coordinator actor's lifecycle. - -use std::sync::{Arc, Mutex}; - -use distribution::gossip_channel::{GossipChannel, serialize_each}; -use distribution::types::NodeId; -use shared_types::ContentHash; -use shared_types::pool::*; -use swactor_datastore::pool::disseminator::{PoolDisseminator, SharedPoolChannel}; - -fn node_id(b: u8) -> NodeId { - NodeId([b; 32]) -} - -fn make_pool_disseminator(node_byte: u8) -> PoolDisseminator { - PoolDisseminator::new( - PoolId::from_name("test-pool"), - "test-pool".into(), - node_id(node_byte), - 3, - ) -} - -// ─── Disseminator convergence scenarios ──────────────────────────────────── - -/// Two nodes join a pool and exchange gossip until they converge -/// on identical state. -#[test] -fn two_nodes_converge_on_membership() { - let mut d1 = make_pool_disseminator(1); - let mut d2 = make_pool_disseminator(2); - - d1.join(2); - d2.join(2); - - // Simulate 3 gossip rounds - for _ in 0..3 { - let mut ch1 = SharedPoolChannel::new(Arc::new(Mutex::new(d1))); - let mut ch2 = SharedPoolChannel::new(Arc::new(Mutex::new(d2))); - - let bytes1 = ch1.take_pending_bytes(100); - let bytes2 = ch2.take_pending_bytes(100); - - ch1.apply_incoming_bytes(&bytes2, 2); - ch2.apply_incoming_bytes(&bytes1, 2); - - d1 = Arc::try_unwrap(ch1.into_inner()).unwrap().into_inner().unwrap(); - d2 = Arc::try_unwrap(ch2.into_inner()).unwrap().into_inner().unwrap(); - } - - assert_eq!(d1.member_count(), 2); - assert_eq!(d2.member_count(), 2); -} - -/// Content announced on one node is visible on another after gossip. -#[test] -fn content_location_propagates_via_gossip() { - let mut d1 = make_pool_disseminator(1); - let mut d2 = make_pool_disseminator(2); - let hash = ContentHash::of(b"test-content"); - - d1.join(2); - d2.join(2); - d1.announce_content(hash, 2); - - // Gossip d1 → d2 - let mut ch1 = SharedPoolChannel::new(Arc::new(Mutex::new(d1))); - let bytes = ch1.take_pending_bytes(100); - let _d1 = Arc::try_unwrap(ch1.into_inner()).unwrap().into_inner().unwrap(); - - let mut ch2 = SharedPoolChannel::new(Arc::new(Mutex::new(d2))); - ch2.apply_incoming_bytes(&bytes, 2); - let d2 = Arc::try_unwrap(ch2.into_inner()).unwrap().into_inner().unwrap(); - - assert_eq!(d2.locate_content(&hash), vec![node_id(1)]); -} - -/// A node's leave is propagated and removes it from the active member list -/// on the receiving node. -#[test] -fn leave_propagates_via_gossip() { - let mut d1 = make_pool_disseminator(1); - let mut d2 = make_pool_disseminator(2); - - d1.join(2); - d2.join(2); - - // Exchange so both see each other - let mut ch1 = SharedPoolChannel::new(Arc::new(Mutex::new(d1))); - let mut ch2 = SharedPoolChannel::new(Arc::new(Mutex::new(d2))); - let bytes1 = ch1.take_pending_bytes(100); - let bytes2 = ch2.take_pending_bytes(100); - ch1.apply_incoming_bytes(&bytes2, 2); - ch2.apply_incoming_bytes(&bytes1, 2); - d1 = Arc::try_unwrap(ch1.into_inner()).unwrap().into_inner().unwrap(); - d2 = Arc::try_unwrap(ch2.into_inner()).unwrap().into_inner().unwrap(); - - assert_eq!(d1.member_count(), 2); - assert_eq!(d2.member_count(), 2); - - // d1 leaves - d1.leave(2); - - // Gossip leave to d2 - let mut ch1 = SharedPoolChannel::new(Arc::new(Mutex::new(d1))); - let bytes = ch1.take_pending_bytes(100); - let _d1 = Arc::try_unwrap(ch1.into_inner()).unwrap().into_inner().unwrap(); - - let mut ch2 = SharedPoolChannel::new(Arc::new(Mutex::new(d2))); - ch2.apply_incoming_bytes(&bytes, 2); - let d2 = Arc::try_unwrap(ch2.into_inner()).unwrap().into_inner().unwrap(); - - assert_eq!(d2.member_count(), 1); // only d2 remains active -} - -/// Content tombstone propagates and removes the location. -#[test] -fn content_deletion_propagates() { - let mut d1 = make_pool_disseminator(1); - let mut d2 = make_pool_disseminator(2); - let hash = ContentHash::of(b"ephemeral"); - - d1.join(2); - d2.join(2); - d1.announce_content(hash, 2); - - // Propagate content announcement - let mut ch1 = SharedPoolChannel::new(Arc::new(Mutex::new(d1))); - let bytes = ch1.take_pending_bytes(100); - d1 = Arc::try_unwrap(ch1.into_inner()).unwrap().into_inner().unwrap(); - let mut ch2 = SharedPoolChannel::new(Arc::new(Mutex::new(d2))); - ch2.apply_incoming_bytes(&bytes, 2); - d2 = Arc::try_unwrap(ch2.into_inner()).unwrap().into_inner().unwrap(); - - assert_eq!(d2.locate_content(&hash).len(), 1); - - // d1 removes content - d1.remove_content(hash, 2); - - // Propagate tombstone - let mut ch1 = SharedPoolChannel::new(Arc::new(Mutex::new(d1))); - let bytes = ch1.take_pending_bytes(100); - let _ = Arc::try_unwrap(ch1.into_inner()).unwrap().into_inner().unwrap(); - let mut ch2 = SharedPoolChannel::new(Arc::new(Mutex::new(d2))); - ch2.apply_incoming_bytes(&bytes, 2); - d2 = Arc::try_unwrap(ch2.into_inner()).unwrap().into_inner().unwrap(); - - assert!(d2.locate_content(&hash).is_empty()); -} - -/// ACL grant propagates to other nodes. -#[test] -fn acl_grant_propagates() { - let mut d1 = make_pool_disseminator(1); - let mut d2 = make_pool_disseminator(2); - - d1.grant_access(node_id(3), 2); - - let mut ch1 = SharedPoolChannel::new(Arc::new(Mutex::new(d1))); - let bytes = ch1.take_pending_bytes(100); - let _ = Arc::try_unwrap(ch1.into_inner()).unwrap().into_inner().unwrap(); - - let mut ch2 = SharedPoolChannel::new(Arc::new(Mutex::new(d2))); - ch2.apply_incoming_bytes(&bytes, 2); - d2 = Arc::try_unwrap(ch2.into_inner()).unwrap().into_inner().unwrap(); - - assert!(d2.is_node_authorized(&node_id(3))); - assert!(!d2.is_node_authorized(&node_id(4))); -} - -/// Capacity announcement propagates and is reflected in summary. -#[test] -fn capacity_propagates_and_summarizes() { - let mut d1 = make_pool_disseminator(1); - let mut d2 = make_pool_disseminator(2); - - d1.join(2); - d2.join(2); - d1.announce_capacity(1_000_000, 100_000, 2); - d2.announce_capacity(2_000_000, 200_000, 2); - - // Exchange - let mut ch1 = SharedPoolChannel::new(Arc::new(Mutex::new(d1))); - let mut ch2 = SharedPoolChannel::new(Arc::new(Mutex::new(d2))); - let bytes1 = ch1.take_pending_bytes(100); - let bytes2 = ch2.take_pending_bytes(100); - ch1.apply_incoming_bytes(&bytes2, 2); - ch2.apply_incoming_bytes(&bytes1, 2); - d1 = Arc::try_unwrap(ch1.into_inner()).unwrap().into_inner().unwrap(); - d2 = Arc::try_unwrap(ch2.into_inner()).unwrap().into_inner().unwrap(); - - let (total1, used1) = d1.pool_capacity_summary(); - let (total2, used2) = d2.pool_capacity_summary(); - - assert_eq!(total1, 3_000_000); - assert_eq!(used1, 300_000); - assert_eq!(total2, 3_000_000); - assert_eq!(used2, 300_000); -} - -/// The node_with_most_free_space query returns the correct node. -#[test] -fn placement_query_picks_node_with_most_space() { - let mut d = make_pool_disseminator(1); - d.join(3); - - // Simulate node 2 joining and having lots of space - let member2 = PoolEntry::Membership(PoolMemberEntry { - pool_id: PoolId::from_name("test-pool"), - node_id: [2u8; 32], - state: PoolMemberState::Active, - generation: 1, - }); - let cap2 = PoolEntry::Capacity(PoolCapacityEntry { - pool_id: PoolId::from_name("test-pool"), - node_id: [2u8; 32], - total_bytes: 10_000_000, - used_bytes: 1_000_000, - generation: 1, - }); - - // Simulate node 3 with less free space - let member3 = PoolEntry::Membership(PoolMemberEntry { - pool_id: PoolId::from_name("test-pool"), - node_id: [3u8; 32], - state: PoolMemberState::Active, - generation: 1, - }); - let cap3 = PoolEntry::Capacity(PoolCapacityEntry { - pool_id: PoolId::from_name("test-pool"), - node_id: [3u8; 32], - total_bytes: 5_000_000, - used_bytes: 4_000_000, - generation: 1, - }); - - let entries = vec![member2, cap2, member3, cap3]; - let bytes = serialize_each(&entries); - let mut ch = SharedPoolChannel::new(Arc::new(Mutex::new(d))); - ch.apply_incoming_bytes(&bytes, 3); - d = Arc::try_unwrap(ch.into_inner()).unwrap().into_inner().unwrap(); - - // d1 has no capacity announced, node 2 has 9M free, node 3 has 1M free - let best = d.node_with_most_free_space().unwrap(); - assert_eq!(best, node_id(2)); -} - -/// Five-node convergence: all nodes join, one announces content, everyone converges. -#[test] -fn five_node_pool_converges() { - let pool = PoolId::from_name("five-pool"); - let mut nodes: Vec = (0..5) - .map(|i| PoolDisseminator::new(pool, "five-pool".into(), node_id(i as u8), 3)) - .collect(); - - // All join - for n in &mut nodes { - n.join(5); - } - - // Node 0 and 2 announce content - let hash_a = ContentHash::of(b"file-a"); - let hash_b = ContentHash::of(b"file-b"); - nodes[0].announce_content(hash_a, 5); - nodes[2].announce_content(hash_b, 5); - - // Run 10 gossip rounds - for _ in 0..10 { - let mut channels: Vec = nodes - .into_iter() - .map(|d| SharedPoolChannel::new(Arc::new(Mutex::new(d)))) - .collect(); - - let pending: Vec>> = channels - .iter_mut() - .map(|ch| ch.take_pending_bytes(100)) - .collect(); - - for (recv_idx, ch) in channels.iter_mut().enumerate() { - for (send_idx, bytes) in pending.iter().enumerate() { - if recv_idx != send_idx { - ch.apply_incoming_bytes(bytes, 5); - } - } - } - - nodes = channels - .into_iter() - .map(|ch| Arc::try_unwrap(ch.into_inner()).unwrap().into_inner().unwrap()) - .collect(); - } - - for (i, node) in nodes.iter().enumerate() { - assert_eq!(node.member_count(), 5, "node {i} should see 5 members"); - assert_eq!( - node.locate_content(&hash_a), - vec![node_id(0)], - "node {i} should locate file-a on node 0" - ); - assert_eq!( - node.locate_content(&hash_b), - vec![node_id(2)], - "node {i} should locate file-b on node 2" - ); - } -} - -// ─── GossipChannel wire format tests ─────────────────────────────────────── - -/// Verify the GossipChannel topic tag is correct. -#[test] -fn shared_pool_channel_topic_tag() { - let d = make_pool_disseminator(1); - let ch = SharedPoolChannel::new(Arc::new(Mutex::new(d))); - assert_eq!(GossipChannel::topic_tag(&ch), "pool"); -} - -/// Verify serialization round-trip through GossipChannel bytes interface. -#[test] -fn gossip_channel_bytes_roundtrip() { - let mut d = make_pool_disseminator(1); - d.join(2); - let hash = ContentHash::of(b"roundtrip"); - d.announce_content(hash, 2); - - let mut ch = SharedPoolChannel::new(Arc::new(Mutex::new(d))); - let bytes = ch.take_pending_bytes(100); - assert!(!bytes.is_empty()); - - // Apply to a fresh disseminator - let d2 = make_pool_disseminator(2); - let mut ch2 = SharedPoolChannel::new(Arc::new(Mutex::new(d2))); - ch2.apply_incoming_bytes(&bytes, 2); - let d2 = Arc::try_unwrap(ch2.into_inner()).unwrap().into_inner().unwrap(); - - assert_eq!(d2.member_count(), 1); - assert_eq!(d2.locate_content(&hash), vec![node_id(1)]); -} diff --git a/crates/datastore/tests/transfer_tests.rs b/crates/datastore/tests/transfer_tests.rs index f803337..6f4b470 100644 --- a/crates/datastore/tests/transfer_tests.rs +++ b/crates/datastore/tests/transfer_tests.rs @@ -6,7 +6,7 @@ use swactor_datastore::chunking::{chunk_blob, reassemble_blob, verify_integrity} use swactor_datastore::messages::{BlobStoreMsg, DatastoreResponse, TransferMsg}; use swactor_datastore::types::{ChunkRef, ContentHash, ObjectManifest}; -use distribution::types::NodeId; +use swactor::transport::NodeId; use common::{ spawn_blob_store, spawn_transfer, test_runtime, tick_and_drain, tick_n, tick_until_recv, diff --git a/crates/distribution/Cargo.toml b/crates/distribution/Cargo.toml index 16fc876..c2567a3 100644 --- a/crates/distribution/Cargo.toml +++ b/crates/distribution/Cargo.toml @@ -5,18 +5,17 @@ edition = "2024" [features] default = ["tcp"] -tcp = [] +tcp = ["dep:swactor-transport", "swactor-transport/tcp"] iroh = ["dep:iroh", "dep:tokio"] relay = ["iroh", "dep:iroh-relay"] [dependencies] swactor = { path = "../..", features = ["serde", "transport"] } -shared-types = { path = "../shared-types" } +swactor-transport = { path = "../transport", optional = true } ed25519-dalek = { version = "2", features = ["rand_core"] } rand_core = { version = "0.6", features = ["getrandom"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -bs58 = "0.5" iroh = { version = "0.96", optional = true } iroh-relay = { version = "0.96", features = ["server"], optional = true } tokio = { version = "1", features = ["rt-multi-thread"], optional = true } diff --git a/crates/distribution/src/crypto.rs b/crates/distribution/src/crypto.rs index 43a1a42..a59d25d 100644 --- a/crates/distribution/src/crypto.rs +++ b/crates/distribution/src/crypto.rs @@ -1,6 +1,48 @@ -use ed25519_dalek::{Signer, Verifier}; +//! Ed25519 cryptographic primitives for distribution. -use crate::types::{DirectoryEntry, DirectoryEntryPayload, NodeId, Signature}; +use std::fmt; + +use ed25519_dalek::{Signer, Verifier}; +use serde::{Deserialize, Serialize}; + +use crate::types::{DirectoryEntry, DirectoryEntryPayload, NodeId}; + +// ─── Signature ────────────────────────────────────────────────────────────── + +/// An ed25519 signature (64 bytes). +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct Signature(pub [u8; 64]); + +impl Serialize for Signature { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_bytes(&self.0) + } +} + +impl<'de> Deserialize<'de> for Signature { + fn deserialize>(deserializer: D) -> Result { + let bytes: Vec = Deserialize::deserialize(deserializer)?; + if bytes.len() != 64 { + return Err(serde::de::Error::custom(format!( + "expected 64 bytes for Signature, got {}", + bytes.len() + ))); + } + let mut arr = [0u8; 64]; + arr.copy_from_slice(&bytes); + Ok(Signature(arr)) + } +} + +impl fmt::Debug for Signature { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Sig(")?; + for b in &self.0[..4] { + write!(f, "{:02x}", b)?; + } + write!(f, "\u{2026})") + } +} // ─── Keypair ──────────────────────────────────────────────────────────────── @@ -40,9 +82,33 @@ impl Keypair { let sig = self.inner.sign(msg); Signature(sig.to_bytes()) } +} +// ─── Verification ─────────────────────────────────────────────────────────── + +/// Verify a signature against a `NodeId` (public key) and message bytes. +pub fn verify(node_id: &NodeId, msg: &[u8], sig: &Signature) -> bool { + let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(&node_id.0) else { + return false; + }; + let signature = ed25519_dalek::Signature::from_bytes(&sig.0); + vk.verify(msg, &signature).is_ok() +} + +// ─── Directory Entry Helpers ──────────────────────────────────────────────── + +/// Extension methods for Keypair specific to distribution directory entries. +pub trait KeypairExt { /// Sign a directory entry payload, returning a complete `DirectoryEntry`. - pub fn sign_directory_entry( + fn sign_directory_entry( + &self, + actor_addr: swactor::actor::ActorAddress, + generation: u64, + ) -> DirectoryEntry; +} + +impl KeypairExt for Keypair { + fn sign_directory_entry( &self, actor_addr: swactor::actor::ActorAddress, generation: u64, @@ -63,17 +129,6 @@ impl Keypair { } } -// ─── Verification ─────────────────────────────────────────────────────────── - -/// Verify a signature against a `NodeId` (public key) and message bytes. -pub fn verify(node_id: &NodeId, msg: &[u8], sig: &Signature) -> bool { - let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(&node_id.0) else { - return false; - }; - let signature = ed25519_dalek::Signature::from_bytes(&sig.0); - vk.verify(msg, &signature).is_ok() -} - /// Verify a `DirectoryEntry`'s signature against its embedded `node_id`. pub fn verify_directory_entry(entry: &DirectoryEntry) -> bool { let payload = entry.payload(); diff --git a/crates/distribution/src/driver.rs b/crates/distribution/src/driver.rs index 5500b2d..26c280f 100644 --- a/crates/distribution/src/driver.rs +++ b/crates/distribution/src/driver.rs @@ -19,7 +19,7 @@ use crate::messages::*; use crate::node::{DistributedNode, DistributedNodeConfig}; use crate::snapshot::DistributionNodeSnapshot; use crate::swim::node::NodeAction; -use crate::transport::{TcpAcceptor, TcpTransport}; +use swactor_transport::tcp::{TcpAcceptor, TcpTransport, encode_wire_envelope_with_hints}; use crate::types::NodeId; /// Dummy destination address used in wire envelopes for SWIM protocol messages. @@ -44,6 +44,12 @@ pub struct AddressHint { /// Maps NodeId → SocketAddr. Maintained by the TCP driver layer. pub struct PeerAddressBook(HashMap); +impl Default for PeerAddressBook { + fn default() -> Self { + Self::new() + } +} + impl PeerAddressBook { pub fn new() -> Self { Self(HashMap::new()) @@ -142,20 +148,18 @@ impl NodeDriver { // Enrich member addresses from address book for member in &mut snap.members { - if let Some(node_id) = parse_node_id_hex(&member.node_id) { - if let Some(addr) = self.address_book.resolve(&node_id) { + if let Some(node_id) = parse_node_id_hex(&member.node_id) + && let Some(addr) = self.address_book.resolve(&node_id) { member.addr = Some(addr.to_string()); } - } } // Enrich routing neighbor addresses from address book for neighbor in &mut snap.routing_neighbors { - if let Some(node_id) = parse_node_id_hex(&neighbor.node_id) { - if let Some(addr) = self.address_book.resolve(&node_id) { + if let Some(node_id) = parse_node_id_hex(&neighbor.node_id) + && let Some(addr) = self.address_book.resolve(&node_id) { neighbor.addr = Some(addr.to_string()); } - } } snap @@ -201,11 +205,10 @@ impl NodeDriver { pub fn recv(&mut self) { let envelopes = self.acceptor.try_recv(&mut self.streams); for (envelope, _peer_addr, hints_bytes) in envelopes { - if !hints_bytes.is_empty() { - if let Ok(hints) = serde_json::from_slice::>(&hints_bytes) { + if !hints_bytes.is_empty() + && let Ok(hints) = serde_json::from_slice::>(&hints_bytes) { self.learn_hints(&hints); } - } let response_actions = self.dispatch_incoming(envelope); self.send_actions(&response_actions); } @@ -223,14 +226,13 @@ impl NodeDriver { for (envelope, _peer_addr, hints_bytes) in envelopes { // Extract sender NodeId from hints let mut sender_node_id = None; - if !hints_bytes.is_empty() { - if let Ok(hints) = serde_json::from_slice::>(&hints_bytes) { + if !hints_bytes.is_empty() + && let Ok(hints) = serde_json::from_slice::>(&hints_bytes) { if let Some(first) = hints.first() { sender_node_id = Some(first.node_id); } self.learn_hints(&hints); } - } // Check peer auth if we know the sender if let Some(node_id) = sender_node_id { @@ -492,21 +494,3 @@ fn parse_node_id_hex(hex: &str) -> Option { Some(NodeId(bytes)) } -/// Encode a wire envelope with address hints appended after the payload. -/// -/// Extended frame format: -/// [4B frame_len][32B dest][4B tag_len][tag][4B hints_len][hints][payload] -fn encode_wire_envelope_with_hints(envelope: &WireEnvelope, hints_bytes: &[u8]) -> Vec { - let tag_bytes = envelope.type_tag.as_bytes(); - let frame_len: u32 = (32 + 4 + tag_bytes.len() + 4 + hints_bytes.len() + envelope.payload.len()) as u32; - - let mut buf = Vec::with_capacity(4 + frame_len as usize); - buf.extend_from_slice(&frame_len.to_be_bytes()); - buf.extend_from_slice(&envelope.dest.0); - buf.extend_from_slice(&(tag_bytes.len() as u32).to_be_bytes()); - buf.extend_from_slice(tag_bytes); - buf.extend_from_slice(&(hints_bytes.len() as u32).to_be_bytes()); - buf.extend_from_slice(hints_bytes); - buf.extend_from_slice(&envelope.payload); - buf -} diff --git a/crates/distribution/src/gossip_channel.rs b/crates/distribution/src/gossip_channel.rs deleted file mode 100644 index a49dce6..0000000 --- a/crates/distribution/src/gossip_channel.rs +++ /dev/null @@ -1,268 +0,0 @@ -//! Generic gossip channel abstraction. -//! -//! Provides `GossipChannel` — a trait for any topic that wants to piggyback -//! on SWIM protocol messages — and `DisseminationBuffer` — a reusable -//! budget-limited dissemination queue that replaces the 4 independent copies -//! of the `Λ * ceil(log₂(n))` pattern. - -use serde::{Serialize, de::DeserializeOwned}; - -// ─── GossipChannel trait ──────────────────────────────────────────────────── - -/// A gossip channel that can piggyback serialized entries on SWIM messages. -/// -/// Each channel has a unique topic tag and handles its own serialization. -/// The wire transport uses type-erased `Vec` entries. -pub trait GossipChannel: Send { - /// Unique string tag identifying this channel in the piggyback payload. - fn topic_tag(&self) -> &'static str; - - /// Take up to `max_entries` pending entries, serialized as bytes. - fn take_pending_bytes(&mut self, max_entries: usize) -> Vec>; - - /// Apply incoming entries (deserialized from bytes) received from gossip. - fn apply_incoming_bytes(&mut self, entries: &[Vec], cluster_size: usize); - - /// Re-enqueue all state for dissemination (anti-entropy on membership recovery). - fn re_disseminate_all(&mut self, cluster_size: usize); - - /// Handle a node being declared dead. - fn on_node_death(&mut self, node_id: &crate::types::NodeId); - - /// Periodic garbage collection tick. - fn gc_tick(&mut self); -} - -// ─── DisseminationBuffer ──────────────────────────────────────────────── - -/// A queued entry with a remaining transmit budget. -#[derive(Debug, Clone)] -struct BufferEntry { - item: T, - remaining: usize, -} - -/// Reusable generic dissemination buffer. -/// -/// Manages budget-limited gossip dissemination for any entry type. -/// Each entry is transmitted `Λ * ceil(log₂(n))` times before eviction. -#[derive(Debug)] -pub struct DisseminationBuffer { - entries: Vec>, - lambda: usize, -} - -impl DisseminationBuffer { - /// Create a new buffer with the given dissemination multiplier (Λ). - pub fn new(lambda: usize) -> Self { - Self { - entries: Vec::new(), - lambda, - } - } - - /// Compute the transmit budget: `Λ * ceil(log₂(max(n, 2)))`. - pub fn transmit_budget(&self, cluster_size: usize) -> usize { - let n = cluster_size.max(2) as f64; - let log_n = n.log2().ceil() as usize; - self.lambda * log_n.max(1) - } - - /// Enqueue an entry for dissemination. Does not check for duplicates. - pub fn enqueue(&mut self, item: T, cluster_size: usize) { - let budget = self.transmit_budget(cluster_size); - self.entries.push(BufferEntry { - item, - remaining: budget, - }); - } - - /// Enqueue an entry, replacing an existing one if `matcher` returns true. - /// If no match is found, pushes a new entry. - pub fn enqueue_or_replace(&mut self, item: T, cluster_size: usize, matcher: F) - where - F: Fn(&T) -> bool, - { - let budget = self.transmit_budget(cluster_size); - - if let Some(existing) = self.entries.iter_mut().find(|e| matcher(&e.item)) { - existing.item = item; - existing.remaining = budget; - return; - } - - self.entries.push(BufferEntry { - item, - remaining: budget, - }); - } - - /// Take up to `max_count` entries for piggyback. - /// Decrements remaining budget and evicts exhausted entries. - pub fn take(&mut self, max_count: usize) -> Vec { - let count = max_count.min(self.entries.len()); - let mut result = Vec::with_capacity(count); - - for entry in self.entries.iter_mut().take(count) { - result.push(entry.item.clone()); - entry.remaining = entry.remaining.saturating_sub(1); - } - - self.entries.retain(|e| e.remaining > 0); - result - } - - /// Re-enqueue all given items with fresh budgets. - pub fn re_enqueue_all(&mut self, items: impl IntoIterator, cluster_size: usize) { - for item in items { - self.enqueue(item, cluster_size); - } - } - - /// Retain only entries matching the predicate. - pub fn retain(&mut self, mut predicate: F) - where - F: FnMut(&T) -> bool, - { - self.entries.retain(|e| predicate(&e.item)); - } - - /// Number of queued entries. - pub fn len(&self) -> usize { - self.entries.len() - } - - /// Whether the buffer is empty. - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } -} - -// ─── Serialization helpers ───────────────────────────────────────────────── - -/// Serialize a slice of items to a Vec of byte vectors. -pub fn serialize_each(items: &[T]) -> Vec> { - items - .iter() - .filter_map(|item| serde_json::to_vec(item).ok()) - .collect() -} - -/// Deserialize a slice of byte vectors into items, skipping failures. -pub fn deserialize_each(entries: &[Vec]) -> Vec { - entries - .iter() - .filter_map(|bytes| serde_json::from_slice(bytes).ok()) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn budget_math_two_nodes() { - let buf: DisseminationBuffer = DisseminationBuffer::new(3); - // log2(2) = 1, ceil = 1, 3 * 1 = 3 - assert_eq!(buf.transmit_budget(2), 3); - } - - #[test] - fn budget_math_single_node_floors_to_two() { - let buf: DisseminationBuffer = DisseminationBuffer::new(3); - // cluster_size=1 → max(1,2)=2, log2(2)=1, 3*1=3 - assert_eq!(buf.transmit_budget(1), 3); - } - - #[test] - fn budget_math_32_nodes() { - let buf: DisseminationBuffer = DisseminationBuffer::new(3); - // log2(32) = 5, 3 * 5 = 15 - assert_eq!(buf.transmit_budget(32), 15); - } - - #[test] - fn budget_math_33_nodes() { - let buf: DisseminationBuffer = DisseminationBuffer::new(3); - // log2(33) ≈ 5.04, ceil = 6, 3 * 6 = 18 - assert_eq!(buf.transmit_budget(33), 18); - } - - #[test] - fn enqueue_take_evicts_after_budget() { - let mut buf = DisseminationBuffer::new(3); - buf.enqueue(42u32, 2); // budget = 3 - - // Take 3 times — each take decrements once - let r1 = buf.take(1); - assert_eq!(r1, vec![42]); - assert_eq!(buf.len(), 1); - - let r2 = buf.take(1); - assert_eq!(r2, vec![42]); - assert_eq!(buf.len(), 1); - - let r3 = buf.take(1); - assert_eq!(r3, vec![42]); - // After 3 takes, budget exhausted → evicted - assert_eq!(buf.len(), 0); - } - - #[test] - fn enqueue_or_replace_replaces_matching_entry() { - let mut buf = DisseminationBuffer::new(3); - buf.enqueue_or_replace(("key", 1), 2, |e| e.0 == "key"); - buf.enqueue_or_replace(("key", 2), 2, |e| e.0 == "key"); - - assert_eq!(buf.len(), 1); - let taken = buf.take(1); - assert_eq!(taken, vec![("key", 2)]); - } - - #[test] - fn enqueue_or_replace_adds_when_no_match() { - let mut buf = DisseminationBuffer::new(3); - buf.enqueue_or_replace(("a", 1), 2, |e| e.0 == "a"); - buf.enqueue_or_replace(("b", 2), 2, |e| e.0 == "b"); - - assert_eq!(buf.len(), 2); - } - - #[test] - fn re_enqueue_all_refreshes_budgets() { - let mut buf = DisseminationBuffer::new(3); - buf.enqueue(1u32, 2); - buf.enqueue(2u32, 2); - - // Drain them - for _ in 0..3 { - buf.take(2); - } - assert!(buf.is_empty()); - - // Re-enqueue - buf.re_enqueue_all(vec![1, 2, 3], 2); - assert_eq!(buf.len(), 3); - } - - #[test] - fn retain_removes_non_matching() { - let mut buf = DisseminationBuffer::new(3); - buf.enqueue(1u32, 2); - buf.enqueue(2u32, 2); - buf.enqueue(3u32, 2); - - buf.retain(|item| *item != 2); - assert_eq!(buf.len(), 2); - let taken = buf.take(3); - assert_eq!(taken, vec![1, 3]); - } - - #[test] - fn serialize_deserialize_roundtrip() { - let items = vec![1u32, 2, 3]; - let bytes = serialize_each(&items); - let recovered: Vec = deserialize_each(&bytes); - assert_eq!(recovered, items); - } -} diff --git a/crates/distribution/src/iroh_driver.rs b/crates/distribution/src/iroh_driver.rs index 8bd749b..14b7c7a 100644 --- a/crates/distribution/src/iroh_driver.rs +++ b/crates/distribution/src/iroh_driver.rs @@ -169,7 +169,7 @@ impl IrohDriver { if !allowed { eprintln!( "iroh driver: rejected connection from unauthorized peer {}", - crate::identity::hex_encode(&node_id.0[..4]) + swactor::transport::hex_encode(&node_id.0[..4]) ); conn.close(0u32.into(), b"unauthorized"); continue; @@ -179,14 +179,14 @@ impl IrohDriver { if negotiated_alpn == ALPN { eprintln!( "iroh driver: accepted SWIM connection from {}", - crate::identity::hex_encode(&node_id.0[..4]) + swactor::transport::hex_encode(&node_id.0[..4]) ); swim_buf.lock().unwrap().push((node_id, conn)); } else { eprintln!( "iroh driver: accepted non-SWIM connection from {} (ALPN: {})", - crate::identity::hex_encode(&node_id.0[..4]), - String::from_utf8_lossy(&negotiated_alpn), + swactor::transport::hex_encode(&node_id.0[..4]), + String::from_utf8_lossy(negotiated_alpn), ); other_buf.lock().unwrap().push((node_id, conn)); } @@ -524,7 +524,7 @@ impl IrohDriver { if !self.is_peer_allowed(&node_id) { return Err(format!( "peer {} not in allow-list", - crate::identity::hex_encode(&node_id.0[..4]) + swactor::transport::hex_encode(&node_id.0[..4]) ) .into()); } diff --git a/crates/distribution/src/kademlia/directory.rs b/crates/distribution/src/kademlia/directory.rs index e5cedac..fadb8ff 100644 --- a/crates/distribution/src/kademlia/directory.rs +++ b/crates/distribution/src/kademlia/directory.rs @@ -16,6 +16,12 @@ pub struct DirectoryShard { entries: HashMap>, } +impl Default for DirectoryShard { + fn default() -> Self { + Self::new() + } +} + impl DirectoryShard { pub fn new() -> Self { Self { @@ -56,7 +62,7 @@ impl DirectoryShard { let mut removed = Vec::new(); for entries in self.entries.values_mut() { let _before = entries.len(); - let drained: Vec<_> = entries.drain(..).collect(); + let drained: Vec<_> = std::mem::take(entries); for entry in drained { if entry.node_id == *node_id { removed.push(entry); @@ -74,7 +80,7 @@ impl DirectoryShard { pub fn remove_where bool>(&mut self, predicate: F) -> Vec { let mut removed = Vec::new(); for entries in self.entries.values_mut() { - let drained: Vec<_> = entries.drain(..).collect(); + let drained: Vec<_> = std::mem::take(entries); for entry in drained { if predicate(&entry) { removed.push(entry); diff --git a/crates/distribution/src/kademlia/lookup.rs b/crates/distribution/src/kademlia/lookup.rs index ac93965..f00c940 100644 --- a/crates/distribution/src/kademlia/lookup.rs +++ b/crates/distribution/src/kademlia/lookup.rs @@ -11,7 +11,7 @@ use std::collections::{HashMap, HashSet}; -use crate::types::NodeId; +use crate::types::{NodeId, NodeIdDistance}; use super::routing_table::{RoutingTable, K}; /// Concurrency parameter — how many queries to issue in parallel per round. diff --git a/crates/distribution/src/kademlia/repair.rs b/crates/distribution/src/kademlia/repair.rs index 88c7d83..573ad2b 100644 --- a/crates/distribution/src/kademlia/repair.rs +++ b/crates/distribution/src/kademlia/repair.rs @@ -17,6 +17,12 @@ pub struct RepairQueue { pending: HashMap, } +impl Default for RepairQueue { + fn default() -> Self { + Self::new() + } +} + impl RepairQueue { pub fn new() -> Self { Self { diff --git a/crates/distribution/src/kademlia/routing_table.rs b/crates/distribution/src/kademlia/routing_table.rs index 101f0c8..ed21b24 100644 --- a/crates/distribution/src/kademlia/routing_table.rs +++ b/crates/distribution/src/kademlia/routing_table.rs @@ -7,7 +7,7 @@ use std::collections::VecDeque; -use crate::types::NodeId; +use crate::types::{NodeId, NodeIdDistance}; /// Default replication parameter. pub const K: usize = 20; diff --git a/crates/distribution/src/lib.rs b/crates/distribution/src/lib.rs index eb62498..5469829 100644 --- a/crates/distribution/src/lib.rs +++ b/crates/distribution/src/lib.rs @@ -1,11 +1,8 @@ pub mod types; pub mod crypto; -pub mod identity; pub mod peer_auth; pub mod messages; pub mod codec; -#[cfg(feature = "tcp")] -pub mod transport; pub mod swim; pub mod kademlia; pub mod cache; diff --git a/crates/distribution/src/node.rs b/crates/distribution/src/node.rs index 566447c..aa1b1d5 100644 --- a/crates/distribution/src/node.rs +++ b/crates/distribution/src/node.rs @@ -6,7 +6,7 @@ use swactor::actor::ActorAddress; use crate::cache::LocationCache; -use crate::crypto::Keypair; +use crate::crypto::{Keypair, KeypairExt}; use crate::kademlia::directory::{actor_addr_as_node_id, DirectoryShard}; use crate::kademlia::repair::{RepairQueue, RepublishTracker}; use crate::kademlia::routing_table::RoutingTable; @@ -232,12 +232,11 @@ impl DistributedNode { } // 2. Check local directory shard - if let Some(entries) = self.directory.get(actor_addr) { - if let Some(entry) = entries.first() { + if let Some(entries) = self.directory.get(actor_addr) + && let Some(entry) = entries.first() { self.cache.insert(*actor_addr, entry.node_id); return ResolveResult::Cached(entry.node_id); } - } // 3. Need to do a Kademlia lookup let target = actor_addr_as_node_id(actor_addr); diff --git a/crates/distribution/src/peer_auth.rs b/crates/distribution/src/peer_auth.rs index 2bcfb2a..d0016ae 100644 --- a/crates/distribution/src/peer_auth.rs +++ b/crates/distribution/src/peer_auth.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; -use crate::identity::{hex_decode, hex_encode}; +use swactor::transport::{hex_decode, hex_encode}; use crate::types::NodeId; /// A single trusted peer entry. @@ -55,11 +55,10 @@ impl PeerAllowList { .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; let mut map = HashMap::new(); for entry in file.peers { - if let Some(bytes) = hex_decode(&entry.node_id) { - if let Ok(arr) = <[u8; 32]>::try_from(bytes.as_slice()) { + if let Some(bytes) = hex_decode(&entry.node_id) + && let Ok(arr) = <[u8; 32]>::try_from(bytes.as_slice()) { map.insert(NodeId(arr), entry); } - } } map } else { @@ -134,7 +133,7 @@ impl PeerAllowList { } let json = serde_json::to_string_pretty(&file) - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + .map_err(|e| io::Error::other(e))?; std::fs::write(path, json) } } diff --git a/crates/distribution/src/registry.rs b/crates/distribution/src/registry.rs index 711321d..ac6acf6 100644 --- a/crates/distribution/src/registry.rs +++ b/crates/distribution/src/registry.rs @@ -167,11 +167,10 @@ impl ClusterRegistry { /// Merge a single remote entry. Returns true if state changed. pub fn merge(&mut self, remote: RegistryEntry) -> bool { - if let Some(existing) = self.entries.get(&remote.name) { - if !lww_wins(&remote, existing) { + if let Some(existing) = self.entries.get(&remote.name) + && !lww_wins(&remote, existing) { return false; } - } let changed = match self.entries.get(&remote.name) { Some(existing) => existing != &remote, @@ -254,7 +253,7 @@ impl ClusterRegistry { /// Periodic GC: remove tombstones past TTL with exhausted dissemination budgets. pub fn gc_tick(&mut self) { self.tick_count += 1; - if self.tick_count % self.config.gc_interval != 0 { + if !self.tick_count.is_multiple_of(self.config.gc_interval) { return; } diff --git a/crates/distribution/src/snapshot.rs b/crates/distribution/src/snapshot.rs index 257a90d..d4b3d01 100644 --- a/crates/distribution/src/snapshot.rs +++ b/crates/distribution/src/snapshot.rs @@ -1,6 +1,6 @@ //! Serializable snapshot of a `DistributedNode`'s state. //! -//! Used by the runtime-dashboard to display distribution monitoring data +//! Used by the dashboard to display distribution monitoring data //! for a single node without reaching out to other nodes. use serde::{Deserialize, Serialize}; @@ -176,7 +176,7 @@ impl DistributedNode { let recent_targets: Vec = self .recent_probe_targets() .iter() - .map(|id| node_id_hex(id)) + .map(node_id_hex) .collect(); let registry = self.registry(); diff --git a/crates/distribution/src/swim/lifeguard.rs b/crates/distribution/src/swim/lifeguard.rs index ba9fc42..a94f308 100644 --- a/crates/distribution/src/swim/lifeguard.rs +++ b/crates/distribution/src/swim/lifeguard.rs @@ -107,8 +107,8 @@ fn log2_ceil(n: u64) -> u64 { return 0; } // Number of bits needed = position of highest set bit - let bits = 64 - (n - 1).leading_zeros() as u64; - bits + + 64 - (n - 1).leading_zeros() as u64 } #[cfg(test)] diff --git a/crates/distribution/src/swim/member_list.rs b/crates/distribution/src/swim/member_list.rs index dfaef15..b7248a1 100644 --- a/crates/distribution/src/swim/member_list.rs +++ b/crates/distribution/src/swim/member_list.rs @@ -137,24 +137,22 @@ impl MemberList { /// Mark a node as suspect (if currently alive and same/higher incarnation). pub fn suspect(&mut self, node_id: NodeId) -> bool { - if let Some(entry) = self.members.get_mut(&node_id) { - if entry.state == MemberState::Alive { + if let Some(entry) = self.members.get_mut(&node_id) + && entry.state == MemberState::Alive { entry.state = MemberState::Suspect; return true; } - } false } /// Mark a node as dead. Only transitions from Suspect → Dead, /// enforcing the SWIM lifecycle invariant (Alive → Suspect → Dead). pub fn declare_dead(&mut self, node_id: NodeId) -> bool { - if let Some(entry) = self.members.get_mut(&node_id) { - if entry.state == MemberState::Suspect { + if let Some(entry) = self.members.get_mut(&node_id) + && entry.state == MemberState::Suspect { entry.state = MemberState::Dead; return true; } - } false } diff --git a/crates/distribution/src/swim/node.rs b/crates/distribution/src/swim/node.rs index 577dff2..f8baade 100644 --- a/crates/distribution/src/swim/node.rs +++ b/crates/distribution/src/swim/node.rs @@ -3,7 +3,7 @@ //! This is the top-level SWIM state machine that a `DistributedNode` will drive. //! It produces `SwimAction`s that the caller translates into real network I/O. -use crate::identity::hex_encode; +use swactor::transport::hex_encode; use crate::messages::MembershipUpdate; use crate::types::{MemberState, NodeId, NodeRecord}; @@ -284,14 +284,13 @@ impl SwimNode { // so it piggybacks on this message. This is the key mechanism // for partition-heal recovery: the target learns it was // suspected/declared dead and refutes by bumping its incarnation. - if let Some(entry) = self.members.get(&to) { - if entry.state == MemberState::Dead || entry.state == MemberState::Suspect { + if let Some(entry) = self.members.get(&to) + && (entry.state == MemberState::Dead || entry.state == MemberState::Suspect) { self.dissemination.enqueue( membership_update(to, entry.state, entry.incarnation), self.cluster_size(), ); } - } let pb = self.dissemination.pack_piggyback(self.max_piggyback); actions.push(NodeAction::SendPing { to, diff --git a/crates/distribution/src/swim/probe.rs b/crates/distribution/src/swim/probe.rs index a85deea..954ce22 100644 --- a/crates/distribution/src/swim/probe.rs +++ b/crates/distribution/src/swim/probe.rs @@ -233,7 +233,7 @@ impl SwimProbe { self.next_probe_tick = self.tick + self.config.probe_interval; - if let Some(target) = self.pick_probe_target(&mut MemberList::clone_shallow(members)) { + if let Some(target) = self.pick_probe_target(&MemberList::clone_shallow(members)) { // Record this probe target in history if self.recent_targets.len() >= PROBE_HISTORY_SIZE { self.recent_targets.pop_front(); @@ -305,14 +305,11 @@ impl SwimProbe { } fn handle_indirect_ack(&mut self, target: NodeId, sequence: u64, _members: &mut MemberList, _actions: &mut Vec) { - match &self.phase { - ProbePhase::WaitingIndirectAck { target: expected, sequence: expected_seq, .. } => { - if target == *expected && sequence == *expected_seq { - self.cancel_suspicion_timer(target); - self.phase = ProbePhase::Idle; - } + if let ProbePhase::WaitingIndirectAck { target: expected, sequence: expected_seq, .. } = &self.phase { + if target == *expected && sequence == *expected_seq { + self.cancel_suspicion_timer(target); + self.phase = ProbePhase::Idle; } - _ => {} } } diff --git a/crates/distribution/src/types.rs b/crates/distribution/src/types.rs index c8a691a..e62e593 100644 --- a/crates/distribution/src/types.rs +++ b/crates/distribution/src/types.rs @@ -1,19 +1,22 @@ -use std::fmt; - use serde::{Deserialize, Serialize}; use swactor::actor::ActorAddress; -// ─── NodeId ───────────────────────────────────────────────────────────────── +pub use swactor::transport::NodeId; +pub use crate::crypto::Signature; -/// A node's identity — the raw bytes of an ed25519 public key. -/// -/// Also serves as the Kademlia key for XOR distance calculations. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct NodeId(pub [u8; 32]); +// ─── NodeId extensions (Kademlia-specific) ───────────────────────────────── -impl NodeId { +/// Kademlia XOR distance operations on NodeId. +pub trait NodeIdDistance { /// XOR distance between two node IDs (Kademlia metric). - pub fn xor_distance(&self, other: &NodeId) -> [u8; 32] { + fn xor_distance(&self, other: &NodeId) -> [u8; 32]; + /// Number of leading zero bits in the XOR distance to `other`. + /// Returns 0..=256. Used to select the k-bucket index. + fn xor_leading_zeros(&self, other: &NodeId) -> u32; +} + +impl NodeIdDistance for NodeId { + fn xor_distance(&self, other: &NodeId) -> [u8; 32] { let mut out = [0u8; 32]; for i in 0..32 { out[i] = self.0[i] ^ other.0[i]; @@ -21,9 +24,7 @@ impl NodeId { out } - /// Number of leading zero bits in the XOR distance to `other`. - /// Returns 0..=256. Used to select the k-bucket index. - pub fn xor_leading_zeros(&self, other: &NodeId) -> u32 { + fn xor_leading_zeros(&self, other: &NodeId) -> u32 { let dist = self.xor_distance(other); let mut zeros = 0u32; for byte in dist { @@ -38,62 +39,6 @@ impl NodeId { } } -impl fmt::Debug for NodeId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "NodeId(")?; - for b in &self.0[..4] { - write!(f, "{:02x}", b)?; - } - write!(f, "\u{2026})") - } -} - -impl fmt::Display for NodeId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - for b in &self.0[..8] { - write!(f, "{:02x}", b)?; - } - write!(f, "\u{2026}") - } -} - -// ─── Signature ────────────────────────────────────────────────────────────── - -/// An ed25519 signature (64 bytes). -#[derive(Clone, Copy, PartialEq, Eq)] -pub struct Signature(pub [u8; 64]); - -impl Serialize for Signature { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_bytes(&self.0) - } -} - -impl<'de> Deserialize<'de> for Signature { - fn deserialize>(deserializer: D) -> Result { - let bytes: Vec = Deserialize::deserialize(deserializer)?; - if bytes.len() != 64 { - return Err(serde::de::Error::custom(format!( - "expected 64 bytes for Signature, got {}", - bytes.len() - ))); - } - let mut arr = [0u8; 64]; - arr.copy_from_slice(&bytes); - Ok(Signature(arr)) - } -} - -impl fmt::Debug for Signature { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Sig(")?; - for b in &self.0[..4] { - write!(f, "{:02x}", b)?; - } - write!(f, "\u{2026})") - } -} - // ─── MemberState ──────────────────────────────────────────────────────────── /// SWIM membership state for a node. diff --git a/crates/distribution/tests/kademlia_directory.rs b/crates/distribution/tests/kademlia_directory.rs index 3c00a0f..6f3e979 100644 --- a/crates/distribution/tests/kademlia_directory.rs +++ b/crates/distribution/tests/kademlia_directory.rs @@ -1,5 +1,5 @@ use swactor::actor::ActorAddress; -use distribution::crypto::Keypair; +use distribution::crypto::{Keypair, KeypairExt}; use distribution::kademlia::directory::{ actor_addr_as_node_id, resolve_quorum, DirectoryShard, QuorumResult, }; diff --git a/crates/distribution/tests/kademlia_routing.rs b/crates/distribution/tests/kademlia_routing.rs index 8392728..03d209b 100644 --- a/crates/distribution/tests/kademlia_routing.rs +++ b/crates/distribution/tests/kademlia_routing.rs @@ -1,5 +1,5 @@ use distribution::kademlia::routing_table::RoutingTable; -use distribution::types::NodeId; +use distribution::types::{NodeId, NodeIdDistance}; fn node(byte: u8) -> NodeId { NodeId([byte; 32]) diff --git a/crates/distribution/tests/node_integration.rs b/crates/distribution/tests/node_integration.rs index 361a574..5a61581 100644 --- a/crates/distribution/tests/node_integration.rs +++ b/crates/distribution/tests/node_integration.rs @@ -7,7 +7,7 @@ mod common; use swactor::actor::ActorAddress; use common::{test_config, TestCluster}; -use distribution::crypto::Keypair; +use distribution::crypto::{Keypair, KeypairExt}; use distribution::node::{DistributedNode, DistributedNodeConfig, ResolveResult}; use distribution::swim::node::NodeAction; diff --git a/crates/distribution/tests/repair.rs b/crates/distribution/tests/repair.rs index 8afddc5..a85dd59 100644 --- a/crates/distribution/tests/repair.rs +++ b/crates/distribution/tests/repair.rs @@ -6,7 +6,7 @@ //! - Repair queue can be drained by the caller use swactor::actor::ActorAddress; -use distribution::crypto::Keypair; +use distribution::crypto::{Keypair, KeypairExt}; use distribution::kademlia::directory::DirectoryShard; use distribution::kademlia::repair::{RepairQueue, RepublishTracker}; diff --git a/crates/distribution/tests/transport_and_codec.rs b/crates/distribution/tests/transport_and_codec.rs index a5bb710..48f9a39 100644 --- a/crates/distribution/tests/transport_and_codec.rs +++ b/crates/distribution/tests/transport_and_codec.rs @@ -83,7 +83,7 @@ mod tcp_transport { use distribution::codec::distribution_codec_registry; use distribution::messages::*; - use distribution::transport::{TcpAcceptor, TcpTransport}; + use swactor_transport::tcp::{TcpAcceptor, TcpTransport}; use distribution::types::NodeId; #[test] diff --git a/crates/distribution/tests/types_and_crypto.rs b/crates/distribution/tests/types_and_crypto.rs index 4c1e804..4309e29 100644 --- a/crates/distribution/tests/types_and_crypto.rs +++ b/crates/distribution/tests/types_and_crypto.rs @@ -1,6 +1,6 @@ use swactor::actor::ActorAddress; -use distribution::crypto::{self, Keypair}; -use distribution::types::{DirectoryEntry, MemberState, NodeId, NodeRecord, Signature}; +use distribution::crypto::{self, Keypair, KeypairExt}; +use distribution::types::{DirectoryEntry, MemberState, NodeId, NodeIdDistance, NodeRecord, Signature}; // ─── Keypair generation and identity ──────────────────────────────────────── diff --git a/crates/swactor-node/Cargo.toml b/crates/node/Cargo.toml similarity index 78% rename from crates/swactor-node/Cargo.toml rename to crates/node/Cargo.toml index 7499647..1fc6a68 100644 --- a/crates/swactor-node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -1,22 +1,26 @@ [package] -name = "swactor-node" +name = "node" version = "0.1.0" edition = "2024" [dependencies] swactor = { path = "../..", features = ["serde", "tracing", "transport"] } -swactor-std = { path = "../std" } -dashboard = { path = "../dashboard", features = ["distribution"] } +dashboard = { path = "../dashboard" } swactor-datastore = { path = "../datastore" } +swactor-transport = { path = "../transport" } distribution = { path = "../distribution" } -swactor-streams = { path = "../streams" } clap = { version = "4", features = ["derive"] } ctrlc = "3" iroh = { version = "0.96", optional = true } toml = "0.8" serde = { version = "1", features = ["derive"] } +serde_json = "1" libc = "0.2" +[lib] +name = "swactor_node" +path = "src/lib.rs" + [features] default = ["iroh", "relay"] tcp = ["distribution/tcp"] diff --git a/crates/node/README.md b/crates/node/README.md new file mode 100644 index 0000000..bbeede9 --- /dev/null +++ b/crates/node/README.md @@ -0,0 +1,99 @@ +# node + +The `node` crate produces the `swactor` binary — the batteries-included entry point for running a swactor node. It composes the actor runtime, SWIM-based cluster membership, content-addressed datastore, data streams, an HTTP dashboard, and an optional embedded relay server into a single process. + +Run `swactor --help` for full CLI usage. + +## What a Default Node Does + +### Identity + +Every node has a persistent Ed25519 keypair stored at `~/.swactor/identity/node.key.json`. This is the node's identity across restarts — deleting it makes the node appear as a new peer to the cluster. The keypair's public key doubles as the node ID (used in SWIM, peer auth, and invite codes). A deterministic human-readable name (e.g. `swift-falcon`) is derived from the key so you can tell nodes apart in logs and the dashboard. + +### Networking + +The default transport is **iroh** (QUIC over UDP). Nodes find each other via invite codes — base58-encoded public keys exchanged out-of-band. `swactor join ` adds a peer to the allow-list and sets it as the seed node for the next startup. + +Once connected, **SWIM protocol** handles cluster membership: protocol probes every 500ms, 600ms probe timeout (tuned for relay round-trips), 2 indirect probes, 4s suspicion window. All intervals are in ticks where **1 tick = 100ms** (the main loop period). + +**Peer auth** operates in two modes: open (no `peers_file`) or allow-list (`peers.json`). In allow-list mode, SWIM messages from unknown nodes are dropped at the transport layer. New peers can be added via `swactor join` or the dashboard UI, both of which hot-update the allow-list. + +### Relay + +Nodes with a public IP auto-promote to embedded relay servers (port 3340). Candidacy is evaluated at startup: the node checks its outbound IP is non-RFC1918 and the relay port is bindable. Relay URLs are announced via SWIM gossip so other nodes discover them automatically. Nodes behind NAT use relays for indirect connectivity — this is why the probe timeout is 600ms instead of the typical 300ms. + +### Storage + +The **datastore** is a content-addressed, chunked store. Default config persists to `~/.swactor/datastore/`. Auth is enabled by default (ACL files in `~/.swactor/auth/`). The datastore runs as a group of actors inside the runtime and is driven by the main tick loop — GC runs every 1000 ticks (~100s) and dissemination every 50 ticks (~5s). A `StreamManager` actor bridges iroh QUIC streams into the datastore for bulk data transfer between nodes. + +Disable with `--no-datastore`. Use `--storage-path` to change location, or omit it from config for in-memory only. + +### Observability + +An HTTP dashboard serves on **port 9090**. It exposes runtime stats, cluster membership state, tracing output, and a peer management UI (add/remove peers). The dashboard receives a snapshot of the distribution layer every tick. + +### State & Lifecycle + +All persistent state lives under `~/.swactor/`. Deleting this directory fully resets the node (new identity, empty cluster, empty datastore). The node shuts down cleanly on SIGINT (Ctrl+C). `swactor install` copies the binary to `~/.swactor/bin/swactor` and registers it as a system service (systemd user unit, OpenRC/sysvinit init script, or `@reboot` crontab depending on the host). + +## Exposed Ports + +| Port | Service | Configurable via | +|------|---------|-----------------| +| 9090 | HTTP dashboard | `--dashboard-port` | +| 3340 | Embedded relay (if eligible) | `--relay-port` | + +## Data Directory (`~/.swactor/`) + +``` +~/.swactor/ +├── node.toml # Node configuration +├── peers.json # Peer allow-list +├── identity/ +│ └── node.key.json # Persistent Ed25519 keypair +├── datastore/ # Content-addressed chunk storage +├── auth/ # ACL and owner key files +└── bin/ + └── swactor # Installed binary (after `swactor install`) +``` + +## Architecture + +``` +CLI Args + TOML Config + │ + ▼ + Config Resolution (CLI > config > defaults) + │ + ▼ + Identity (Ed25519 Keypair) ──► Node Name + │ + ▼ + ┌─────────────────────────────────────────────┐ + │ Actor Runtime │ + │ (2 threads, StdExtension, stats hook) │ + │ │ + │ ┌──────────────┐ ┌──────────────────────┐ │ + │ │ StreamManager │ │ Datastore Group │ │ + │ │ (actor) │◄─┤ (store, gateway, │ │ + │ │ │ │ bridge actors) │ │ + │ └──────────────┘ └──────────────────────┘ │ + └──────────────┬──────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────┐ + │ Distribution Driver (iroh) │ + │ SWIM probes, gossip, relay │ + └──────────────┬───────────────────┘ + │ + ▼ + ┌──────────────────────────────────┐ + │ HTTP Dashboard │ + │ :9090 — stats, tracing, peers │ + └──────────────────────────────────┘ + │ + ▼ + Main Tick Loop (100ms) + recv → tick → streams → joins → + heartbeats → snapshot → datastore +``` diff --git a/crates/swactor-node/build.rs b/crates/node/build.rs similarity index 100% rename from crates/swactor-node/build.rs rename to crates/node/build.rs diff --git a/crates/swactor-node/src/config.rs b/crates/node/src/config.rs similarity index 95% rename from crates/swactor-node/src/config.rs rename to crates/node/src/config.rs index 3fa02e8..74c4c68 100644 --- a/crates/swactor-node/src/config.rs +++ b/crates/node/src/config.rs @@ -1,4 +1,4 @@ -//! TOML configuration file support for swactor-node. +//! TOML configuration file support for the swactor node binary. //! //! CLI flags take precedence over config file values, which take //! precedence over compiled defaults. diff --git a/crates/swactor-node/src/install.rs b/crates/node/src/install.rs similarity index 100% rename from crates/swactor-node/src/install.rs rename to crates/node/src/install.rs diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs new file mode 100644 index 0000000..66695e9 --- /dev/null +++ b/crates/node/src/lib.rs @@ -0,0 +1,6 @@ +pub mod config; +pub mod names; +pub mod plugins; +#[cfg(feature = "relay")] +pub mod relay; +pub mod install; diff --git a/crates/swactor-node/src/main.rs b/crates/node/src/main.rs similarity index 92% rename from crates/swactor-node/src/main.rs rename to crates/node/src/main.rs index e43520c..e816508 100644 --- a/crates/swactor-node/src/main.rs +++ b/crates/node/src/main.rs @@ -1,4 +1,4 @@ -//! swactor-node — unified distributed node with dashboard and datastore. +//! Swactor node — unified distributed node with dashboard and datastore. //! //! Combines distribution, runtime dashboard, and content-addressed datastore //! into a single batteries-included binary. Datastore is on by default @@ -14,26 +14,22 @@ use clap::{Parser, Subcommand}; use swactor::actor::{ActorInterface, Ctx}; use swactor::config::RuntimeConfig; use swactor::runtime::Runtime; -use distribution::crypto::Keypair; -use distribution::identity::{base58_encode, base58_decode, hex_encode, load_or_generate_keypair}; +use swactor::transport::NodeId; +use swactor_transport::crypto::Keypair; +use swactor_transport::identity::{base58_encode, base58_decode, hex_encode, load_or_generate_keypair}; use distribution::node::DistributedNodeConfig; use distribution::peer_auth::PeerAllowList; use distribution::snapshot::DistributionNodeSnapshot; use distribution::swim::probe::SwimConfig; -use distribution::types::NodeId; use dashboard::collector::StatsCollector; -use dashboard::distribution_collector::DistributionStatsProvider; use dashboard::{start_dashboard, DashboardConfig}; -use swactor_datastore::bridge::DatastoreNodeFactory; use swactor_datastore::{DatastoreAuthConfig, DatastoreGroup, DatastoreGroupConfig}; -mod config; -mod install; -mod names; +use swactor_node::{config, install, names, plugins}; #[cfg(feature = "relay")] -mod relay; +use swactor_node::relay; // ── CLI ────────────────────────────────────────────────────────────────── @@ -168,18 +164,6 @@ impl ActorInterface for HeartbeatActor { fn handle(&mut self, _ctx: &Ctx, _msg: Heartbeat) {} } -// ── Snapshot provider ──────────────────────────────────────────────────── - -struct SnapshotProvider { - snapshot: Arc>>, -} - -impl DistributionStatsProvider for SnapshotProvider { - fn snapshot(&self) -> Option { - self.snapshot.lock().unwrap().clone() - } -} - // ── Main ───────────────────────────────────────────────────────────────── fn default_config_dir() -> std::path::PathBuf { @@ -352,10 +336,15 @@ fn main() { cfg.listen.as_ref().and_then(|s| s.parse().ok()) }); - // Signal handler + // Signal handler — second Ctrl+C forces immediate exit { let stop = Arc::clone(&stop); ctrlc::set_handler(move || { + if stop.load(Ordering::Relaxed) { + eprintln!("\nForce exit."); + std::process::exit(1); + } + eprintln!("\nShutting down (press Ctrl+C again to force)..."); stop.store(true, Ordering::Relaxed); }) .expect("failed to set signal handler"); @@ -444,11 +433,10 @@ fn main() { .unwrap_or_else(|| names::generate_name(&keypair)); // Persist to config if not already set - if cfg.node_name.is_none() { - if let Some(config_path) = &resolved_config_path { + if cfg.node_name.is_none() + && let Some(config_path) = &resolved_config_path { persist_node_name(config_path, &node_name); } - } eprintln!("Node name: {node_name}"); @@ -484,7 +472,6 @@ fn main() { ..Default::default() }); dash.install_tracing(); - dash.set_peer_auth(Arc::clone(&peer_auth)); // Create actor runtime let num_threads = 2; @@ -495,7 +482,7 @@ fn main() { channel_buffer_size: 2000, ..Default::default() }) - .with_extension(Arc::new(swactor_std::StdExtension::new())); + .with_extension(Arc::new(swactor::std::StdExtension::new())); rt.set_stats_hook(collector.clone()); let handle = rt.run().expect("failed to start runtime"); @@ -522,18 +509,27 @@ fn main() { }, ) .expect("failed to start datastore"); - dash.set_datastore(group.bridge().clone()); + // Register datastore plugin + let ds_plugin = plugins::datastore::DatastorePlugin::from_group( + &group, + Arc::clone(&handle.runtime), + chunk_size, + ); + dash.register_plugin(Arc::new(ds_plugin)); Some(group) } else { eprintln!("Datastore: disabled"); + // Register stopped datastore plugin (allows starting from dashboard) + let ds_plugin = plugins::datastore::DatastorePlugin::stopped( + Arc::clone(&handle.runtime), + chunk_size, + ); + dash.register_plugin(Arc::new(ds_plugin)); None }; - // Always wire up the factory so the UI can start/stop datastore - let factory = DatastoreNodeFactory::new(Arc::clone(&handle.runtime), chunk_size); - dash.set_datastore_factory(Arc::new(factory)); - // Distribution config + eprintln!("Distribution: SWIM (transport: {transport})"); let swim_config = SwimConfig { probe_interval: 5, probe_timeout: 6, // 600ms — allows relay round-trip @@ -548,9 +544,15 @@ fn main() { ..Default::default() }; - // Channel for triggering SWIM joins (fed by dashboard "Add Peer") + // Channel for triggering SWIM joins (fed by peers plugin "Add Peer") let (join_tx, join_rx) = std::sync::mpsc::channel::(); - dash.set_join_sender(join_tx); + + // Register peers plugin + let peers_plugin = plugins::peers::PeersPlugin::new( + Arc::clone(&peer_auth), + Some(join_tx), + ); + dash.register_plugin(Arc::new(peers_plugin)); match transport.as_str() { #[cfg(feature = "iroh")] @@ -601,7 +603,6 @@ fn main() { } } - eprintln!("\nShutting down..."); handle.shutdown(); dash.shutdown(); handle.join(); @@ -629,7 +630,8 @@ fn run_tcp( use distribution::driver::NodeDriver; let listen_addr = listen.expect("--listen is required for TCP mode"); - let mut driver = NodeDriver::with_keypair(listen_addr, keypair, node_config) + let dist_keypair = distribution::crypto::Keypair::from_bytes(&keypair.secret_bytes()); + let mut driver = NodeDriver::with_keypair(listen_addr, dist_keypair, node_config) .expect("failed to create node driver"); eprintln!( @@ -648,16 +650,16 @@ fn run_tcp( // Spawn and register actors let actor_addrs = spawn_actors(actors, handle, driver.node_mut()); - // Wire distribution snapshot to dashboard + // Wire distribution snapshot to dashboard via plugin let mut snap = driver.snapshot(); snap.node_name = Some(node_name.clone()); snap.invite_code = Some(invite_code.clone()); let cached_snapshot: Arc>> = Arc::new(Mutex::new(Some(snap))); - let provider = SnapshotProvider { - snapshot: Arc::clone(&cached_snapshot), - }; - dash.set_distribution(Arc::new(provider)); + let dist_plugin = plugins::distribution::DistributionPlugin::new( + Arc::clone(&cached_snapshot), + ); + dash.register_plugin(Arc::new(dist_plugin)); // Start dashboard HTTP on a standalone tokio runtime (no iroh runtime in TCP mode) dash.start_http_standalone(); @@ -713,7 +715,7 @@ fn run_iroh( ) { use distribution::iroh_driver::{IrohDriver, IrohDriverConfig}; use iroh::{RelayMode, SecretKey}; - use swactor_std::RuntimeNaming; + use swactor::std::RuntimeNaming; // Evaluate relay candidacy and determine embedded relay bind address #[cfg(feature = "relay")] @@ -776,7 +778,7 @@ fn run_iroh( relay_mode, node: node_config, peer_auth: Some(peer_auth.clone()), - additional_alpns: vec![swactor_streams::ALPN.to_vec()], + additional_alpns: vec![swactor_datastore::streams::ALPN.to_vec()], #[cfg(feature = "relay")] embedded_relay_bind, #[cfg(feature = "relay")] @@ -785,7 +787,7 @@ fn run_iroh( let mut driver = IrohDriver::new(iroh_config).expect("failed to create iroh driver"); // Spawn StreamManager actor - let stream_mgr = swactor_streams::StreamManager::new( + let stream_mgr = swactor_datastore::streams::StreamManager::new( driver.endpoint().clone(), driver.tokio_handle(), Arc::clone(&handle.runtime), @@ -796,7 +798,7 @@ fn run_iroh( .expect("spawn StreamManager"); handle .runtime - .register_name(swactor_streams::STREAM_MANAGER_NAME, stream_mgr_addr) + .register_name(swactor_datastore::streams::STREAM_MANAGER_NAME, stream_mgr_addr) .expect("register StreamManager"); // Wire streams into the datastore @@ -834,16 +836,16 @@ fn run_iroh( false }; - // Wire distribution snapshot to dashboard + // Wire distribution snapshot to dashboard via plugin let mut snap = driver.snapshot(); snap.node_name = Some(node_name.clone()); snap.invite_code = Some(invite_code.clone()); let cached_snapshot: Arc>> = Arc::new(Mutex::new(Some(snap))); - let provider = SnapshotProvider { - snapshot: Arc::clone(&cached_snapshot), - }; - dash.set_distribution(Arc::new(provider)); + let dist_plugin = plugins::distribution::DistributionPlugin::new( + Arc::clone(&cached_snapshot), + ); + dash.register_plugin(Arc::new(dist_plugin)); // Start dashboard HTTP on IrohDriver's tokio runtime dash.start_http(driver.tokio_handle()); @@ -863,7 +865,7 @@ fn run_iroh( let mgr_addr = stream_mgr_addr; let node_bytes = node_id.0; driver.tokio_handle().spawn(async move { - match swactor_streams::accept::handle_incoming( + match swactor_datastore::streams::accept::handle_incoming( node_bytes, conn, &rt_clone, mgr_addr, ) .await @@ -914,13 +916,12 @@ fn run_iroh( } // Lazily pick up home relay URL once the endpoint connects - if !home_relay_set { - if let Some(url) = driver.home_relay_url() { + if !home_relay_set + && let Some(url) = driver.home_relay_url() { eprintln!("Relay URL (home): {url}"); driver.node_mut().set_relay_url(Some(url.to_string())); home_relay_set = true; } - } for addr in &actor_addrs { let _ = handle.runtime.send_to(*addr, Heartbeat); diff --git a/crates/swactor-node/src/names.rs b/crates/node/src/names.rs similarity index 97% rename from crates/swactor-node/src/names.rs rename to crates/node/src/names.rs index 59feaf8..d20d52f 100644 --- a/crates/swactor-node/src/names.rs +++ b/crates/node/src/names.rs @@ -3,7 +3,7 @@ //! Produces deterministic `adjective-animal` names from a keypair's //! public key bytes, so the same identity always gets the same default name. -use distribution::crypto::Keypair; +use swactor_transport::crypto::Keypair; const ADJECTIVES: &[&str] = &[ "bold", "brave", "bright", "calm", "clever", diff --git a/crates/node/src/plugins/datastore.rs b/crates/node/src/plugins/datastore.rs new file mode 100644 index 0000000..2e232c9 --- /dev/null +++ b/crates/node/src/plugins/datastore.rs @@ -0,0 +1,592 @@ +//! Datastore plugin adapter for the dashboard plugin system. +//! +//! Wraps `DatastoreBridge` and `DatastoreNodeFactory` into a `DashboardPlugin` +//! that the dashboard can poll for snapshots and dispatch HTTP requests to. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use dashboard::plugin::{DashboardPlugin, PluginResponse}; + +use swactor_datastore::bridge::{DatastoreGroup, DatastoreGroupConfig}; +use swactor::runtime::Runtime; + +use crate::plugins::datastore::bridge_ops::BridgeOps; + +/// HTML page for the datastore plugin. +const DATASTORE_HTML: &str = include_str!("datastore_page.html"); + +/// Dashboard plugin that exposes datastore metrics, CRUD operations, and +/// lifecycle management through the generic plugin interface. +pub struct DatastorePlugin { + /// The bridge operations handle — either a live bridge or a factory-only state. + state: Mutex, +} + +enum DatastoreState { + Running(BridgeOps), + Stopped { + runtime: Arc, + default_chunk_size: u32, + }, +} + +impl DatastorePlugin { + /// Create a plugin wrapping an already-running datastore group. + pub fn from_group(group: &DatastoreGroup, runtime: Arc, default_chunk_size: u32) -> Self { + Self { + state: Mutex::new(DatastoreState::Running(BridgeOps::from_group( + group, + runtime, + default_chunk_size, + ))), + } + } + + /// Create a plugin with no running datastore (factory-only mode). + pub fn stopped(runtime: Arc, default_chunk_size: u32) -> Self { + Self { + state: Mutex::new(DatastoreState::Stopped { + runtime, + default_chunk_size, + }), + } + } +} + +impl DashboardPlugin for DatastorePlugin { + fn name(&self) -> &str { + "datastore" + } + + fn snapshot_json(&self) -> Option { + let state = self.state.lock().unwrap(); + match &*state { + DatastoreState::Running(ops) => { + let snap_json = ops.snapshot_json().unwrap_or_else(|| "null".into()); + Some(format!( + r#"{{"is_running":true,"snapshot":{}}}"#, + snap_json + )) + } + DatastoreState::Stopped { .. } => { + Some(r#"{"is_running":false,"snapshot":null}"#.to_string()) + } + } + } + + fn handle_request( + &self, + method: &str, + path: &str, + query: &HashMap, + body: &[u8], + ) -> PluginResponse { + match (method, path) { + ("GET", "list") => { + let state = self.state.lock().unwrap(); + let ops = match &*state { + DatastoreState::Running(ops) => ops, + _ => return PluginResponse::error(503, "datastore not running"), + }; + let scope = match query.get("scope").map(|s| s.as_str()) { + Some("local") => ListScope::Local, + _ => ListScope::Swarm, + }; + let name_filter = query.get("name").map(|s| s.as_str()); + match ops.list_objects(name_filter, scope) { + Ok(json) => PluginResponse::json(json), + Err(e) => PluginResponse::error(500, e), + } + } + ("GET", "get") => { + let state = self.state.lock().unwrap(); + let ops = match &*state { + DatastoreState::Running(ops) => ops, + _ => return PluginResponse::error(503, "datastore not running"), + }; + let hash = match query.get("hash") { + Some(h) => h.as_str(), + None => return PluginResponse::error(400, "missing ?hash= parameter"), + }; + match ops.get_object(hash) { + Ok(json) => PluginResponse::json(json), + Err(e) if e.contains("not found") => PluginResponse::error(404, e), + Err(e) => PluginResponse::error(500, e), + } + } + ("GET", "data") => { + let state = self.state.lock().unwrap(); + let ops = match &*state { + DatastoreState::Running(ops) => ops, + _ => return PluginResponse::error(503, "datastore not running"), + }; + let hash = match query.get("hash") { + Some(h) => h.as_str(), + None => return PluginResponse::error(400, "missing ?hash= parameter"), + }; + match ops.get_data(hash) { + Ok(data) => PluginResponse::Binary { + content_type: "application/octet-stream".into(), + data, + }, + Err(e) if e.contains("not found") => PluginResponse::error(404, e), + Err(e) => PluginResponse::error(500, e), + } + } + ("GET", "status") => { + let state = self.state.lock().unwrap(); + let ops = match &*state { + DatastoreState::Running(ops) => ops, + _ => return PluginResponse::error(503, "datastore not running"), + }; + match ops.node_status() { + Ok(json) => PluginResponse::json(json), + Err(e) => PluginResponse::error(500, e), + } + } + ("POST", "put") => { + let state = self.state.lock().unwrap(); + let ops = match &*state { + DatastoreState::Running(ops) => ops, + _ => return PluginResponse::error(503, "datastore not running"), + }; + let name = query.get("name").cloned(); + match ops.put_data(body.to_vec(), name) { + Ok(json) => PluginResponse::json(json), + Err(e) => PluginResponse::error(500, e), + } + } + ("POST", "delete") => { + let state = self.state.lock().unwrap(); + let ops = match &*state { + DatastoreState::Running(ops) => ops, + _ => return PluginResponse::error(503, "datastore not running"), + }; + let hash = match query.get("hash") { + Some(h) => h.as_str(), + None => return PluginResponse::error(400, "missing ?hash= parameter"), + }; + match ops.delete_object(hash) { + Ok(json) => PluginResponse::json(json), + Err(e) if e.contains("not found") => PluginResponse::error(404, e), + Err(e) => PluginResponse::error(500, e), + } + } + ("POST", "start") => { + let mut state = self.state.lock().unwrap(); + if matches!(&*state, DatastoreState::Running(_)) { + return PluginResponse::error(409, "datastore already running"); + } + let (runtime, chunk_size) = match &*state { + DatastoreState::Stopped { + runtime, + default_chunk_size, + } => (Arc::clone(runtime), *default_chunk_size), + _ => unreachable!(), + }; + let storage_path = query.get("storage_path").cloned(); + match start_datastore_group(&runtime, chunk_size, storage_path) { + Ok(ops) => { + *state = DatastoreState::Running(ops); + PluginResponse::json(r#"{"ok":true}"#.to_string()) + } + Err(e) => PluginResponse::error(500, e), + } + } + ("POST", "shutdown") => { + let mut state = self.state.lock().unwrap(); + let (runtime, chunk_size) = match &*state { + DatastoreState::Running(ops) => { + let _ = ops.shutdown_datastore(); + (Arc::clone(&ops.runtime), ops.default_chunk_size) + } + _ => return PluginResponse::error(503, "datastore not running"), + }; + *state = DatastoreState::Stopped { + runtime, + default_chunk_size: chunk_size, + }; + PluginResponse::json(r#"{"ok":true}"#.to_string()) + } + _ => PluginResponse::not_found(), + } + } + + fn html_page(&self) -> Option<&str> { + Some(DATASTORE_HTML) + } +} + +/// Scope filter for listing objects — mirrors the old `ListScope` from dashboard. +#[derive(Debug, Clone, Copy)] +enum ListScope { + Local, + Swarm, +} + +/// Start a new datastore group and return bridge operations. +fn start_datastore_group( + runtime: &Arc, + chunk_size: u32, + storage_path: Option, +) -> Result { + use swactor::transport::NodeId; + use std::time::{SystemTime, UNIX_EPOCH}; + + // Generate a unique node ID + let mut bytes = [0u8; 32]; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + for (i, b) in nanos.to_le_bytes().iter().enumerate() { + bytes[i % 32] ^= *b; + } + let pid = std::process::id(); + for (i, b) in pid.to_le_bytes().iter().enumerate() { + bytes[i + 16] ^= *b; + } + let node_id = NodeId(bytes); + let node_hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + + let group = DatastoreGroup::spawn( + Arc::clone(runtime), + DatastoreGroupConfig { + node_id, + node_id_hex: node_hex, + chunk_size, + storage_path, + auth: None, + gc_interval: 1000, + disseminate_interval: 50, + }, + )?; + + Ok(BridgeOps::from_group(&group, Arc::clone(runtime), chunk_size)) +} + +/// Internal module that extracts the bridge operations from `DatastoreBridge`. +/// +/// Instead of depending on the `DatastoreStatsProvider` trait (which we're +/// removing from the dashboard), we replicate the operations directly using +/// the datastore actor addresses and runtime. +mod bridge_ops { + use super::*; + use std::collections::BTreeMap; + use std::thread; + use std::time::{Duration, Instant}; + + use swactor::actor::ActorAddress; + use swactor::runtime::Inbox; + use swactor_datastore::chunking::reassemble_blob; + use swactor_datastore::messages::{DatastoreNodeMsg, DatastoreResponse, MetadataMsg}; + use swactor_datastore::metrics::DatastoreMetrics; + use swactor_datastore::types::ContentHash; + + const POLL_TIMEOUT: Duration = Duration::from_secs(5); + const POLL_INTERVAL: Duration = Duration::from_millis(1); + + fn poll_response( + inbox: &Inbox, + timeout: Duration, + ) -> Option { + let start = Instant::now(); + loop { + if let Some(resp) = inbox.try_recv() { + return Some(resp); + } + if start.elapsed() > timeout { + return None; + } + thread::sleep(POLL_INTERVAL); + } + } + + fn entry_to_json(entry: &swactor_datastore::types::ObjectEntry) -> serde_json::Value { + let node_hex: String = entry.node_id.0.iter().map(|b| format!("{b:02x}")).collect(); + serde_json::json!({ + "content_hash": entry.content_hash.to_hex(), + "name": entry.name, + "node_id": node_hex, + "tags": entry.tags, + "size_bytes": entry.size_bytes, + "created_at": entry.created_at, + }) + } + + fn manifest_to_json(manifest: &swactor_datastore::types::ObjectManifest) -> serde_json::Value { + let chunks: Vec = manifest + .chunks + .iter() + .map(|c| { + serde_json::json!({ + "hash": c.hash.to_hex(), + "offset": c.offset, + "size": c.size, + }) + }) + .collect(); + serde_json::json!({ + "content_hash": manifest.content_hash.to_hex(), + "chunks": chunks, + "total_size": manifest.total_size, + "chunk_size": manifest.chunk_size, + "content_type": manifest.content_type, + }) + } + + fn entries_to_json(entries: &[swactor_datastore::types::ObjectEntry]) -> Vec { + entries.iter().map(entry_to_json).collect() + } + + pub struct BridgeOps { + metrics: Arc, + pub runtime: Arc, + datastore_addr: ActorAddress, + metadata_addr: ActorAddress, + pub default_chunk_size: u32, + } + + impl BridgeOps { + pub fn from_group( + group: &DatastoreGroup, + runtime: Arc, + default_chunk_size: u32, + ) -> Self { + let datastore_addr = group.datastore_addr(); + let metadata_addr = group.metadata_addr(); + let metrics = Arc::new(DatastoreMetrics::new()); + + Self { + metrics, + runtime, + datastore_addr, + metadata_addr, + default_chunk_size, + } + } + + pub fn snapshot_json(&self) -> Option { + let snap = self.metrics.snapshot(); + serde_json::to_string(&snap).ok() + } + + pub fn list_objects( + &self, + name_filter: Option<&str>, + scope: super::ListScope, + ) -> Result { + let inbox = self + .runtime + .new_inbox::() + .map_err(|e| format!("failed to create inbox: {e}"))?; + + match scope { + super::ListScope::Local => { + let _ = self.runtime.send_to( + self.datastore_addr, + DatastoreNodeMsg::List { + name_filter: name_filter.map(|s| s.to_string()), + all: false, + reply_to: *inbox.addr(), + }, + ); + } + super::ListScope::Swarm => { + let _ = self.runtime.send_to( + self.metadata_addr, + MetadataMsg::ListLocal { + name_filter: name_filter.map(|s| s.to_string()), + reply_to: *inbox.addr(), + }, + ); + } + } + + match poll_response(&inbox, POLL_TIMEOUT) { + Some(DatastoreResponse::ListOk { entries }) => { + let json = + serde_json::json!({ "entries": entries_to_json(&entries) }).to_string(); + Ok(json) + } + Some(DatastoreResponse::Error { reason }) => Err(reason), + _ => Err("timeout".into()), + } + } + + pub fn get_object(&self, hash: &str) -> Result { + let content_hash = ContentHash::from_hex(hash) + .ok_or_else(|| "invalid content hash hex".to_string())?; + + let inbox = self + .runtime + .new_inbox::() + .map_err(|e| format!("failed to create inbox: {e}"))?; + + let _ = self.runtime.send_to( + self.datastore_addr, + DatastoreNodeMsg::Get { + content_hash, + reply_to: *inbox.addr(), + }, + ); + + match poll_response(&inbox, POLL_TIMEOUT) { + Some(DatastoreResponse::GetOk { entry, manifest }) => { + self.metrics.record_get(&content_hash.to_hex()); + let json = serde_json::json!({ + "entry": entry_to_json(&entry), + "manifest": manifest_to_json(&manifest), + }) + .to_string(); + Ok(json) + } + Some(DatastoreResponse::NotFound) => Err("not found".into()), + Some(DatastoreResponse::Error { reason }) => Err(reason), + _ => Err("timeout".into()), + } + } + + pub fn get_data(&self, hash: &str) -> Result, String> { + let content_hash = ContentHash::from_hex(hash) + .ok_or_else(|| "invalid content hash hex".to_string())?; + + let inbox = self + .runtime + .new_inbox::() + .map_err(|e| format!("failed to create inbox: {e}"))?; + + let _ = self.runtime.send_to( + self.datastore_addr, + DatastoreNodeMsg::Get { + content_hash, + reply_to: *inbox.addr(), + }, + ); + + self.metrics.record_get(&content_hash.to_hex()); + + let manifest = match poll_response(&inbox, POLL_TIMEOUT) { + Some(DatastoreResponse::GetOk { manifest, .. }) => manifest, + Some(DatastoreResponse::NotFound) => return Err("not found".into()), + Some(DatastoreResponse::Error { reason }) => return Err(reason), + _ => return Err("timeout".into()), + }; + + let mut chunk_data = Vec::new(); + for chunk_ref in &manifest.chunks { + let chunk_inbox = self + .runtime + .new_inbox::() + .map_err(|e| format!("failed to create inbox: {e}"))?; + + let _ = self.runtime.send_to( + self.datastore_addr, + DatastoreNodeMsg::ReadChunk { + hash: chunk_ref.hash, + reply_to: *chunk_inbox.addr(), + }, + ); + + match poll_response(&chunk_inbox, POLL_TIMEOUT) { + Some(DatastoreResponse::ChunkOk { hash, data }) => { + chunk_data.push((hash, data)); + } + _ => return Err("failed to read chunk".into()), + } + } + + reassemble_blob(&manifest, &chunk_data) + .map_err(|e| format!("reassembly failed: {e:?}")) + } + + pub fn put_data(&self, data: Vec, name: Option) -> Result { + let body_len = data.len(); + + let inbox = self + .runtime + .new_inbox::() + .map_err(|e| format!("failed to create inbox: {e}"))?; + + let _ = self.runtime.send_to( + self.datastore_addr, + DatastoreNodeMsg::Put { + data, + name: name.clone(), + tags: BTreeMap::new(), + reply_to: *inbox.addr(), + }, + ); + + match poll_response(&inbox, POLL_TIMEOUT) { + Some(DatastoreResponse::PutOk { content_hash }) => { + let hex = content_hash.to_hex(); + self.metrics + .record_put(&hex, name.as_deref(), body_len as u64); + let json = serde_json::json!({ "content_hash": hex }).to_string(); + Ok(json) + } + Some(DatastoreResponse::Error { reason }) => Err(reason), + _ => Err("timeout waiting for put response".into()), + } + } + + pub fn delete_object(&self, hash: &str) -> Result { + let content_hash = ContentHash::from_hex(hash) + .ok_or_else(|| "invalid content hash hex".to_string())?; + + let inbox = self + .runtime + .new_inbox::() + .map_err(|e| format!("failed to create inbox: {e}"))?; + + let _ = self.runtime.send_to( + self.datastore_addr, + DatastoreNodeMsg::Delete { + content_hash, + reply_to: *inbox.addr(), + }, + ); + + match poll_response(&inbox, POLL_TIMEOUT) { + Some(DatastoreResponse::DeleteOk { content_hash }) => { + let hex = content_hash.to_hex(); + self.metrics.record_delete(&hex, 0); + let json = serde_json::json!({ "content_hash": hex }).to_string(); + Ok(json) + } + Some(DatastoreResponse::NotFound) => Err("not found".into()), + Some(DatastoreResponse::Error { reason }) => Err(reason), + _ => Err("timeout".into()), + } + } + + pub fn node_status(&self) -> Result { + let inbox = self + .runtime + .new_inbox::() + .map_err(|e| format!("failed to create inbox: {e}"))?; + + let _ = self.runtime.send_to( + self.datastore_addr, + DatastoreNodeMsg::Status { + reply_to: *inbox.addr(), + }, + ); + + match poll_response(&inbox, POLL_TIMEOUT) { + Some(DatastoreResponse::NodeStatus { node_id }) => { + let hex: String = node_id.0.iter().map(|b| format!("{b:02x}")).collect(); + let json = serde_json::json!({ "node_id": hex }).to_string(); + Ok(json) + } + _ => Err("timeout".into()), + } + } + + pub fn shutdown_datastore(&self) -> Result<(), String> { + Ok(()) + } + } +} diff --git a/crates/dashboard/src/datastore_html.rs b/crates/node/src/plugins/datastore_page.html similarity index 97% rename from crates/dashboard/src/datastore_html.rs rename to crates/node/src/plugins/datastore_page.html index 4522338..4671a62 100644 --- a/crates/dashboard/src/datastore_html.rs +++ b/crates/node/src/plugins/datastore_page.html @@ -1,4 +1,3 @@ -pub const DATASTORE_HTML: &str = r##" @@ -194,8 +193,8 @@ pub const DATASTORE_HTML: &str = r##"
@@ -424,7 +423,7 @@ pub const DATASTORE_HTML: &str = r##" var btn = $('uploadBtn'); btn.disabled = true; btn.textContent = 'uploading...'; - var url = '/api/datastore/put'; + var url = '/api/plugin/datastore/put'; if (name) url += '?name=' + encodeURIComponent(name); fetch(url, { method: 'POST', body: file }) .then(function(r) { @@ -441,7 +440,7 @@ pub const DATASTORE_HTML: &str = r##" }; window.download = function(hash, filename) { - fetch('/api/datastore/data?hash=' + hash) + fetch('/api/plugin/datastore/data?hash=' + hash) .then(function(r) { if (!r.ok) throw new Error('not found'); return r.blob(); @@ -458,7 +457,7 @@ pub const DATASTORE_HTML: &str = r##" window.del = function(hash) { if (!confirm('Delete ' + hash.substring(0, 16) + '?')) return; - fetch('/api/datastore/delete?hash=' + hash, { method: 'POST' }) + fetch('/api/plugin/datastore/delete?hash=' + hash, { method: 'POST' }) .then(function(r) { if (!r.ok) return r.json().then(function(j) { throw new Error(j.error || r.statusText); }); toast('deleted', 'success'); @@ -467,7 +466,7 @@ pub const DATASTORE_HTML: &str = r##" }; window.showDetail = function(hash) { - fetch('/api/datastore/get?hash=' + hash) + fetch('/api/plugin/datastore/get?hash=' + hash) .then(function(r) { if (!r.ok) throw new Error('not found'); return r.json(); @@ -505,7 +504,7 @@ pub const DATASTORE_HTML: &str = r##" window.startDatastore = function() { $('startBtn').disabled = true; - fetch('/api/datastore/start', { method: 'POST' }) + fetch('/api/plugin/datastore/start', { method: 'POST' }) .then(function(r) { if (!r.ok) return r.json().then(function(j) { throw new Error(j.error || r.statusText); }); return r.json(); @@ -521,7 +520,7 @@ pub const DATASTORE_HTML: &str = r##" window.stopDatastore = function() { if (!confirm('Stop the datastore? In-memory data will be lost.')) return; $('stopBtn').disabled = true; - fetch('/api/datastore/shutdown', { method: 'POST' }) + fetch('/api/plugin/datastore/shutdown', { method: 'POST' }) .then(function(r) { if (!r.ok) return r.json().then(function(j) { throw new Error(j.error || r.statusText); }); return r.json(); @@ -568,4 +567,3 @@ pub const DATASTORE_HTML: &str = r##" -"##; diff --git a/crates/node/src/plugins/distribution.rs b/crates/node/src/plugins/distribution.rs new file mode 100644 index 0000000..c21ef67 --- /dev/null +++ b/crates/node/src/plugins/distribution.rs @@ -0,0 +1,62 @@ +//! Distribution plugin adapter for the dashboard plugin system. +//! +//! Wraps a cached `DistributionNodeSnapshot` into a `DashboardPlugin` that the +//! dashboard can poll for snapshots and serve the distribution page. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use dashboard::plugin::{DashboardPlugin, PluginResponse}; +use distribution::snapshot::DistributionNodeSnapshot; + +/// HTML page for the distribution plugin. +const DISTRIBUTION_HTML: &str = include_str!("distribution_page.html"); + +/// Dashboard plugin that exposes distribution node snapshots. +pub struct DistributionPlugin { + cached: Arc>>, +} + +impl DistributionPlugin { + pub fn new(cached: Arc>>) -> Self { + Self { cached } + } +} + +impl DashboardPlugin for DistributionPlugin { + fn name(&self) -> &str { + "distribution" + } + + fn snapshot_json(&self) -> Option { + let guard = self.cached.lock().unwrap(); + let snapshot = guard.as_ref()?; + serde_json::to_string(snapshot).ok() + } + + fn handle_request( + &self, + method: &str, + path: &str, + _query: &HashMap, + _body: &[u8], + ) -> PluginResponse { + match (method, path) { + ("GET", "") => { + let guard = self.cached.lock().unwrap(); + match guard.as_ref() { + Some(snapshot) => match serde_json::to_string(snapshot) { + Ok(json) => PluginResponse::json(json), + Err(_) => PluginResponse::json("{}".into()), + }, + None => PluginResponse::json("{}".into()), + } + } + _ => PluginResponse::not_found(), + } + } + + fn html_page(&self) -> Option<&str> { + Some(DISTRIBUTION_HTML) + } +} diff --git a/crates/dashboard/src/distribution_html.rs b/crates/node/src/plugins/distribution_page.html similarity index 99% rename from crates/dashboard/src/distribution_html.rs rename to crates/node/src/plugins/distribution_page.html index 00ff106..81032f5 100644 --- a/crates/dashboard/src/distribution_html.rs +++ b/crates/node/src/plugins/distribution_page.html @@ -1,4 +1,3 @@ -pub const DISTRIBUTION_HTML: &str = r##" @@ -167,8 +166,8 @@ pub const DISTRIBUTION_HTML: &str = r##"
@@ -852,7 +851,7 @@ pub const DISTRIBUTION_HTML: &str = r##" function setToken(t) { dashToken = t; } function fetchPeers() { - fetch('/api/peers') + fetch('/api/plugin/peers') .then(function(r) { return r.json(); }) .then(function(d) { renderPeers(d); }) .catch(function() {}); @@ -894,7 +893,7 @@ pub const DISTRIBUTION_HTML: &str = r##" var nid = document.getElementById('peerNodeId').value.trim(); var label = document.getElementById('peerLabel').value.trim(); if (!nid) return; - fetch('/api/peers/add', { + fetch('/api/plugin/peers/add', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + dashToken }, body: JSON.stringify({ node_id: nid, label: label }) @@ -906,7 +905,7 @@ pub const DISTRIBUTION_HTML: &str = r##" }; window.removePeer = function(nid) { - fetch('/api/peers/remove', { + fetch('/api/plugin/peers/remove', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + dashToken }, body: JSON.stringify({ node_id: nid }) @@ -1153,4 +1152,3 @@ pub const DISTRIBUTION_HTML: &str = r##" -"##; diff --git a/crates/node/src/plugins/mod.rs b/crates/node/src/plugins/mod.rs new file mode 100644 index 0000000..8c8c36f --- /dev/null +++ b/crates/node/src/plugins/mod.rs @@ -0,0 +1,3 @@ +pub mod datastore; +pub mod distribution; +pub mod peers; diff --git a/crates/node/src/plugins/peers.rs b/crates/node/src/plugins/peers.rs new file mode 100644 index 0000000..b363529 --- /dev/null +++ b/crates/node/src/plugins/peers.rs @@ -0,0 +1,280 @@ +//! Peers plugin adapter for the dashboard plugin system. +//! +//! Moves peer management from the dashboard's server.rs into a plugin that +//! handles add/remove/sync/list operations via `PeerAllowList`. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use dashboard::plugin::{DashboardPlugin, PluginResponse}; +use dashboard::JoinPeerInfo; +use swactor::transport::NodeId; +use swactor_transport::identity::{base58_decode, hex_decode}; +use distribution::peer_auth::PeerAllowList; + +/// Dashboard plugin that exposes peer management operations. +pub struct PeersPlugin { + peer_auth: Arc>, + join_sender: Option>, +} + +impl PeersPlugin { + pub fn new( + peer_auth: Arc>, + join_sender: Option>, + ) -> Self { + Self { + peer_auth, + join_sender, + } + } +} + +impl DashboardPlugin for PeersPlugin { + fn name(&self) -> &str { + "peers" + } + + fn snapshot_json(&self) -> Option { + let list = self.peer_auth.lock().unwrap(); + let is_open = list.is_open(); + let peers: Vec = list + .list_peers() + .iter() + .map(|p| { + serde_json::json!({ + "node_id": p.node_id, + "label": p.label, + }) + }) + .collect(); + let json = serde_json::json!({ + "mode": if is_open { "open" } else { "allow-list" }, + "peers": peers, + }); + Some(json.to_string()) + } + + fn handle_request( + &self, + method: &str, + path: &str, + _query: &HashMap, + body: &[u8], + ) -> PluginResponse { + match (method, path) { + ("GET", "list") => self.handle_list(), + ("POST", "add") => self.handle_add(body), + ("POST", "sync") => self.handle_sync(body), + ("POST", "remove") => self.handle_remove(body), + _ => PluginResponse::not_found(), + } + } + + fn html_page(&self) -> Option<&str> { + None + } +} + +impl PeersPlugin { + fn handle_list(&self) -> PluginResponse { + let list = self.peer_auth.lock().unwrap(); + let is_open = list.is_open(); + let peers: Vec = list + .list_peers() + .iter() + .map(|p| { + serde_json::json!({ + "node_id": p.node_id, + "label": p.label, + }) + }) + .collect(); + let json = serde_json::json!({ + "mode": if is_open { "open" } else { "allow-list" }, + "peers": peers, + }); + PluginResponse::json(json.to_string()) + } + + fn handle_add(&self, body: &[u8]) -> PluginResponse { + let body_str = match std::str::from_utf8(body) { + Ok(s) => s, + Err(_) => return PluginResponse::error(400, "invalid UTF-8"), + }; + + let parsed: serde_json::Value = match serde_json::from_str(body_str) { + Ok(v) => v, + Err(e) => return PluginResponse::error(400, format!("invalid JSON: {e}")), + }; + + let node_id_str = match parsed.get("node_id").and_then(|v| v.as_str()) { + Some(s) => s, + None => return PluginResponse::error(400, "missing node_id field"), + }; + let label = parsed + .get("label") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let bytes: [u8; 32] = if let Some(b) = hex_decode(node_id_str) { + match b.try_into() { + Ok(arr) => arr, + Err(_) => { + return PluginResponse::error(400, "invalid node_id (hex decoded to wrong length)"); + } + } + } else if let Some(arr) = base58_decode(node_id_str) { + arr + } else { + return PluginResponse::error(400, "invalid node_id (expected 64-char hex or base58)"); + }; + + let relay_url = parsed + .get("relay_url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let node_id = NodeId(bytes); + let mut list = self.peer_auth.lock().unwrap(); + list.add_peer(node_id, label); + if let Err(e) = list.save() { + eprintln!("warning: failed to persist peers.json: {e}"); + } + drop(list); + + // Trigger a SWIM join for the newly added peer + if let Some(tx) = &self.join_sender { + let _ = tx.send((bytes, relay_url)); + } + + PluginResponse::json(r#"{"ok":true}"#.to_string()) + } + + fn handle_sync(&self, body: &[u8]) -> PluginResponse { + let body_str = match std::str::from_utf8(body) { + Ok(s) => s, + Err(_) => return PluginResponse::error(400, "invalid UTF-8"), + }; + + let parsed: serde_json::Value = match serde_json::from_str(body_str) { + Ok(v) => v, + Err(e) => return PluginResponse::error(400, format!("invalid JSON: {e}")), + }; + + let peers = match parsed.get("peers").and_then(|v| v.as_array()) { + Some(arr) => arr, + None => return PluginResponse::error(400, "missing peers array"), + }; + + let mut parsed_peers: Vec<(NodeId, String)> = Vec::new(); + for peer in peers { + let node_id_str = match peer.get("node_id").and_then(|v| v.as_str()) { + Some(s) => s, + None => return PluginResponse::error(400, "peer missing node_id"), + }; + let label = peer + .get("label") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let bytes: [u8; 32] = if let Some(b) = hex_decode(node_id_str) { + match b.try_into() { + Ok(arr) => arr, + Err(_) => { + return PluginResponse::error( + 400, + format!("invalid node_id hex length for {node_id_str}"), + ); + } + } + } else if let Some(arr) = base58_decode(node_id_str) { + arr + } else { + return PluginResponse::error(400, format!("invalid node_id: {node_id_str}")); + }; + + parsed_peers.push((NodeId(bytes), label)); + } + + { + let mut list = self.peer_auth.lock().unwrap(); + for (node_id, label) in &parsed_peers { + list.add_peer(*node_id, label.clone()); + } + if let Err(e) = list.save() { + eprintln!("warning: failed to persist peers.json: {e}"); + } + } + + // Trigger a SWIM join to the seed peer if specified + let join_seed = parsed.get("join_seed").and_then(|v| v.as_str()); + if let Some(seed_str) = join_seed { + let seed_bytes: Option<[u8; 32]> = if let Some(b) = hex_decode(seed_str) { + b.try_into().ok() + } else { + base58_decode(seed_str) + }; + + if let Some(bytes) = seed_bytes { + let relay_url = peers.iter().find_map(|p| { + let nid = p.get("node_id").and_then(|v| v.as_str())?; + let peer_bytes: [u8; 32] = if let Some(b) = hex_decode(nid) { + b.try_into().ok()? + } else { + base58_decode(nid)? + }; + if peer_bytes == bytes { + p.get("relay_url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + } else { + None + } + }); + + if let Some(tx) = &self.join_sender { + let _ = tx.send((bytes, relay_url)); + } + } + } + + let added = parsed_peers.len(); + PluginResponse::json(format!(r#"{{"ok":true,"added":{added}}}"#)) + } + + fn handle_remove(&self, body: &[u8]) -> PluginResponse { + let body_str = match std::str::from_utf8(body) { + Ok(s) => s, + Err(_) => return PluginResponse::error(400, "invalid UTF-8"), + }; + + let parsed: serde_json::Value = match serde_json::from_str(body_str) { + Ok(v) => v, + Err(e) => return PluginResponse::error(400, format!("invalid JSON: {e}")), + }; + + let node_id_hex = match parsed.get("node_id").and_then(|v| v.as_str()) { + Some(s) => s, + None => return PluginResponse::error(400, "missing node_id field"), + }; + + let bytes = match hex_decode(node_id_hex) { + Some(b) if b.len() == 32 => b, + _ => { + return PluginResponse::error(400, "invalid node_id hex (must be 64 hex chars)"); + } + }; + + let node_id = NodeId(bytes.try_into().unwrap()); + let mut list = self.peer_auth.lock().unwrap(); + list.remove_peer(&node_id); + if let Err(e) = list.save() { + eprintln!("warning: failed to persist peers.json: {e}"); + } + + PluginResponse::json(r#"{"ok":true}"#.to_string()) + } +} diff --git a/crates/swactor-node/src/relay.rs b/crates/node/src/relay.rs similarity index 100% rename from crates/swactor-node/src/relay.rs rename to crates/node/src/relay.rs diff --git a/crates/process/Cargo.toml b/crates/process/Cargo.toml index 7ff3c55..4290131 100644 --- a/crates/process/Cargo.toml +++ b/crates/process/Cargo.toml @@ -11,6 +11,9 @@ ssh = ["dep:russh", "dep:russh-keys", "dep:tokio", "dep:async-trait"] swactor = { path = "../..", default-features = false, features = ["no_random"] } crossbeam-queue = "0.3.12" libc = "0.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" russh = { version = "0.46", optional = true } russh-keys = { version = "0.46", optional = true } tokio = { version = "1", features = ["rt", "time", "sync"], optional = true } diff --git a/crates/process/src/lib.rs b/crates/process/src/lib.rs index 4ccd085..fe10e15 100644 --- a/crates/process/src/lib.rs +++ b/crates/process/src/lib.rs @@ -5,12 +5,15 @@ pub mod event; pub mod local; pub mod message; pub mod mock; +pub mod pipeline; +pub mod pipeline_types; pub mod queue; pub mod session; pub mod spawn; pub mod subscriber; pub mod types; pub mod waker; +pub mod yaml; #[cfg(feature = "ssh")] pub mod ssh; diff --git a/crates/ci/src/pipeline.rs b/crates/process/src/pipeline.rs similarity index 98% rename from crates/ci/src/pipeline.rs rename to crates/process/src/pipeline.rs index 20f241e..c11646c 100644 --- a/crates/ci/src/pipeline.rs +++ b/crates/process/src/pipeline.rs @@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; -use crate::{JobDefinition, JobId, JobStatus, PipelineId, PipelineStatus}; +use crate::pipeline_types::{JobDefinition, JobId, JobStatus, PipelineId, PipelineStatus}; /// A pipeline execution: tracks the DAG of jobs and their statuses. #[derive(Debug, Clone)] @@ -97,11 +97,10 @@ impl PipelineExecution { ) { let to_skip = self.transitive_dependents(job_name); for dep_name in to_skip { - if let Some(dep_job) = self.jobs.get_mut(&dep_name) { - if dep_job.status == JobStatus::Pending { + if let Some(dep_job) = self.jobs.get_mut(&dep_name) + && dep_job.status == JobStatus::Pending { dep_job.status = JobStatus::Skipped; } - } } } @@ -241,7 +240,7 @@ mod tests { use std::collections::HashMap; use super::*; - use crate::JobDefinition; + use crate::pipeline_types::JobDefinition; fn make_job(name: &str, needs: &[&str]) -> JobDefinition { JobDefinition { diff --git a/crates/process/src/pipeline_types.rs b/crates/process/src/pipeline_types.rs new file mode 100644 index 0000000..a23bca4 --- /dev/null +++ b/crates/process/src/pipeline_types.rs @@ -0,0 +1,153 @@ +//! Core pipeline types extracted from the CI crate. +//! +//! General-purpose pipeline execution primitives: identifiers, statuses, +//! job definitions, and result types. No Forgejo-specific concerns. + +use std::collections::HashMap; +use std::fmt; + +use serde::{Deserialize, Serialize}; + +// ─── Core Identifiers ─────────────────────────────────────────────────────── + +/// Unique identifier for a pipeline execution. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct PipelineId(pub u64); + +impl fmt::Display for PipelineId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "pipeline-{}", self.0) + } +} + +/// Unique identifier for a job within a pipeline. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct JobId { + pub pipeline_id: PipelineId, + pub job_name: String, +} + +impl fmt::Display for JobId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}/{}", self.pipeline_id, self.job_name) + } +} + +// ─── Job Status ───────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum JobStatus { + Pending, + WaitingForProvisioner, + Provisioning, + Running, + Passed, + Failed { reason: String }, + Skipped, + Interrupted, +} + +impl JobStatus { + pub fn is_terminal(&self) -> bool { + matches!( + self, + JobStatus::Passed | JobStatus::Failed { .. } | JobStatus::Skipped | JobStatus::Interrupted + ) + } +} + +// ─── Pipeline Status ──────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PipelineStatus { + Pending, + Running, + Passed, + Failed, + Error { reason: String }, +} + +impl PipelineStatus { + pub fn is_terminal(&self) -> bool { + matches!( + self, + PipelineStatus::Passed | PipelineStatus::Failed | PipelineStatus::Error { .. } + ) + } +} + +// ─── Job Definition ───────────────────────────────────────────────────────── + +/// A parsed job from a pipeline YAML file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JobDefinition { + pub name: String, + pub run: Vec, + pub needs: Vec, + pub timeout_secs: u64, + pub docker: bool, + pub artifacts: Vec, + pub env: HashMap, +} + +// ─── Job Execution Results ────────────────────────────────────────────────── + +/// Streamed output from a running job. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JobProgress { + pub job_id: JobId, + pub output_line: String, +} + +/// Final result of a job execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JobComplete { + pub job_id: JobId, + pub result: Result, + pub artifacts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JobSuccess; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum JobFailure { + CommandFailed { exit_code: i32, last_lines: Vec }, + SshError(String), + ExecError(String), + Timeout, + Interrupted, +} + +impl fmt::Display for JobFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + JobFailure::CommandFailed { exit_code, .. } => { + write!(f, "command exited with code {exit_code}") + } + JobFailure::SshError(msg) => write!(f, "SSH error: {msg}"), + JobFailure::ExecError(msg) => write!(f, "exec error: {msg}"), + JobFailure::Timeout => write!(f, "job timed out"), + JobFailure::Interrupted => write!(f, "spot instance interrupted"), + } + } +} + +// ─── Local Pipeline Types ─────────────────────────────────────────────────── + +/// Job execution request for a local pipeline runner. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalStartJob { + pub job_id: JobId, + pub work_dir: String, + pub job_def: JobDefinition, + pub env_overrides: HashMap, +} + +/// Configuration for a local pipeline runner. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalPipelineConfig { + pub repo_url: String, + pub work_dir: String, + pub pipeline_yaml_path: String, +} diff --git a/crates/process/src/session.rs b/crates/process/src/session.rs index 64100b1..29d1869 100644 --- a/crates/process/src/session.rs +++ b/crates/process/src/session.rs @@ -182,13 +182,12 @@ impl ProcessSession { }); } // Backpressure: buffer if over limit - if let Some(limit) = self.spec.stdin_buffer_limit { - if self.flow.pending_stdin_bytes >= limit { + if let Some(limit) = self.spec.stdin_buffer_limit + && self.flow.pending_stdin_bytes >= limit { self.stdin_buffer_bytes += data.len(); self.stdin_buffer.push_back(data); return vec![]; } - } self.flow.pending_stdin_bytes += data.len(); vec![ProcessAction::WriteStdin { data }] } diff --git a/crates/process/src/types.rs b/crates/process/src/types.rs index 358d7a0..0705c6d 100644 --- a/crates/process/src/types.rs +++ b/crates/process/src/types.rs @@ -60,12 +60,8 @@ pub enum ProcessError { /// Passive tracking of stdin backpressure. #[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Default)] pub struct FlowControl { pub pending_stdin_bytes: usize, } -impl Default for FlowControl { - fn default() -> Self { - Self { pending_stdin_bytes: 0 } - } -} diff --git a/crates/ci/src/yaml.rs b/crates/process/src/yaml.rs similarity index 94% rename from crates/ci/src/yaml.rs rename to crates/process/src/yaml.rs index 2bf5df4..dc50f6c 100644 --- a/crates/ci/src/yaml.rs +++ b/crates/process/src/yaml.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use crate::{EventType, JobDefinition, WebhookEvent}; +use crate::pipeline_types::JobDefinition; /// Root of a `.ci.yml` file. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -72,6 +72,26 @@ impl RunCommand { } } +// ─── Webhook / trigger event ───────────────────────────────────────────────── + +/// Parsed event from a webhook or other trigger source. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebhookEvent { + pub event_type: EventType, + pub repo_owner: String, + pub repo_name: String, + pub branch: String, + pub commit_sha: String, + pub tag: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum EventType { + Push, + Tag, + Merge, +} + // ─── Parsing ──────────────────────────────────────────────────────────────── /// Parse a `.ci.yml` string into a `CiYaml`. @@ -270,7 +290,7 @@ pipelines: jobs: build: run: cargo build --release - artifacts: ["target/release/swactor-node"] + artifacts: ["target/release/swactor"] "#; let ci = parse_ci_yaml(yaml).unwrap(); assert_eq!(ci.pipelines.len(), 3); @@ -429,5 +449,4 @@ pipelines: let multi = to_job_definition("multi", &check.jobs["multi"]); assert_eq!(multi.run, vec!["cargo fmt -- --check", "cargo test"]); } - } diff --git a/crates/shared-types/Cargo.toml b/crates/shared-types/Cargo.toml deleted file mode 100644 index 5255abf..0000000 --- a/crates/shared-types/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "shared-types" -version = "0.1.0" -edition = "2024" - -[dependencies] -serde = { version = "1", features = ["derive"] } -blake3 = "1" diff --git a/crates/shared-types/src/pool.rs b/crates/shared-types/src/pool.rs deleted file mode 100644 index d19cc4f..0000000 --- a/crates/shared-types/src/pool.rs +++ /dev/null @@ -1,252 +0,0 @@ -//! Shared types for the pooled datastore protocol. -//! -//! Types live in `shared-types` because both `distribution` and `datastore` -//! depend on them. - -use std::fmt; - -use serde::{Deserialize, Serialize}; - -use crate::ContentHash; - -// Re-export NodeId-shaped bytes — pool uses the same 32-byte node identifier. -// The actual NodeId type lives in `distribution::types`, but we use raw [u8; 32] -// here to avoid a circular dependency. Callers convert as needed. - -// ─── PoolId ──────────────────────────────────────────────────────────────── - -/// Unique pool identifier: blake3(name_bytes). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct PoolId(pub [u8; 32]); - -impl PoolId { - /// Create a pool ID from a human-readable name. - pub fn from_name(name: &str) -> Self { - PoolId(*blake3::hash(name.as_bytes()).as_bytes()) - } - - /// Encode as lowercase hex string. - pub fn to_hex(&self) -> String { - let mut s = String::with_capacity(64); - for b in &self.0 { - use fmt::Write; - write!(s, "{:02x}", b).unwrap(); - } - s - } - - /// Parse a 64-character hex string into a PoolId. - pub fn from_hex(hex: &str) -> Option { - if hex.len() != 64 { - return None; - } - let mut bytes = [0u8; 32]; - for (i, chunk) in hex.as_bytes().chunks(2).enumerate() { - let hi = hex_digit(chunk[0])?; - let lo = hex_digit(chunk[1])?; - bytes[i] = (hi << 4) | lo; - } - Some(PoolId(bytes)) - } -} - -impl fmt::Display for PoolId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - for b in &self.0[..8] { - write!(f, "{:02x}", b)?; - } - write!(f, "\u{2026}") - } -} - -// ─── Pool Membership ─────────────────────────────────────────────────────── - -/// Whether a node is actively participating in a pool. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum PoolMemberState { - Active, - Left, -} - -/// A node's membership in a pool. Higher generation always wins. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct PoolMemberEntry { - pub pool_id: PoolId, - pub node_id: [u8; 32], - pub state: PoolMemberState, - pub generation: u64, -} - -// ─── Pool Capacity ───────────────────────────────────────────────────────── - -/// A node's storage capacity announcement. Higher generation always wins. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct PoolCapacityEntry { - pub pool_id: PoolId, - pub node_id: [u8; 32], - pub total_bytes: u64, - pub used_bytes: u64, - pub generation: u64, -} - -// ─── Content Location ────────────────────────────────────────────────────── - -/// Where a content hash is stored. Key: (pool_id, content_hash, node_id). -/// Higher generation wins. Tombstones indicate deletion. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ContentLocationEntry { - pub pool_id: PoolId, - pub content_hash: ContentHash, - pub node_id: [u8; 32], - pub generation: u64, - pub tombstone: bool, -} - -// ─── Pool ACL ────────────────────────────────────────────────────────────── - -/// Authorization for a node to join a pool. Higher generation wins. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct PoolACLEntry { - pub pool_id: PoolId, - pub node_id: [u8; 32], - pub granted_by: [u8; 32], - pub generation: u64, - pub revoked: bool, -} - -// ─── Tagged Union ────────────────────────────────────────────────────────── - -/// All pool entry variants, used for gossip serialization. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum PoolEntry { - Membership(PoolMemberEntry), - Capacity(PoolCapacityEntry), - ContentLocation(ContentLocationEntry), - ACL(PoolACLEntry), -} - -// ─── Pool Config ─────────────────────────────────────────────────────────── - -/// Configuration for a pool. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PoolConfig { - pub pool_name: String, - pub pool_id: PoolId, - /// Total bytes this node pledges to the pool. - pub capacity_bytes: u64, - /// Tombstone TTL in ticks before GC. - pub tombstone_ttl: u64, - /// GC interval in ticks. - pub gc_interval: u64, - /// Dissemination multiplier (Λ). - pub dissemination_lambda: usize, -} - -impl PoolConfig { - pub fn new(pool_name: &str, capacity_bytes: u64) -> Self { - Self { - pool_name: pool_name.to_string(), - pool_id: PoolId::from_name(pool_name), - capacity_bytes, - tombstone_ttl: 3600, - gc_interval: 1000, - dissemination_lambda: 3, - } - } -} - -// ─── Helpers ─────────────────────────────────────────────────────────────── - -fn hex_digit(b: u8) -> Option { - match b { - b'0'..=b'9' => Some(b - b'0'), - b'a'..=b'f' => Some(b - b'a' + 10), - b'A'..=b'F' => Some(b - b'A' + 10), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn pool_id_from_name_is_deterministic() { - let a = PoolId::from_name("test-pool"); - let b = PoolId::from_name("test-pool"); - assert_eq!(a, b); - } - - #[test] - fn pool_id_different_names_differ() { - let a = PoolId::from_name("pool-a"); - let b = PoolId::from_name("pool-b"); - assert_ne!(a, b); - } - - #[test] - fn pool_id_hex_roundtrip() { - let id = PoolId::from_name("roundtrip-test"); - let hex = id.to_hex(); - let recovered = PoolId::from_hex(&hex).unwrap(); - assert_eq!(id, recovered); - } - - #[test] - fn pool_entry_serde_roundtrip() { - let entry = PoolEntry::Membership(PoolMemberEntry { - pool_id: PoolId::from_name("test"), - node_id: [1u8; 32], - state: PoolMemberState::Active, - generation: 1, - }); - let bytes = serde_json::to_vec(&entry).unwrap(); - let recovered: PoolEntry = serde_json::from_slice(&bytes).unwrap(); - match recovered { - PoolEntry::Membership(m) => { - assert_eq!(m.node_id, [1u8; 32]); - assert_eq!(m.state, PoolMemberState::Active); - assert_eq!(m.generation, 1); - } - _ => panic!("wrong variant"), - } - } - - #[test] - fn higher_generation_wins_for_membership() { - let old = PoolMemberEntry { - pool_id: PoolId::from_name("test"), - node_id: [1u8; 32], - state: PoolMemberState::Active, - generation: 1, - }; - let new = PoolMemberEntry { - pool_id: PoolId::from_name("test"), - node_id: [1u8; 32], - state: PoolMemberState::Left, - generation: 2, - }; - assert!(new.generation > old.generation); - } - - #[test] - fn content_location_tombstone_semantics() { - let live = ContentLocationEntry { - pool_id: PoolId::from_name("test"), - content_hash: ContentHash::of(b"hello"), - node_id: [1u8; 32], - generation: 1, - tombstone: false, - }; - let dead = ContentLocationEntry { - pool_id: PoolId::from_name("test"), - content_hash: ContentHash::of(b"hello"), - node_id: [1u8; 32], - generation: 2, - tombstone: true, - }; - assert!(!live.tombstone); - assert!(dead.tombstone); - assert!(dead.generation > live.generation); - } -} diff --git a/crates/simulation/Cargo.toml b/crates/simulation/Cargo.toml index 6f77f91..a6ce71b 100644 --- a/crates/simulation/Cargo.toml +++ b/crates/simulation/Cargo.toml @@ -5,12 +5,12 @@ edition = "2024" [features] default = [] +distribution = ["dep:distribution"] gossip = ["dep:log"] dashboard = ["gossip", "dep:tiny_http", "dep:toml"] -ci = ["dep:swactor-ci"] [dependencies] -distribution = { path = "../distribution" } +distribution = { path = "../distribution", optional = true } swactor = { path = "../..", features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -18,11 +18,9 @@ getrandom = "0.2" log = { version = "0.4", optional = true } tiny_http = { version = "0.12", optional = true } toml = { version = "0.8", optional = true } -swactor-ci = { path = "../ci", optional = true } [dev-dependencies] -simulation = { path = ".", features = ["gossip", "ci"] } -swactor-ci = { path = "../ci" } +simulation = { path = ".", features = ["gossip", "distribution"] } [[example]] name = "gossip_sim" diff --git a/crates/simulation/src/distribution/properties.rs b/crates/simulation/src/distribution/properties.rs index 697451e..d309dfc 100644 --- a/crates/simulation/src/distribution/properties.rs +++ b/crates/simulation/src/distribution/properties.rs @@ -733,7 +733,7 @@ pub fn check_staggered_join( .snapshots_per_round .iter() .take(by_round) - .last() + .next_back() .map(|round_snaps| { expected_joined .iter() diff --git a/crates/simulation/src/distribution/sim.rs b/crates/simulation/src/distribution/sim.rs index 85472ce..7184aab 100644 --- a/crates/simulation/src/distribution/sim.rs +++ b/crates/simulation/src/distribution/sim.rs @@ -1,45 +1,15 @@ -use std::collections::{HashMap, HashSet}; - use distribution::node::{DistributedNode, DistributedNodeConfig, ResolveResult}; use distribution::swim::node::NodeAction; use distribution::swim::probe::SwimConfig; use distribution::types::NodeId; use swactor::actor::ActorAddress; +use crate::runner::NetworkState; +pub use crate::runner::{NetworkFault, NetworkTopology, NodeLocation, Partition}; use crate::trace::{Event, SimulationTrace}; use super::trace::{DistributionEventKind, DistributionSnapshot}; -/// Network location of a simulated node. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum NodeLocation { - /// Publicly reachable (e.g. cloud VPS). Can receive inbound from anyone. - Public, - /// Behind NAT. Can only receive inbound from same LAN group or via relay. - Nat { group: String }, - /// Completely firewalled — no inbound or outbound. - Firewalled, -} - -/// Network topology describing NAT/firewall/relay placement. -#[derive(Debug, Clone)] -pub struct NetworkTopology { - /// Per-node location (indexed by node_idx). Length must equal num_nodes. - pub locations: Vec, - /// Node indices that act as relay forwarders for cross-NAT traffic. - pub relay_nodes: Vec, -} - -/// A network partition between two sets of nodes. -/// Nodes in `side_a` cannot communicate with nodes in `side_b`. -#[derive(Debug, Clone)] -pub struct Partition { - pub side_a: Vec, - pub side_b: Vec, - /// If true, A→B is blocked but B→A works (asymmetric). - pub asymmetric: bool, -} - /// An action to execute at a specific round during the simulation. #[derive(Debug, Clone)] pub enum SimAction { @@ -57,21 +27,6 @@ pub enum SimAction { Introduce { node_a: usize, node_b: usize }, } -/// Schedule entry for network faults. -#[derive(Debug, Clone)] -pub enum NetworkFault { - /// Introduce a partition at the given round. - Partition { round: usize, partition: Partition }, - /// Heal a partition at the given round (restores full connectivity). - Heal { round: usize }, - /// Set message drop rate (0.0 = no drops, 1.0 = drop all). - SetDropRate { round: usize, rate: f64 }, - /// Per-link drop rate. rate=0.0 clears the fault. - LinkFault { round: usize, from: usize, to: usize, rate: f64, bidirectional: bool }, - /// Relay penalty — extra drop probability for relay-routed messages. - SetRelayPenalty { round: usize, rate: f64 }, -} - /// Configuration for a distribution simulation run. #[derive(Debug, Clone)] pub struct DistributionSimConfig { @@ -130,185 +85,6 @@ impl Default for DistributionSimConfig { } } -/// Tracks active network state during simulation. -struct NetworkState { - /// Set of (from_idx, to_idx) pairs where messages are blocked. - blocked: HashSet<(usize, usize)>, - /// Probability of dropping a message [0.0, 1.0]. - drop_rate: f64, - /// Simple counter-based deterministic "random" for drop decisions. - drop_counter: u64, - /// Optional NAT/firewall topology. - topology: Option, - /// Per-node alive status (indexed by node_idx). - alive: Vec, - /// Per-link drop rates (from, to) -> rate. - link_drop_rates: HashMap<(usize, usize), f64>, - /// Extra drop probability for relay-routed messages. - relay_penalty: f64, -} - -impl NetworkState { - fn new() -> Self { - Self { - blocked: HashSet::new(), - drop_rate: 0.0, - drop_counter: 0x853c49e6748fea9b, - topology: None, - alive: Vec::new(), - link_drop_rates: HashMap::new(), - relay_penalty: 0.0, - } - } - - fn new_with_topology(topology: Option, num_nodes: usize) -> Self { - Self { - blocked: HashSet::new(), - drop_rate: 0.0, - drop_counter: 0x853c49e6748fea9b, - topology, - alive: vec![true; num_nodes], - link_drop_rates: HashMap::new(), - relay_penalty: 0.0, - } - } - - fn set_alive(&mut self, idx: usize, alive: bool) { - if idx < self.alive.len() { - self.alive[idx] = alive; - } - } - - fn apply_fault(&mut self, fault: &NetworkFault, num_nodes: usize) { - match fault { - NetworkFault::Partition { partition, .. } => { - for &a in &partition.side_a { - for &b in &partition.side_b { - if a < num_nodes && b < num_nodes { - self.blocked.insert((a, b)); - if !partition.asymmetric { - self.blocked.insert((b, a)); - } - } - } - } - } - NetworkFault::Heal { .. } => { - self.blocked.clear(); - } - NetworkFault::SetDropRate { rate, .. } => { - self.drop_rate = rate.clamp(0.0, 1.0); - } - NetworkFault::LinkFault { from, to, rate, bidirectional, .. } => { - let rate = rate.clamp(0.0, 1.0); - if rate == 0.0 { - self.link_drop_rates.remove(&(*from, *to)); - if *bidirectional { - self.link_drop_rates.remove(&(*to, *from)); - } - } else { - self.link_drop_rates.insert((*from, *to), rate); - if *bidirectional { - self.link_drop_rates.insert((*to, *from), rate); - } - } - } - NetworkFault::SetRelayPenalty { rate, .. } => { - self.relay_penalty = rate.clamp(0.0, 1.0); - } - } - } - - /// Check if `from` can directly initiate a connection to `to`. - fn directly_reachable(&self, from: usize, to: usize) -> bool { - let topo = match &self.topology { - Some(t) => t, - None => return true, // No topology = full connectivity - }; - if from >= topo.locations.len() || to >= topo.locations.len() { - return true; - } - match (&topo.locations[from], &topo.locations[to]) { - (_, NodeLocation::Firewalled) => false, - (NodeLocation::Firewalled, _) => false, - (_, NodeLocation::Public) => true, // Anyone can reach public - (NodeLocation::Public, NodeLocation::Nat { .. }) => false, // Can't initiate inbound to NAT - (NodeLocation::Nat { group: g1 }, NodeLocation::Nat { group: g2 }) => g1 == g2, // Same LAN - } - } - - /// Check if two nodes can communicate (bidirectional once established). - /// Either direct reachability in either direction, or via a relay. - fn can_reach(&self, from: usize, to: usize) -> bool { - let topo = match &self.topology { - Some(t) => t, - None => return true, - }; - // Direct: if either side can initiate, the connection is bidirectional - if self.directly_reachable(from, to) || self.directly_reachable(to, from) { - return true; - } - // Relay path: any alive relay R where both endpoints can bidirectionally reach R - for &r in &topo.relay_nodes { - if r == from || r == to { - continue; - } - if !self.alive.get(r).copied().unwrap_or(false) { - continue; - } - let from_reaches_r = self.directly_reachable(from, r) || self.directly_reachable(r, from); - let to_reaches_r = self.directly_reachable(to, r) || self.directly_reachable(r, to); - if from_reaches_r && to_reaches_r { - return true; - } - } - false - } - - /// Returns true when neither direction is directly reachable but a relay path exists. - fn requires_relay(&self, from: usize, to: usize) -> bool { - if self.topology.is_none() { - return false; - } - if self.directly_reachable(from, to) || self.directly_reachable(to, from) { - return false; - } - self.can_reach(from, to) - } - - /// Returns true if this message should be delivered. - fn should_deliver(&mut self, from_idx: usize, to_idx: usize) -> bool { - // 1. Check partition blocks - if self.blocked.contains(&(from_idx, to_idx)) { - return false; - } - // 2. Check NAT reachability (only if topology is set) - if self.topology.is_some() && !self.can_reach(from_idx, to_idx) { - return false; - } - // 3. Determine effective drop rate: per-link if set, else global - let base_rate = self.link_drop_rates - .get(&(from_idx, to_idx)) - .copied() - .unwrap_or(self.drop_rate); - // 4. Compose relay penalty if applicable - let effective_rate = if self.relay_penalty > 0.0 && self.requires_relay(from_idx, to_idx) { - 1.0 - (1.0 - base_rate) * (1.0 - self.relay_penalty) - } else { - base_rate - }; - // 5. Apply effective rate via LCG PRNG - if effective_rate > 0.0 { - self.drop_counter = self.drop_counter.wrapping_mul(6364136223846793005).wrapping_add(1); - let r = (self.drop_counter >> 33) as f64 / (u32::MAX as f64); - if r < effective_rate { - return false; - } - } - true - } -} - pub type DistTrace = SimulationTrace; /// Run a distribution simulation, returning both the trace and the final node states. @@ -424,8 +200,8 @@ fn run_simulation_inner(config: DistributionSimConfig) -> (DistTrace, Vec