diff --git a/Cargo.lock b/Cargo.lock index 4078588..38a5374 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,24 +17,126 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "base64", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite 0.24.0", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + [[package]] name = "cc" version = "1.2.62" @@ -69,6 +171,73 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -82,7 +251,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -103,12 +272,36 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -122,11 +315,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-sink", "futures-task", "pin-project-lite", "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -161,6 +376,86 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -185,12 +480,115 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -233,12 +631,27 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" version = "0.4.29" @@ -268,12 +681,56 @@ dependencies = [ "uuid", ] +[[package]] +name = "maestro-registry" +version = "0.1.0" +dependencies = [ + "axum", + "chrono", + "dirs", + "futures-util", + "maestro-protocol", + "rand", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "tokio-tungstenite 0.21.0", + "toml", + "tracing", + "uuid", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -289,12 +746,42 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -329,6 +816,47 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[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", +] + [[package]] name = "rustix" version = "1.1.4" @@ -339,7 +867,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -348,6 +876,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "semver" version = "1.0.28" @@ -397,6 +931,49 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -409,6 +986,28 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "syn" version = "2.0.117" @@ -420,6 +1019,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -427,10 +1043,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -453,6 +1069,210 @@ dependencies = [ "syn", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.21.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.24.0", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "url", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -465,18 +1285,54 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", ] +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -633,6 +1489,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -642,6 +1507,72 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -736,6 +1667,109 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 1cfd6e0..bf52613 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,12 +10,20 @@ repository = "https://git.naps.pt/naps62/maestro" publish = false [workspace.dependencies] +axum = { version = "0.7", features = ["ws"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } +dirs = "5" +futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } maestro-protocol = { path = "crates/maestro-protocol" } +rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" tempfile = "3" thiserror = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util"] } +tokio-tungstenite = "0.21" +toml = "0.8" +tracing = "0.1" uuid = { version = "1", features = ["v4", "serde"] } [profile.release] diff --git a/crates/maestro-registry/Cargo.toml b/crates/maestro-registry/Cargo.toml new file mode 100644 index 0000000..0451e8f --- /dev/null +++ b/crates/maestro-registry/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "maestro-registry" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true +description = "Always-on registry — daemon presence index and session catalog" + +[dependencies] +axum.workspace = true +chrono.workspace = true +dirs.workspace = true +futures-util.workspace = true +maestro-protocol.workspace = true +rand.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +toml.workspace = true +tracing.workspace = true +uuid.workspace = true + +[dev-dependencies] +tempfile.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "test-util"] } +tokio-tungstenite.workspace = true diff --git a/crates/maestro-registry/src/handler.rs b/crates/maestro-registry/src/handler.rs new file mode 100644 index 0000000..c47404f --- /dev/null +++ b/crates/maestro-registry/src/handler.rs @@ -0,0 +1,165 @@ +//! Per-connection handler for the `WS /daemon` endpoint. +//! +//! The handler is split from the axum-specific glue so it can be exercised +//! directly with an in-process pair of channels in tests. The axum side just +//! adapts a `WebSocket` into the trait below. + +use std::time::Duration; + +use axum::extract::ws::{Message, WebSocket}; +use futures_util::{SinkExt, StreamExt}; +use maestro_protocol::{DaemonInbound, DaemonOutbound, ErrorInfo}; +use tokio::sync::mpsc; + +use crate::state::Registry; + +/// Bound the registry's per-daemon outbound queue. 64 in-flight R→D requests +/// is plenty for a single daemon and bounds memory use under back-pressure. +const OUTBOUND_BUFFER: usize = 64; + +/// Run the handshake + message loop on a freshly-accepted WebSocket. Returns +/// the `daemon_id` once the connection terminates (for logging). +/// +/// `heartbeat_timeout` is the maximum gap allowed between inbound frames — +/// once exceeded, the connection is dropped and the daemon's sessions are +/// removed from the catalog. Production code passes +/// [`crate::state::HEARTBEAT_TIMEOUT`]; tests use a short value. +pub async fn handle_socket( + socket: WebSocket, + registry: Registry, + expected_token: &str, + heartbeat_timeout: Duration, +) -> Option { + let (mut sink, mut stream) = socket.split(); + + let hello = match read_hello(&mut stream).await { + Some(h) => h, + None => return None, + }; + + let (daemon_id, hostname, version, token) = match hello { + DaemonOutbound::Hello { + daemon_id, + hostname, + version, + token, + } => (daemon_id, hostname, version, token), + _ => { + let _ = send_error(&mut sink, "expected_hello", "first message must be Hello").await; + return None; + } + }; + + if token != expected_token { + let _ = send_error(&mut sink, "auth_failed", "invalid token").await; + return None; + } + + let (out_tx, mut out_rx) = mpsc::channel::(OUTBOUND_BUFFER); + registry + .register(daemon_id.clone(), hostname, version, out_tx) + .await; + + let _ = sink + .send(Message::Text( + serde_json::to_string(&DaemonInbound::Ack).expect("ack serialize"), + )) + .await; + + loop { + tokio::select! { + inbound = tokio::time::timeout(heartbeat_timeout, stream.next()) => { + match inbound { + Err(_) => { + tracing::info!(%daemon_id, "heartbeat timeout — dropping"); + break; + } + Ok(None) => break, + Ok(Some(Err(e))) => { + tracing::info!(%daemon_id, ?e, "ws read error"); + break; + } + Ok(Some(Ok(Message::Close(_)))) => break, + Ok(Some(Ok(Message::Text(text)))) => { + if !dispatch_text(®istry, &daemon_id, &text).await { + // malformed; close the connection + break; + } + } + Ok(Some(Ok(Message::Binary(_) | Message::Ping(_) | Message::Pong(_)))) => { + // Daemons only send text frames on this endpoint. + } + } + } + Some(msg) = out_rx.recv() => { + let text = serde_json::to_string(&msg).expect("serialize daemon-inbound"); + if sink.send(Message::Text(text)).await.is_err() { + break; + } + } + } + } + + registry.deregister(&daemon_id).await; + Some(daemon_id) +} + +/// Reads the first text frame, parses it as a `DaemonOutbound`, returns it. +async fn read_hello( + stream: &mut futures_util::stream::SplitStream, +) -> Option { + match tokio::time::timeout(Duration::from_secs(5), stream.next()).await { + Ok(Some(Ok(Message::Text(t)))) => serde_json::from_str(&t).ok(), + _ => None, + } +} + +async fn dispatch_text(registry: &Registry, daemon_id: &str, text: &str) -> bool { + let parsed: DaemonOutbound = match serde_json::from_str(text) { + Ok(p) => p, + Err(e) => { + tracing::warn!(%daemon_id, error = %e, "malformed message"); + return false; + } + }; + match parsed { + DaemonOutbound::Hello { .. } => { + tracing::warn!(%daemon_id, "second Hello on live connection — ignoring"); + } + DaemonOutbound::SessionsSnapshot { sessions } => { + registry.apply_snapshot(daemon_id, sessions).await; + } + DaemonOutbound::SessionAdded { session } => { + registry.add_session(daemon_id, session).await; + } + DaemonOutbound::SessionRemoved { id } => { + registry.remove_session(daemon_id, id).await; + } + DaemonOutbound::SessionUpdated { session } => { + registry.update_session(daemon_id, session).await; + } + DaemonOutbound::Heartbeat => { + // Per-read timeout above is the heartbeat watchdog; no extra work. + } + DaemonOutbound::CreateSessionResult { reply_to, result } => { + registry.complete_create(reply_to, result).await; + } + DaemonOutbound::KillSessionResult { reply_to, result } => { + registry.complete_kill(reply_to, result).await; + } + } + true +} + +async fn send_error( + sink: &mut futures_util::stream::SplitSink, + code: &str, + message: &str, +) -> Result<(), axum::Error> { + let err = DaemonInbound::Error(ErrorInfo { + code: code.into(), + message: message.into(), + }); + let text = serde_json::to_string(&err).expect("serialize error"); + sink.send(Message::Text(text)).await +} diff --git a/crates/maestro-registry/src/lib.rs b/crates/maestro-registry/src/lib.rs new file mode 100644 index 0000000..b341ec6 --- /dev/null +++ b/crates/maestro-registry/src/lib.rs @@ -0,0 +1,16 @@ +//! The always-on Maestro registry. +//! +//! This crate covers the daemon-facing surface only — there is no client HTTP +//! API in NAP-213. See `docs/superpowers/specs/2026-05-24-maestro-design.md` +//! §Registry and §"Wire protocol (D↔R)". + +#![deny(missing_debug_implementations)] + +pub mod handler; +pub mod server; +pub mod state; +pub mod token; + +pub use server::{router, router_with_timeout, RegistryServer}; +pub use state::{DaemonView, Registry, RegistryError, RpcError, HEARTBEAT_TIMEOUT, RPC_TIMEOUT}; +pub use token::{ensure_token, RegistryConfig, TokenError}; diff --git a/crates/maestro-registry/src/server.rs b/crates/maestro-registry/src/server.rs new file mode 100644 index 0000000..06cac6a --- /dev/null +++ b/crates/maestro-registry/src/server.rs @@ -0,0 +1,83 @@ +//! axum router + listener for the daemon-facing WS endpoint. +//! +//! The router exposes only `WS /daemon` in NAP-213. Client-facing HTTP arrives +//! in NAP-215. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::ws::WebSocketUpgrade; +use axum::extract::State; +use axum::response::IntoResponse; +use axum::routing::any; +use axum::Router; +use tokio::net::TcpListener; + +use crate::handler::handle_socket; +use crate::state::{Registry, HEARTBEAT_TIMEOUT}; + +#[derive(Debug, Clone)] +struct AppState { + registry: Registry, + token: Arc, + heartbeat_timeout: Duration, +} + +/// Build the daemon-facing router with the production heartbeat timeout. +pub fn router(registry: Registry, token: String) -> Router { + router_with_timeout(registry, token, HEARTBEAT_TIMEOUT) +} + +/// Build the daemon-facing router with a custom heartbeat timeout. Mainly for +/// integration tests that want to verify the timeout path without waiting 30s. +pub fn router_with_timeout( + registry: Registry, + token: String, + heartbeat_timeout: Duration, +) -> Router { + Router::new() + .route("/daemon", any(daemon_ws)) + .with_state(AppState { + registry, + token: Arc::new(token), + heartbeat_timeout, + }) +} + +async fn daemon_ws(State(state): State, ws: WebSocketUpgrade) -> impl IntoResponse { + let registry = state.registry.clone(); + let token = state.token.clone(); + let timeout = state.heartbeat_timeout; + ws.on_upgrade(move |socket| async move { + let _ = handle_socket(socket, registry, token.as_str(), timeout).await; + }) +} + +/// Convenience: bind a TCP listener and serve the router on it. +#[derive(Debug)] +pub struct RegistryServer { + pub addr: SocketAddr, + pub registry: Registry, +} + +impl RegistryServer { + /// Bind to `bind_addr` and serve in-place. Returns the bound `SocketAddr` + /// (so callers passing `:0` can discover it) and a handle to the registry + /// state. The future runs until the listener is dropped. + pub async fn bind( + bind_addr: SocketAddr, + token: String, + ) -> std::io::Result<(Self, impl std::future::Future>)> { + let listener = TcpListener::bind(bind_addr).await?; + let addr = listener.local_addr()?; + let registry = Registry::new(); + let app = router(registry.clone(), token); + let server = Self { + addr, + registry: registry.clone(), + }; + let fut = async move { axum::serve(listener, app).await }; + Ok((server, fut)) + } +} diff --git a/crates/maestro-registry/src/state.rs b/crates/maestro-registry/src/state.rs new file mode 100644 index 0000000..e0f8e8a --- /dev/null +++ b/crates/maestro-registry/src/state.rs @@ -0,0 +1,304 @@ +//! In-memory registry state shared across all daemon connections. +//! +//! The state holds: +//! +//! - one [`DaemonEntry`] per connected daemon, keyed by `daemon_id` +//! - one entry in the session catalog per `(daemon_id, session_id)` pair +//! - one pending-reply oneshot per outstanding R→D RPC +//! +//! Concurrent access is guarded by a single `Mutex` — the registry handles a +//! handful of daemons and these critical sections are tiny. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; +use std::time::Duration; + +use maestro_protocol::{ + CreateSessionRequest, DaemonInbound, ErrorInfo, ReplyTo, RpcResult, Session, +}; +use tokio::sync::{mpsc, oneshot, Mutex}; +use uuid::Uuid; + +/// How long a daemon can go without sending us *any* message before it is +/// considered offline and its sessions are dropped from the catalog. +pub const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(30); + +/// How long we wait for a `CreateSessionResult` / `KillSessionResult` after +/// sending the matching R→D request. Spec §"Wire protocol (D↔R)". +pub const RPC_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Debug, thiserror::Error)] +pub enum RegistryError { + #[error("daemon `{0}` is not connected")] + DaemonNotConnected(String), + #[error("daemon outbound channel closed before send")] + DaemonGone, + #[error("rpc reply channel closed before reply arrived")] + ReplyChannelClosed, + #[error("rpc timed out after {0:?}")] + Timeout(Duration), +} + +/// Distinct error type for RPC failures bubbled up by the daemon. Separated +/// from [`RegistryError`] so callers can distinguish transport from daemon- +/// reported faults. +#[derive(Debug, thiserror::Error)] +#[error("daemon rpc failed: {0:?}")] +pub struct RpcError(pub ErrorInfo); + +/// Public, read-only view of a single connected daemon. +#[derive(Debug, Clone)] +pub struct DaemonView { + pub daemon_id: String, + pub hostname: String, + pub version: String, +} + +/// Shared, cloneable registry handle. +#[derive(Debug, Clone)] +pub struct Registry { + inner: Arc>, +} + +#[derive(Debug, Default)] +struct State { + daemons: HashMap, + /// `(daemon_id, session_id) -> Session`. + sessions: BTreeMap<(String, Uuid), Session>, + pending_create: HashMap>>, + pending_kill: HashMap>>, +} + +#[derive(Debug)] +struct DaemonEntry { + hostname: String, + version: String, + outbound: mpsc::Sender, +} + +impl Registry { + pub fn new() -> Self { + Self { + inner: Arc::new(Mutex::new(State::default())), + } + } + + // ----------------------------------------------------------------------- + // Connection lifecycle + // ----------------------------------------------------------------------- + + /// Register a freshly-authenticated daemon. Replaces any previous entry + /// with the same `daemon_id` (and drops its sessions), matching the + /// spec's "re-registration restores catalog" expectation. + pub async fn register( + &self, + daemon_id: String, + hostname: String, + version: String, + outbound: mpsc::Sender, + ) { + let mut state = self.inner.lock().await; + state.drop_daemon_sessions(&daemon_id); + state.daemons.insert( + daemon_id, + DaemonEntry { + hostname, + version, + outbound, + }, + ); + } + + /// Remove the daemon and any sessions it owned. Also fails any pending + /// RPCs that were sent to it. + pub async fn deregister(&self, daemon_id: &str) { + let mut state = self.inner.lock().await; + state.daemons.remove(daemon_id); + state.drop_daemon_sessions(daemon_id); + } + + // ----------------------------------------------------------------------- + // Catalog mutations driven by inbound D→R messages + // ----------------------------------------------------------------------- + + pub async fn apply_snapshot(&self, daemon_id: &str, sessions: Vec) { + let mut state = self.inner.lock().await; + state.drop_daemon_sessions(daemon_id); + for s in sessions { + state.sessions.insert((daemon_id.to_string(), s.id), s); + } + } + + pub async fn add_session(&self, daemon_id: &str, session: Session) { + let mut state = self.inner.lock().await; + state + .sessions + .insert((daemon_id.to_string(), session.id), session); + } + + pub async fn remove_session(&self, daemon_id: &str, id: Uuid) { + let mut state = self.inner.lock().await; + state.sessions.remove(&(daemon_id.to_string(), id)); + } + + pub async fn update_session(&self, daemon_id: &str, session: Session) { + let mut state = self.inner.lock().await; + state + .sessions + .insert((daemon_id.to_string(), session.id), session); + } + + /// Resolve a CreateSession RPC reply. Returns true if a waiter was found. + pub async fn complete_create(&self, reply_to: ReplyTo, result: RpcResult) -> bool { + let mut state = self.inner.lock().await; + match state.pending_create.remove(&reply_to) { + Some(tx) => tx.send(result).is_ok(), + None => false, + } + } + + /// Resolve a KillSession RPC reply. Returns true if a waiter was found. + pub async fn complete_kill(&self, reply_to: ReplyTo, result: RpcResult<()>) -> bool { + let mut state = self.inner.lock().await; + match state.pending_kill.remove(&reply_to) { + Some(tx) => tx.send(result).is_ok(), + None => false, + } + } + + // ----------------------------------------------------------------------- + // Outbound R→D requests (internal API for NAP-215) + // ----------------------------------------------------------------------- + + /// Forward a `CreateSession` to the named daemon and wait for the matching + /// `CreateSessionResult`. Returns the daemon-reported result on success. + pub async fn create_session( + &self, + daemon_id: &str, + request: CreateSessionRequest, + ) -> Result, RegistryError> { + let reply_to = Uuid::new_v4(); + let (tx, rx) = oneshot::channel(); + let outbound = { + let mut state = self.inner.lock().await; + state.pending_create.insert(reply_to, tx); + state + .daemons + .get(daemon_id) + .ok_or_else(|| RegistryError::DaemonNotConnected(daemon_id.to_string()))? + .outbound + .clone() + }; + let send_result = outbound + .send(DaemonInbound::CreateSession { reply_to, request }) + .await; + if send_result.is_err() { + self.inner.lock().await.pending_create.remove(&reply_to); + return Err(RegistryError::DaemonGone); + } + await_rpc(rx, reply_to, |s| &mut s.pending_create, &self.inner) + .await + .map(rpc_into_result) + } + + /// Forward a `KillSession` to the named daemon and wait for the matching + /// `KillSessionResult`. + pub async fn kill_session( + &self, + daemon_id: &str, + id: Uuid, + ) -> Result, RegistryError> { + let reply_to = Uuid::new_v4(); + let (tx, rx) = oneshot::channel(); + let outbound = { + let mut state = self.inner.lock().await; + state.pending_kill.insert(reply_to, tx); + state + .daemons + .get(daemon_id) + .ok_or_else(|| RegistryError::DaemonNotConnected(daemon_id.to_string()))? + .outbound + .clone() + }; + let send_result = outbound + .send(DaemonInbound::KillSession { reply_to, id }) + .await; + if send_result.is_err() { + self.inner.lock().await.pending_kill.remove(&reply_to); + return Err(RegistryError::DaemonGone); + } + await_rpc(rx, reply_to, |s| &mut s.pending_kill, &self.inner) + .await + .map(rpc_into_result) + } + + // ----------------------------------------------------------------------- + // Read-only views (mostly for tests) + // ----------------------------------------------------------------------- + + pub async fn daemons(&self) -> Vec { + let state = self.inner.lock().await; + state + .daemons + .iter() + .map(|(id, e)| DaemonView { + daemon_id: id.clone(), + hostname: e.hostname.clone(), + version: e.version.clone(), + }) + .collect() + } + + pub async fn sessions(&self) -> Vec { + let state = self.inner.lock().await; + state.sessions.values().cloned().collect() + } + + pub async fn sessions_for(&self, daemon_id: &str) -> Vec { + let state = self.inner.lock().await; + state + .sessions + .iter() + .filter(|((d, _), _)| d == daemon_id) + .map(|(_, s)| s.clone()) + .collect() + } +} + +impl Default for Registry { + fn default() -> Self { + Self::new() + } +} + +impl State { + fn drop_daemon_sessions(&mut self, daemon_id: &str) { + self.sessions.retain(|(d, _), _| d.as_str() != daemon_id); + } +} + +/// Await `rx` with [`RPC_TIMEOUT`]; on timeout / channel close, also clean up +/// the pending map so a late reply doesn't leak. +async fn await_rpc( + rx: oneshot::Receiver>, + reply_to: ReplyTo, + pick: impl FnOnce(&mut State) -> &mut HashMap>>, + inner: &Arc>, +) -> Result, RegistryError> { + match tokio::time::timeout(RPC_TIMEOUT, rx).await { + Ok(Ok(result)) => Ok(result), + Ok(Err(_)) => Err(RegistryError::ReplyChannelClosed), + Err(_) => { + let mut state = inner.lock().await; + pick(&mut state).remove(&reply_to); + Err(RegistryError::Timeout(RPC_TIMEOUT)) + } + } +} + +fn rpc_into_result(rpc: RpcResult) -> Result { + match rpc { + RpcResult::Ok { value } => Ok(value), + RpcResult::Err { error } => Err(RpcError(error)), + } +} diff --git a/crates/maestro-registry/src/token.rs b/crates/maestro-registry/src/token.rs new file mode 100644 index 0000000..13c8007 --- /dev/null +++ b/crates/maestro-registry/src/token.rs @@ -0,0 +1,100 @@ +//! First-run bootstrap of the shared registry/daemon token. +//! +//! On startup the registry looks for `~/.config/maestro/registry.toml`: +//! +//! - If present, loads `token` from it. +//! - If absent, generates a 256-bit random token, prints it once to stderr, +//! and persists it. Subsequent runs are silent. + +use std::path::{Path, PathBuf}; + +use rand::RngCore; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, thiserror::Error)] +pub enum TokenError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("toml decode: {0}")] + TomlDecode(#[from] toml::de::Error), + #[error("toml encode: {0}")] + TomlEncode(#[from] toml::ser::Error), + #[error("could not determine config directory (XDG_CONFIG_HOME / $HOME unset?)")] + NoConfigDir, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RegistryConfig { + pub token: String, +} + +/// Load or create the registry config file. On creation, prints the generated +/// token to stderr so the operator can copy it to daemons. +pub fn ensure_token(config_path: &Path) -> Result { + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent)?; + } + if config_path.exists() { + let bytes = std::fs::read(config_path)?; + let cfg: RegistryConfig = toml::from_str(std::str::from_utf8(&bytes).unwrap_or(""))?; + return Ok(cfg); + } + let token = generate_token(); + let cfg = RegistryConfig { + token: token.clone(), + }; + let body = toml::to_string_pretty(&cfg)?; + std::fs::write(config_path, body)?; + eprintln!( + "maestro-registry: generated new MAESTRO_TOKEN; deploy this to every daemon:\n {token}" + ); + Ok(cfg) +} + +/// Default config path: `~/.config/maestro/registry.toml`. Falls back to +/// `TokenError::NoConfigDir` if neither `XDG_CONFIG_HOME` nor `$HOME` are set. +pub fn default_config_path() -> Result { + let base = dirs::config_dir().ok_or(TokenError::NoConfigDir)?; + Ok(base.join("maestro").join("registry.toml")) +} + +fn generate_token() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + hex_encode(&bytes) +} + +fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for &b in bytes { + out.push(HEX[(b >> 4) as usize] as char); + out.push(HEX[(b & 0xf) as usize] as char); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn first_run_generates_persistent_token() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("registry.toml"); + let cfg = ensure_token(&path).unwrap(); + assert_eq!(cfg.token.len(), 64); // 32 bytes hex-encoded + + let again = ensure_token(&path).unwrap(); + assert_eq!(cfg, again, "second call must return same token"); + } + + #[test] + fn creates_parent_directory() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested/here/registry.toml"); + let cfg = ensure_token(&path).unwrap(); + assert!(path.exists()); + assert_eq!(cfg.token.len(), 64); + } +} diff --git a/crates/maestro-registry/tests/daemon_ws.rs b/crates/maestro-registry/tests/daemon_ws.rs new file mode 100644 index 0000000..fb46549 --- /dev/null +++ b/crates/maestro-registry/tests/daemon_ws.rs @@ -0,0 +1,378 @@ +//! End-to-end tests for the `WS /daemon` endpoint, driven by a real +//! `tokio-tungstenite` client against an axum server bound to `:0`. + +use std::collections::HashMap; +use std::future::Future; +use std::net::{Ipv4Addr, SocketAddr}; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use chrono::{DateTime, Utc}; +use futures_util::{SinkExt, StreamExt}; +use maestro_protocol::{ + CreateSessionRequest, DaemonInbound, DaemonOutbound, RpcResult, Session, SessionStatus, +}; +use maestro_registry::{router_with_timeout, Registry}; +use tokio::net::TcpListener; +use tokio::sync::oneshot; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; +use uuid::Uuid; + +const TOKEN: &str = "test-token-please-allow"; +const TEST_HEARTBEAT: Duration = Duration::from_millis(500); + +struct Harness { + addr: SocketAddr, + registry: Registry, + _shutdown_tx: oneshot::Sender<()>, +} + +async fn spawn_registry() -> Harness { + spawn_registry_with_timeout(TEST_HEARTBEAT).await +} + +async fn spawn_registry_with_timeout(heartbeat: Duration) -> Harness { + let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) + .await + .unwrap(); + let addr = listener.local_addr().unwrap(); + let registry = Registry::new(); + let app = router_with_timeout(registry.clone(), TOKEN.to_string(), heartbeat); + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + tokio::spawn(async move { + let _ = axum::serve(listener, app) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }) + .await; + }); + Harness { + addr, + registry, + _shutdown_tx: shutdown_tx, + } +} + +type WsClient = WebSocketStream>; + +async fn connect(addr: SocketAddr) -> WsClient { + let url = format!("ws://{addr}/daemon"); + let (ws, _) = connect_async(url).await.expect("ws connect"); + ws +} + +async fn send_json(ws: &mut WsClient, msg: &DaemonOutbound) { + let text = serde_json::to_string(msg).unwrap(); + ws.send(WsMessage::Text(text)).await.unwrap(); +} + +async fn recv_inbound(ws: &mut WsClient) -> Option { + while let Some(Ok(msg)) = ws.next().await { + if let WsMessage::Text(t) = msg { + return Some(serde_json::from_str(&t).unwrap()); + } + } + None +} + +fn hello(token: &str) -> DaemonOutbound { + DaemonOutbound::Hello { + daemon_id: "laptop".into(), + hostname: "naps-laptop".into(), + version: "0.1.0".into(), + token: token.into(), + } +} + +fn sample_session(id: Uuid, daemon_id: &str) -> Session { + Session { + id, + daemon_id: daemon_id.to_string(), + cwd: PathBuf::from("/tmp"), + command: "bash".into(), + env: HashMap::new(), + metadata: HashMap::new(), + status: SessionStatus::Running, + created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z") + .unwrap() + .with_timezone(&Utc), + } +} + +async fn wait_for(timeout: Duration, mut cond: F) +where + F: FnMut() -> Fut, + Fut: Future, +{ + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if cond().await { + return; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + panic!("timed out after {timeout:?} waiting for condition"); +} + +// -------------------------------------------------------------------------- +// Tests +// -------------------------------------------------------------------------- + +#[tokio::test] +async fn rejects_bad_token() { + let h = spawn_registry().await; + let mut ws = connect(h.addr).await; + + send_json(&mut ws, &hello("wrong")).await; + let reply = recv_inbound(&mut ws).await.expect("error frame"); + match reply { + DaemonInbound::Error(e) => assert_eq!(e.code, "auth_failed"), + other => panic!("expected Error, got {other:?}"), + } + let _ = ws.next().await; + assert!(h.registry.daemons().await.is_empty()); +} + +#[tokio::test] +async fn accepts_good_token_and_snapshot() { + let h = spawn_registry().await; + let mut ws = connect(h.addr).await; + + send_json(&mut ws, &hello(TOKEN)).await; + let ack = recv_inbound(&mut ws).await.expect("ack"); + assert!(matches!(ack, DaemonInbound::Ack)); + + let sessions = vec![ + sample_session(Uuid::new_v4(), "laptop"), + sample_session(Uuid::new_v4(), "laptop"), + ]; + send_json( + &mut ws, + &DaemonOutbound::SessionsSnapshot { + sessions: sessions.clone(), + }, + ) + .await; + + let expected = sessions.len(); + let registry = h.registry.clone(); + wait_for(Duration::from_secs(2), || { + let r = registry.clone(); + async move { r.sessions().await.len() == expected } + }) + .await; + + let daemons = h.registry.daemons().await; + assert_eq!(daemons.len(), 1); + assert_eq!(daemons[0].daemon_id, "laptop"); +} + +#[tokio::test] +async fn applies_individual_diffs() { + let h = spawn_registry().await; + let mut ws = connect(h.addr).await; + + send_json(&mut ws, &hello(TOKEN)).await; + let _ = recv_inbound(&mut ws).await; + + let s1 = sample_session(Uuid::new_v4(), "laptop"); + let s2 = sample_session(Uuid::new_v4(), "laptop"); + + send_json( + &mut ws, + &DaemonOutbound::SessionAdded { + session: s1.clone(), + }, + ) + .await; + send_json( + &mut ws, + &DaemonOutbound::SessionAdded { + session: s2.clone(), + }, + ) + .await; + + let registry = h.registry.clone(); + wait_for(Duration::from_secs(2), || { + let r = registry.clone(); + async move { r.sessions().await.len() == 2 } + }) + .await; + + let mut s1_updated = s1.clone(); + s1_updated.command = "fish".into(); + send_json( + &mut ws, + &DaemonOutbound::SessionUpdated { + session: s1_updated.clone(), + }, + ) + .await; + send_json(&mut ws, &DaemonOutbound::SessionRemoved { id: s2.id }).await; + + let registry = h.registry.clone(); + wait_for(Duration::from_secs(2), || { + let r = registry.clone(); + async move { + let s = r.sessions().await; + s.len() == 1 && s[0].command == "fish" + } + }) + .await; +} + +#[tokio::test] +async fn heartbeat_timeout_drops_daemon() { + let h = spawn_registry_with_timeout(Duration::from_millis(200)).await; + let mut ws = connect(h.addr).await; + + send_json(&mut ws, &hello(TOKEN)).await; + let _ = recv_inbound(&mut ws).await; + send_json( + &mut ws, + &DaemonOutbound::SessionAdded { + session: sample_session(Uuid::new_v4(), "laptop"), + }, + ) + .await; + + let registry = h.registry.clone(); + wait_for(Duration::from_secs(2), || { + let r = registry.clone(); + async move { r.sessions().await.len() == 1 } + }) + .await; + + // Hold the WS open but never send another frame. The server's per-read + // timeout should fire after 200ms and drop the daemon. + let registry = h.registry.clone(); + wait_for(Duration::from_secs(2), || { + let r = registry.clone(); + async move { r.daemons().await.is_empty() && r.sessions().await.is_empty() } + }) + .await; +} + +#[tokio::test] +async fn re_registration_replaces_catalog() { + let h = spawn_registry().await; + + let mut ws1 = connect(h.addr).await; + send_json(&mut ws1, &hello(TOKEN)).await; + let _ = recv_inbound(&mut ws1).await; + let s_old = sample_session(Uuid::new_v4(), "laptop"); + send_json( + &mut ws1, + &DaemonOutbound::SessionAdded { + session: s_old.clone(), + }, + ) + .await; + + let registry = h.registry.clone(); + wait_for(Duration::from_secs(2), || { + let r = registry.clone(); + async move { r.sessions().await.len() == 1 } + }) + .await; + + drop(ws1); + + let registry = h.registry.clone(); + wait_for(Duration::from_secs(2), || { + let r = registry.clone(); + async move { r.daemons().await.is_empty() } + }) + .await; + + let mut ws2 = connect(h.addr).await; + send_json(&mut ws2, &hello(TOKEN)).await; + let _ = recv_inbound(&mut ws2).await; + let s_new = sample_session(Uuid::new_v4(), "laptop"); + send_json( + &mut ws2, + &DaemonOutbound::SessionsSnapshot { + sessions: vec![s_new.clone()], + }, + ) + .await; + + let registry = h.registry.clone(); + let want_id = s_new.id; + wait_for(Duration::from_secs(2), || { + let r = registry.clone(); + async move { + let s = r.sessions().await; + s.len() == 1 && s[0].id == want_id + } + }) + .await; +} + +#[tokio::test] +async fn create_session_rpc_round_trip() { + let h = spawn_registry().await; + let mut ws = connect(h.addr).await; + + send_json(&mut ws, &hello(TOKEN)).await; + let _ = recv_inbound(&mut ws).await; + + let registry = h.registry.clone(); + let request = CreateSessionRequest { + daemon_id: "laptop".into(), + cwd: PathBuf::from("/tmp"), + command: "bash".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }; + let rpc = tokio::spawn(async move { registry.create_session("laptop", request).await }); + + let inbound = recv_inbound(&mut ws).await.expect("CreateSession"); + let (reply_to, _request) = match inbound { + DaemonInbound::CreateSession { reply_to, request } => (reply_to, request), + other => panic!("expected CreateSession, got {other:?}"), + }; + + let session = sample_session(Uuid::new_v4(), "laptop"); + send_json( + &mut ws, + &DaemonOutbound::CreateSessionResult { + reply_to, + result: RpcResult::Ok { + value: session.clone(), + }, + }, + ) + .await; + + let outcome = rpc.await.unwrap().expect("rpc transport ok"); + let returned = outcome.expect("rpc daemon ok"); + assert_eq!(returned.id, session.id); +} + +#[tokio::test] +async fn create_session_to_unknown_daemon_errors() { + let h = spawn_registry().await; + let err = h + .registry + .create_session( + "no-such-daemon", + CreateSessionRequest { + daemon_id: "no-such-daemon".into(), + cwd: PathBuf::from("/tmp"), + command: "bash".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }, + ) + .await + .unwrap_err(); + assert!(matches!( + err, + maestro_registry::RegistryError::DaemonNotConnected(_) + )); +}