diff --git a/workers-dev/Cargo.lock b/workers-dev/Cargo.lock index 52181dcee4..053de5243e 100644 --- a/workers-dev/Cargo.lock +++ b/workers-dev/Cargo.lock @@ -64,12 +64,50 @@ 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 = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[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 = "bytes" version = "1.12.0" @@ -91,12 +129,28 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "clap" version = "4.6.1" @@ -157,6 +211,31 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +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 = "crossterm" version = "0.28.1" @@ -182,6 +261,16 @@ dependencies = [ "winapi", ] +[[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 = "darling" version = "0.23.0" @@ -216,6 +305,39 @@ dependencies = [ "syn", ] +[[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 = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.16.0" @@ -244,24 +366,127 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "foldhash" 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-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "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", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -270,7 +495,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", ] [[package]] @@ -297,157 +522,518 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "ident_case" -version = "1.0.1" +name = "hostname" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] [[package]] -name = "indexmap" -version = "2.14.0" +name = "http" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ - "equivalent", - "hashbrown 0.17.1", + "bytes", + "itoa", ] [[package]] -name = "indoc" -version = "2.0.7" +name = "http-body" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ - "rustversion", + "bytes", + "http", ] [[package]] -name = "instability" -version = "0.3.12" +name = "http-body-util" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ - "darling", - "indoc", - "proc-macro2", - "quote", - "syn", + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", ] [[package]] -name = "is_terminal_polyfill" -version = "1.70.2" +name = "httparse" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] -name = "itertools" -version = "0.13.0" +name = "hyper" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ - "either", + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "hyper-rustls" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] [[package]] -name = "libc" -version = "0.2.186" +name = "hyper-util" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] [[package]] -name = "linux-raw-sys" -version = "0.4.15" +name = "icu_collections" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "icu_locale_core" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] [[package]] -name = "lock_api" -version = "0.4.14" +name = "icu_normalizer" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "scopeguard", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", ] [[package]] -name = "log" -version = "0.4.33" +name = "icu_normalizer_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] -name = "lru" -version = "0.12.5" +name = "icu_properties" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "hashbrown 0.15.5", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", ] [[package]] -name = "memchr" -version = "2.8.2" +name = "icu_properties_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] -name = "mio" -version = "1.2.1" +name = "icu_provider" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.61.2", + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "ident_case" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "idna" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] [[package]] -name = "parking_lot" -version = "0.12.5" +name = "idna_adapter" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ - "lock_api", - "parking_lot_core", + "icu_normalizer", + "icu_properties", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "iii-helpers" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "09daa7c14a9e4c1f7077c4a181918d207e3f05cdfea8b2d7781bbeb80caf4c5d" dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", + "futures-util", + "opentelemetry", + "opentelemetry-http", + "opentelemetry_sdk", + "reqwest", + "schemars", + "serde", + "serde_json", + "sysinfo", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "iii-sdk" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5957568413b9a5178c11bf91b20909e93e568b187e259e941ba6009a7ec3f5c1" +dependencies = [ + "async-trait", + "futures-util", + "hostname", + "iii-helpers", + "reqwest", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[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 = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand", + "thiserror", + "tokio", + "tokio-stream", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", ] [[package]] @@ -456,12 +1042,36 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[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 = "proc-macro2" version = "1.0.106" @@ -471,6 +1081,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.46" @@ -480,11 +1145,46 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] [[package]] name = "ratatui" @@ -516,6 +1216,64 @@ dependencies = [ "bitflags", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustix" version = "0.38.44" @@ -542,6 +1300,53 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -554,12 +1359,68 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.228" @@ -590,6 +1451,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.150" @@ -603,6 +1475,18 @@ dependencies = [ "zmij", ] +[[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 = "serde_yaml" version = "0.9.34+deprecated" @@ -616,6 +1500,23 @@ 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", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook" version = "0.3.18" @@ -647,12 +1548,34 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +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 = "static_assertions" version = "1.1.0" @@ -687,6 +1610,12 @@ dependencies = [ "syn", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.118" @@ -698,6 +1627,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -705,32 +1668,196 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "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 = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "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-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[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 = [ - "bytes", - "libc", - "mio", "pin-project-lite", - "signal-hook-registry", - "tokio-macros", - "windows-sys 0.61.2", + "tracing-attributes", + "tracing-core", ] [[package]] -name = "tokio-macros" -version = "2.7.0" +name = "tracing-attributes" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -738,30 +1865,45 @@ dependencies = [ ] [[package]] -name = "tokio-stream" -version = "0.1.18" +name = "tracing-core" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", - "tokio-util", + "once_cell", ] [[package]] -name = "tokio-util" -version = "0.7.18" +name = "try-lock" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -803,18 +1945,168 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "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 = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -837,12 +2129,116 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -877,6 +2273,15 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -925,6 +2330,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "workers-dev" version = "0.1.0" @@ -932,6 +2343,8 @@ dependencies = [ "anyhow", "clap", "crossterm", + "iii-sdk", + "libc", "ratatui", "serde", "serde_json", @@ -941,6 +2354,115 @@ dependencies = [ "tokio-stream", ] +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +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.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +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 = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[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/workers-dev/Cargo.toml b/workers-dev/Cargo.toml index 8b3274f897..8f19a15fae 100644 --- a/workers-dev/Cargo.toml +++ b/workers-dev/Cargo.toml @@ -14,12 +14,14 @@ path = "src/main.rs" [dependencies] anyhow = "1" clap = { version = "4", features = ["derive"] } +iii-sdk = "=0.20.0" crossterm = "0.28" +libc = "0.2" ratatui = "0.29" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time", "io-util", "process"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time", "io-util", "process", "net"] } tokio-stream = { version = "0.1", features = ["sync"] } [dev-dependencies] diff --git a/workers-dev/README.md b/workers-dev/README.md index c4d0628a2c..d3b4aee586 100644 --- a/workers-dev/README.md +++ b/workers-dev/README.md @@ -33,7 +33,9 @@ Workers are **discovered automatically** from top-level `*/iii.worker.yaml` in t | **harness stack** | `session-manager`, `llm-router`, `context-manager`, `provider-anthropic`, `provider-openai`, `approval-gate`, `harness` | `workers-dev up`, `Ctrl+u` in TUI, `workers-dev start` | | **other** | All remaining repo workers (e.g. `telegram-bot`, `shell`, `console`, …) | `workers-dev start `, `workers-dev start --all`, `Ctrl+a` in TUI | -Only **Rust `deploy: binary`** workers can be started with `cargo run`. Node/bundle workers show as `(iii worker add)` — install them via the iii registry instead. +Only **Rust `deploy: binary`** workers can be started with `cargo run`. Non-Rust (Node/bundle) workers show **Process: external** — install them via the iii registry (`iii worker add`) instead. + +A worker connected to the engine but not started by this `workers-dev` shows **Process: elsewhere** beside **Engine: connected** (`workers-dev` only tracks processes it spawned itself). Non-Rust workers installed via `iii worker add` show **Process: external**. ## Usage @@ -65,16 +67,22 @@ Use `--color never` or `NO_COLOR=1` to force plain output. Default `--color auto | Key | Action | |-----|--------| -| `↑`/`↓` | Select worker (skips group headers) | +| `↑`/`↓` (or `k`/`j`) | Select worker (skips group headers) | | `s` | Start selected worker | | `x` | Stop selected worker | -| `r` | Restart selected worker + dependents | -| `l` | Follow logs | +| `r` | Restart selected worker + dependents (confirm shows the blast radius) | +| `f` | Toggle live-follow of the selected worker's logs | +| `PgUp`/`PgDn` | Scroll the log pane (pauses follow; resumes at the bottom) | +| `+`/`-` | Resize the log pane (drags the divider in two columns, the height when stacked) | +| `/` | Filter workers by name (Enter applies, Esc clears) | | `Ctrl+u` | Start harness stack | | `Ctrl+a` | Start all managed Rust workers | +| `?` | Toggle the key-reference overlay | | `q` | Quit | -The log pane shows the last 20 lines for the **selected worker only** (cargo build output + worker logs). Lines are sanitized (no ANSI, no `\r` overwrite garbage). +On a wide terminal the dashboard is a two-column **master/detail** layout: the worker list on the left (sized to fit its columns), the selected worker's logs filling the rest on the right, with `+`/`-` dragging the divider between them. Below ~100 columns the two panes stack vertically instead, and `+`/`-` trade height. + +The header shows an at-a-glance health summary (`●` connected, `◐` compiling, `✗` crashed, `○` stopped) and flags the engine as `⚠ unreachable` when a status query fails. The log pane shows the **selected worker only**, scrollable through the full ring buffer, following the live tail by default. Crashed workers show their exit code inline. Lines are sanitized (no ANSI, no `\r` overwrite garbage). ## Config (`workers-dev.yaml`) diff --git a/workers-dev/src/commands/mod.rs b/workers-dev/src/commands/mod.rs index 39ba4b7df6..0617bc6eb4 100644 --- a/workers-dev/src/commands/mod.rs +++ b/workers-dev/src/commands/mod.rs @@ -8,7 +8,10 @@ use crate::orchestrator::Orchestrator; use crate::status; pub async fn run_status(orchestrator: &Orchestrator) -> Result<()> { - let views = orchestrator.worker_views().await?; + let (views, engine_error) = orchestrator.dashboard_snapshot().await; + if let Some(err) = engine_error { + eprintln!("warning: engine unreachable: {err}"); + } status::print_status_table(&views); Ok(()) } @@ -49,20 +52,15 @@ pub async fn run_logs( follow: bool, lines: usize, ) -> Result<()> { - if !follow { - let tail = orchestrator.logs_tail(&worker, lines).await?; - let color_enabled = orchestrator.config.color_mode.enabled_for_stdout(); - for line in tail { - logs::print_colored_line(&line, color_enabled, &mut io::stdout())?; - } - return Ok(()); - } - let tail = orchestrator.logs_tail(&worker, lines).await?; let color_enabled = orchestrator.config.color_mode.enabled_for_stdout(); for line in tail { logs::print_colored_line(&line, color_enabled, &mut io::stdout())?; } + + if !follow { + return Ok(()); + } io::stdout().flush()?; let mut rx = orchestrator.subscribe_logs(&worker).await?; @@ -76,7 +74,8 @@ pub async fn run_logs( Err(tokio::sync::broadcast::error::RecvError::Closed) => { let proc = orchestrator.worker_views().await?; match proc.iter().find(|v| v.name == worker) { - Some(v) if v.process_status == "running" || v.process_status == "compiling" => {} + Some(v) if v.process_status == "running" || v.process_status == "compiling" => { + } _ => break, } tokio::time::sleep(std::time::Duration::from_millis(200)).await; @@ -88,6 +87,7 @@ pub async fn run_logs( } pub async fn run_up(orchestrator: Arc) -> Result<()> { + orchestrator.ensure_engine().await?; orchestrator.start_harness_stack(false).await?; crate::tui::run(orchestrator).await } diff --git a/workers-dev/src/config.rs b/workers-dev/src/config.rs index 9b09fd7b20..d6a9e0491e 100644 --- a/workers-dev/src/config.rs +++ b/workers-dev/src/config.rs @@ -9,16 +9,17 @@ use crate::discover::{discover_repo_workers, harness_stack_names, order_worker_n pub const DEFAULT_ENGINE_URL: &str = "ws://127.0.0.1:49134"; pub const DEFAULT_POLL_INTERVAL_MS: u64 = 2000; pub const DEFAULT_CONNECT_TIMEOUT_MS: u64 = 120_000; +/// Per-query timeout for `engine::workers::list`. Kept short so a slow or +/// hung engine can't stall the status poll (and, with off-thread polling, +/// can't freeze the TUI). +pub const ENGINE_QUERY_TIMEOUT_MS: u64 = 3_000; pub const LOG_RING_CAPACITY: usize = 500; pub const DEFAULT_LOG_TAIL: usize = 100; -pub const DASHBOARD_LOG_LINES: usize = 20; #[derive(Debug, Clone)] pub struct Config { pub repo_root: PathBuf, pub engine_url: String, - pub engine_host: String, - pub engine_port: u16, pub release: bool, pub poll_interval_ms: u64, pub connect_timeout_ms: u64, @@ -67,13 +68,45 @@ impl Config { bail!("no workers discovered under {}", repo_root.display()); } - let engine_url = engine_url + let raw_engine_url = engine_url .or(file_cfg.engine_url) .unwrap_or_else(|| DEFAULT_ENGINE_URL.to_string()); - let (engine_host, engine_port) = parse_engine_url(&engine_url, port)?; + let (engine_host, engine_port) = parse_engine_url(&raw_engine_url, port)?; + // Canonicalize so a `--port` override is reflected in the URL the SDK + // actually connects to. The engine WS endpoint is always `/`, so any + // path in the original URL is intentionally dropped. Preserve a `wss://` + // scheme so a TLS engine endpoint isn't silently downgraded to plaintext. + let scheme = if raw_engine_url.starts_with("wss://") { + "wss" + } else { + "ws" + }; + let engine_url = format!("{scheme}://{engine_host}:{engine_port}"); let discovered_names = order_worker_names(&worker_specs); - let workers = file_cfg.workers.unwrap_or(discovered_names); + // An explicit `workers:` list may name folders that auto-discovery + // skipped (folder/name mismatch or missing iii.worker.yaml). Drop those + // with a warning instead of letting WorkerGraph::load abort startup — + // mirroring discover_repo_workers' skip-and-continue behavior. + let workers = match file_cfg.workers { + Some(requested) => { + let valid: std::collections::HashSet<&str> = + discovered_names.iter().map(String::as_str).collect(); + requested + .into_iter() + .filter(|w| { + let ok = valid.contains(w.as_str()); + if !ok { + eprintln!( + "warning: skipping configured worker {w}: not a discovered worker (folder/name mismatch or missing iii.worker.yaml)" + ); + } + ok + }) + .collect() + } + None => discovered_names, + }; let harness_stack = file_cfg .harness_stack .unwrap_or_else(|| harness_stack_names(&worker_specs)); @@ -83,7 +116,9 @@ impl Config { .as_deref() .and_then(|s| { ColorMode::parse(s).or_else(|| { - eprintln!("warning: invalid color mode {s:?}; using auto"); + eprintln!( + "warning: invalid color mode {s:?} (valid: auto, always, never); using auto" + ); None }) }) @@ -92,8 +127,6 @@ impl Config { Ok(Self { repo_root, engine_url, - engine_host, - engine_port, release: release || file_cfg.release.unwrap_or(false), poll_interval_ms: file_cfg .poll_interval_ms @@ -168,7 +201,10 @@ fn validate_repo_root(path: &Path) -> Result<()> { if count > 0 { Ok(()) } else { - bail!("no top-level iii.worker.yaml workers under {}", path.display()) + bail!( + "no top-level iii.worker.yaml workers under {}", + path.display() + ) } } @@ -181,9 +217,9 @@ pub fn parse_engine_url(url: &str, port_override: Option) -> Result<(String let authority = stripped.split('/').next().unwrap_or(stripped); let (host, port) = if let Some((host, port_str)) = authority.rsplit_once(':') { - let port: u16 = port_str - .parse() - .with_context(|| format!("invalid port in engine url {url}"))?; + let port: u16 = port_str.parse().with_context(|| { + format!("invalid port in engine url {url} (port must be a number, e.g. 49134)") + })?; (host.to_string(), port) } else { (authority.to_string(), 49134) diff --git a/workers-dev/src/discover.rs b/workers-dev/src/discover.rs index 709bea6401..952799381b 100644 --- a/workers-dev/src/discover.rs +++ b/workers-dev/src/discover.rs @@ -15,7 +15,7 @@ pub const HARNESS_STACK: &[&str] = &[ "harness", ]; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum WorkerGroup { HarnessStack, Other, @@ -72,13 +72,14 @@ pub fn discover_repo_workers(repo_root: &Path) -> Result> { let raw = fs::read_to_string(&yaml_path) .with_context(|| format!("read {}", yaml_path.display()))?; - let parsed: WorkerYaml = serde_yaml::from_str(&raw) - .with_context(|| format!("parse {}", yaml_path.display()))?; + let parsed: WorkerYaml = + serde_yaml::from_str(&raw).with_context(|| format!("parse {}", yaml_path.display()))?; let name = parsed.name.clone().unwrap_or(folder.clone()); if name != folder { - anyhow::bail!( - "worker folder {folder} has iii.worker.yaml name={name} (mismatch)" + eprintln!( + "warning: skipping worker folder {folder}: iii.worker.yaml name={name} (mismatch)" ); + continue; } let group = if HARNESS_STACK.contains(&name.as_str()) { @@ -96,21 +97,10 @@ pub fn discover_repo_workers(repo_root: &Path) -> Result> { }); } - specs.sort_by(|a, b| { - group_rank(a.group) - .cmp(&group_rank(b.group)) - .then_with(|| a.name.cmp(&b.name)) - }); + specs.sort_by(|a, b| a.group.cmp(&b.group).then_with(|| a.name.cmp(&b.name))); Ok(specs) } -fn group_rank(group: WorkerGroup) -> u8 { - match group { - WorkerGroup::HarnessStack => 0, - WorkerGroup::Other => 1, - } -} - fn classify_spawn(dir: &Path, yaml: &WorkerYaml) -> SpawnKind { let language = yaml.language.as_deref().unwrap_or(""); let deploy = yaml.deploy.as_deref().unwrap_or(""); @@ -175,7 +165,11 @@ mod tests { assert_eq!(specs[2].name, "telegram-bot"); assert_eq!(specs[2].group, WorkerGroup::Other); assert!(matches!( - specs.iter().find(|s| s.name == "claude-code").unwrap().spawn, + specs + .iter() + .find(|s| s.name == "claude-code") + .unwrap() + .spawn, SpawnKind::Unsupported { .. } )); } diff --git a/workers-dev/src/graph.rs b/workers-dev/src/graph.rs index 8eb4fdecd1..38daeed811 100644 --- a/workers-dev/src/graph.rs +++ b/workers-dev/src/graph.rs @@ -30,9 +30,7 @@ impl WorkerGraph { if let Some(name) = &parsed.name { if name != worker { - bail!( - "worker folder {worker} has iii.worker.yaml name={name} (mismatch)" - ); + bail!("worker folder {worker} has iii.worker.yaml name={name} (mismatch)"); } } @@ -57,10 +55,7 @@ impl WorkerGraph { } pub fn dependencies(&self, worker: &str) -> &[String] { - self.deps - .get(worker) - .map(Vec::as_slice) - .unwrap_or(&[]) + self.deps.get(worker).map(Vec::as_slice).unwrap_or(&[]) } /// Topological start order (dependencies before dependents). @@ -86,9 +81,7 @@ impl WorkerGraph { continue; } *in_degree.entry(worker.as_str()).or_insert(0) += 1; - adj.entry(dep.as_str()) - .or_default() - .push(worker.as_str()); + adj.entry(dep.as_str()).or_default().push(worker.as_str()); } } @@ -139,8 +132,7 @@ impl WorkerGraph { while let Some(current) = queue.pop_front() { for candidate in &self.workers { - if self.dependencies(candidate).contains(¤t) - && seen.insert(candidate.clone()) + if self.dependencies(candidate).contains(¤t) && seen.insert(candidate.clone()) { queue.push_back(candidate.clone()); } @@ -216,13 +208,17 @@ mod tests { write_worker_yaml(tmp.path(), "provider-anthropic", &["llm-router"]); write_worker_yaml(tmp.path(), "provider-openai", &["llm-router"]); write_worker_yaml(tmp.path(), "approval-gate", &["session-manager"]); - write_worker_yaml(tmp.path(), "harness", &[ - "session-manager", - "context-manager", - "approval-gate", - "provider-anthropic", - "provider-openai", - ]); + write_worker_yaml( + tmp.path(), + "harness", + &[ + "session-manager", + "context-manager", + "approval-gate", + "provider-anthropic", + "provider-openai", + ], + ); tmp } diff --git a/workers-dev/src/logs.rs b/workers-dev/src/logs.rs index 6a84e2320c..70d2cba645 100644 --- a/workers-dev/src/logs.rs +++ b/workers-dev/src/logs.rs @@ -6,22 +6,11 @@ use ratatui::text::{Line, Span}; /// Normalize process output for TUI display and ring-buffer storage. pub fn normalize_log_line(raw: &str) -> String { - let segment = raw - .rsplit('\r') - .next() - .unwrap_or(raw) - .trim_end(); + let segment = raw.rsplit('\r').next().unwrap_or(raw).trim_end(); let stripped = strip_ansi(segment); stripped.trim_end().to_string() } -pub fn format_for_display(line: &str, max_width: usize) -> String { - if max_width == 0 { - return String::new(); - } - truncate_chars(line, max_width) -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LogKind { CargoProgress, @@ -49,7 +38,8 @@ pub fn classify_log_line(line: &str) -> LogKind { { return LogKind::CargoProgress; } - if line.contains("Finished `") && line.contains("profile") || line.contains("Running `target/") { + if line.contains("Finished `") && line.contains("profile") || line.contains("Running `target/") + { return LogKind::CargoDone; } if contains_level_token(line, "WARN") { @@ -79,29 +69,30 @@ pub fn log_line_to_ratatui(line: &str, max_width: usize, color_enabled: bool) -> } if !color_enabled { - return Line::from(format_for_display(line, max_width)); + return Line::from(truncate_chars(line, max_width)); } if let Some((ts, rest)) = split_tracing_timestamp(line) { let kind = classify_log_line(line); - let mut spans = vec![ - Span::styled( - format_for_display(ts, max_width), - log_timestamp_style(true), - ), - Span::raw(" "), - ]; - let rest_width = max_width.saturating_sub(ts.chars().count().min(max_width) + 1); - spans.push(Span::styled( - format_for_display(rest, rest_width), - log_kind_style(kind, true), - )); + let ts_str = truncate_chars(ts, max_width); + let ts_width: usize = ts_str.chars().map(unicode_width).sum(); + let mut spans = vec![Span::styled(ts_str, log_timestamp_style(true))]; + // Only append the separator + message when columns remain. If the + // timestamp alone fills max_width, a trailing space would push the line + // one column past the pane and wrap it onto a second row. + if ts_width < max_width { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + truncate_chars(rest, max_width - ts_width - 1), + log_kind_style(kind, true), + )); + } return Line::from(spans); } let kind = classify_log_line(line); Line::from(Span::styled( - format_for_display(line, max_width), + truncate_chars(line, max_width), log_kind_style(kind, true), )) } @@ -140,21 +131,6 @@ fn log_crossterm_color(kind: LogKind) -> Option { } } -fn write_crossterm_colored_line( - line: &str, - kind: LogKind, - out: &mut impl Write, -) -> std::io::Result<()> { - if let Some(color) = log_crossterm_color(kind) { - crossterm::execute!(out, SetForegroundColor(color))?; - write!(out, "{line}")?; - crossterm::execute!(out, ResetColor)?; - } else { - write!(out, "{line}")?; - } - Ok(()) -} - pub fn print_colored_line( line: &str, color_enabled: bool, @@ -164,8 +140,13 @@ pub fn print_colored_line( writeln!(out, "{line}")?; return Ok(()); } - let kind = classify_log_line(line); - write_crossterm_colored_line(line, kind, out)?; + if let Some(color) = log_crossterm_color(classify_log_line(line)) { + crossterm::execute!(out, SetForegroundColor(color))?; + write!(out, "{line}")?; + crossterm::execute!(out, ResetColor)?; + } else { + write!(out, "{line}")?; + } writeln!(out)?; Ok(()) } @@ -217,7 +198,11 @@ fn truncate_chars(input: &str, max_width: usize) -> String { } fn unicode_width(ch: char) -> usize { - if ch.is_ascii() { 1 } else { 2 } + if ch.is_ascii() { + 1 + } else { + 2 + } } #[cfg(test)] @@ -227,16 +212,16 @@ mod tests { #[test] fn strips_ansi_and_carriage_return_overwrites() { let raw = " Blocking waiting\r Blocking waiting for file lock\r Compiling harness v1.0.0"; - assert_eq!( - normalize_log_line(raw), - " Compiling harness v1.0.0" - ); + assert_eq!(normalize_log_line(raw), " Compiling harness v1.0.0"); } #[test] fn strips_color_codes() { let raw = "\x1b[2m2026-06-22T15:47:56 INFO\x1b[0m harness: ready"; - assert_eq!(normalize_log_line(raw), "2026-06-22T15:47:56 INFO harness: ready"); + assert_eq!( + normalize_log_line(raw), + "2026-06-22T15:47:56 INFO harness: ready" + ); } #[test] @@ -246,7 +231,9 @@ mod tests { LogKind::CargoProgress ); assert_eq!( - classify_log_line(" Finished `dev` profile [unoptimized + debuginfo] target(s) in 9.21s"), + classify_log_line( + " Finished `dev` profile [unoptimized + debuginfo] target(s) in 9.21s" + ), LogKind::CargoDone ); assert_eq!( @@ -269,4 +256,20 @@ mod tests { assert_eq!(line.spans.len(), 1); assert_eq!(line.spans[0].content, " Compili"); } + + #[test] + fn log_line_to_ratatui_timestamp_fits_narrow_pane() { + // Timestamp alone fills the pane — the trailing separator must not push + // the rendered line one column past max_width (would wrap onto two rows). + let max = 10; + let line = + log_line_to_ratatui("2026-06-22T15:47:56.851170Z INFO harness: ready", max, true); + let total: usize = line + .spans + .iter() + .flat_map(|s| s.content.chars()) + .map(unicode_width) + .sum(); + assert!(total <= max, "rendered width {total} exceeds {max}"); + } } diff --git a/workers-dev/src/main.rs b/workers-dev/src/main.rs index 7fb48cb0ea..7546bc2665 100644 --- a/workers-dev/src/main.rs +++ b/workers-dev/src/main.rs @@ -63,6 +63,7 @@ enum Command { Up, /// Start workers (default: harness stack; use --all for every worker) Start { + /// Worker name(s) to start; default = the harness stack #[arg(value_name = "WORKER")] workers: Vec, /// Start every discovered worker instead of the harness stack @@ -71,15 +72,18 @@ enum Command { }, /// Stop workers (default: all managed) Stop { + /// Worker name(s) to stop; default = all managed workers #[arg(value_name = "WORKER")] workers: Vec, }, /// Rebuild and restart a worker, then restart its dependents Restart { + /// Worker to rebuild and restart (its dependents restart too) worker: String, }, /// Print worker logs from the local ring buffer Logs { + /// Worker whose logs to print worker: String, /// Follow log output #[arg(short = 'f', long)] @@ -105,7 +109,11 @@ async fn main() -> Result<()> { Some(cli.color), )?; - let orchestrator = Arc::new(Orchestrator::new(config)?); + let progress = matches!( + &cli.command, + Some(Command::Start { .. }) | Some(Command::Restart { .. }) + ); + let orchestrator = Arc::new(Orchestrator::new(config, progress)?); let result = match cli.command { None => crate::tui::run(orchestrator.clone()).await, @@ -114,9 +122,7 @@ async fn main() -> Result<()> { commands::run_start(&orchestrator, workers, all).await } Some(Command::Stop { workers }) => commands::run_stop(&orchestrator, workers).await, - Some(Command::Restart { worker }) => { - commands::run_restart(&orchestrator, &worker).await - } + Some(Command::Restart { worker }) => commands::run_restart(&orchestrator, &worker).await, Some(Command::Logs { worker, follow, @@ -131,5 +137,7 @@ async fn main() -> Result<()> { } } + orchestrator.shutdown().await; + result } diff --git a/workers-dev/src/orchestrator.rs b/workers-dev/src/orchestrator.rs index 5d4045354a..b1d3364fcb 100644 --- a/workers-dev/src/orchestrator.rs +++ b/workers-dev/src/orchestrator.rs @@ -3,11 +3,12 @@ use std::process::Stdio; use std::time::{Duration, Instant}; use anyhow::{bail, Context, Result}; +use iii_sdk::{register_worker, IIIClient, InitOptions}; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; use tokio::time; -use crate::config::{Config, DASHBOARD_LOG_LINES}; +use crate::config::Config; use crate::discover::SpawnKind; use crate::graph::WorkerGraph; use crate::logs; @@ -18,28 +19,148 @@ pub struct Orchestrator { pub config: Config, pub graph: WorkerGraph, pub runtimes: SharedRuntimes, + /// When true, CLI start/restart paths stream per-worker progress to stderr. + /// Off for the TUI, which owns the screen and shows status in its own panes. + pub progress: bool, + /// One persistent engine connection, lazily opened on first status query + /// and reused for every poll. Replaces the per-poll `iii trigger` + /// subprocess that made the engine log a register/unregister pair every + /// tick. + engine_client: tokio::sync::OnceCell, } impl Orchestrator { - pub fn new(config: Config) -> Result { + pub fn new(config: Config, progress: bool) -> Result { let graph = WorkerGraph::load(&config.repo_root, &config.workers)?; let runtimes = crate::runtime::new_runtimes(&config.workers); Ok(Self { config, graph, runtimes, + progress, + engine_client: tokio::sync::OnceCell::new(), }) } - pub async fn engine_preflight(&self) -> Result<()> { - status::fetch_engine_workers(&self.config) + /// Lazily open (and cache) the shared engine connection. Connects only + /// when status is actually queried, and waits briefly for the background + /// handshake so the first query doesn't race it. + async fn engine_client(&self) -> &IIIClient { + self.engine_client + .get_or_init(|| async { + // Label the connection so it shows as `workers-dev` in engine + // logs / `engine::workers::list` instead of a bare hostname. + let client = register_worker( + &self.config.engine_url, + InitOptions { + metadata: Some(iii_sdk::runtime::WorkerMetadata { + name: "workers-dev".to_string(), + ..Default::default() + }), + ..Default::default() + }, + ); + let deadline = Instant::now() + Duration::from_millis(2000); + while Instant::now() < deadline + && client.get_connection_state() + != iii_sdk::runtime::IIIConnectionState::Connected + { + time::sleep(Duration::from_millis(50)).await; + } + client + }) .await - .with_context(|| { - format!( - "engine not reachable at {} (start with: iii -c harness/engine.config.yaml)", - self.config.engine_url - ) - })?; + } + + /// Fetch connected workers from the engine over the shared connection. + pub async fn engine_workers(&self) -> Result> { + let client = self.engine_client().await; + status::fetch_engine_workers(client, crate::config::ENGINE_QUERY_TIMEOUT_MS).await + } + + /// Cleanly close the engine connection so the engine logs a prompt + /// unregister instead of waiting for the socket to time out. + pub async fn shutdown(&self) { + if let Some(client) = self.engine_client.get() { + client.shutdown_async().await; + } + } + + /// Fast TCP probe of the engine's WebSocket port. Cheaper than a full + /// trigger round-trip, so the engine-boot wait loop can poll quickly. + async fn engine_reachable(&self) -> bool { + let Ok((host, port)) = crate::config::parse_engine_url(&self.config.engine_url, None) + else { + return false; + }; + matches!( + tokio::time::timeout( + Duration::from_millis(500), + tokio::net::TcpStream::connect((host.as_str(), port)), + ) + .await, + Ok(Ok(_)) + ) + } + + /// Ensure the engine is running, starting `iii -c harness/engine.config.yaml` + /// if it isn't. The engine is detached (own process group, no kill-on-drop) + /// so it outlives the dashboard, like the workers it hosts; then we wait for + /// it to accept connections. + pub async fn ensure_engine(&self) -> Result<()> { + if self.engine_reachable().await { + return Ok(()); + } + + let config_rel = "harness/engine.config.yaml"; + let config_path = self.config.repo_root.join(config_rel); + if !config_path.is_file() { + bail!( + "engine not reachable at {} and {} not found — start it manually: iii -c {config_rel}", + self.config.engine_url, + config_path.display() + ); + } + + eprintln!( + "engine not reachable at {} — starting `iii -c {config_rel}`…", + self.config.engine_url + ); + + let mut cmd = Command::new("iii"); + cmd.arg("-c").arg(config_rel); + cmd.current_dir(&self.config.repo_root); + // Detach: the engine should outlive the dashboard. Suppress its output + // so it can't corrupt the TUI's alternate screen; run it manually if + // you need to watch engine logs. + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::null()); + cmd.stderr(Stdio::null()); + #[cfg(unix)] + cmd.process_group(0); + cmd.spawn().context("spawn iii engine")?; + + let deadline = Instant::now() + Duration::from_millis(self.config.connect_timeout_ms); + while Instant::now() < deadline { + time::sleep(Duration::from_millis(200)).await; + if self.engine_reachable().await { + eprintln!("engine is up at {}", self.config.engine_url); + return Ok(()); + } + } + bail!( + "started the engine but it did not become reachable within {}ms", + self.config.connect_timeout_ms + ); + } + + pub async fn engine_preflight(&self) -> Result<()> { + self.engine_workers().await.with_context(|| { + format!( + "engine not reachable at {} (start with: iii -c harness/engine.config.yaml)", + self.config.engine_url + ) + })?; Ok(()) } @@ -124,11 +245,21 @@ impl Orchestrator { cmd.env("CARGO_TERM_COLOR", "never"); cmd.env("CARGO_TERM_PROGRESS", "never"); cmd.env("CLICOLOR_FORCE", "0"); + // Run the worker in its own process group so stopping it can signal the + // whole group. `cargo run` forks the worker binary as a child; killing + // only cargo orphans the worker (it keeps running, still connected to + // the engine). See `terminate_process_group`. + #[cfg(unix)] + cmd.process_group(0); let mut child = cmd .spawn() .with_context(|| format!("spawn cargo run for {name}"))?; + if self.progress { + eprintln!("▶ {name}: starting (cargo run)…"); + } + let stdout = child.stdout.take().context("stdout pipe")?; let stderr = child.stderr.take().context("stderr pipe")?; @@ -177,7 +308,7 @@ impl Orchestrator { }; if let Some(mut child) = child { - let _ = child.start_kill(); + terminate_process_group(&mut child).await; let _ = child.wait().await; } @@ -191,19 +322,63 @@ impl Orchestrator { } pub async fn wait_connected(&self, name: &str) -> Result<()> { - let deadline = - Instant::now() + Duration::from_millis(self.config.connect_timeout_ms); + let deadline = Instant::now() + Duration::from_millis(self.config.connect_timeout_ms); + let mut last_engine_err: Option = None; loop { if Instant::now() >= deadline { - bail!("timed out waiting for {name} to connect to engine"); + match last_engine_err { + Some(err) => bail!( + "timed out waiting for {name} to connect to engine \ + (engine query failing: {err:#})" + ), + None => bail!("timed out waiting for {name} to connect to engine"), + } } - let engine = status::fetch_engine_workers(&self.config).await.unwrap_or_default(); - if engine.iter().any(|w| w.name.as_deref() == Some(name) && w.status == "connected") { + let engine = match self.engine_workers().await { + Ok(engine) => { + last_engine_err = None; + engine + } + Err(err) => { + last_engine_err = Some(err); + Vec::new() + } + }; + if engine + .iter() + .any(|w| w.name.as_deref() == Some(name) && w.status == "connected") + { + if self.progress { + let elapsed = { + let runtimes = self.runtimes.read().await; + runtimes + .get(name) + .and_then(|rt| rt.started_at) + .map(|t| t.elapsed()) + }; + match elapsed { + Some(d) => eprintln!("✓ {name} connected ({:.1}s)", d.as_secs_f64()), + None => eprintln!("✓ {name} connected"), + } + } return Ok(()); } let proc = self.proc_state(name).await; if proc == ProcState::Crashed || proc == ProcState::Stopped { - bail!("worker {name} exited before connecting to engine"); + let exit_code = { + let runtimes = self.runtimes.read().await; + runtimes.get(name).and_then(|rt| rt.exit_code) + }; + match exit_code { + Some(code) => bail!( + "worker {name} exited (status {code}) before connecting to engine — \ + check logs: workers-dev logs {name}" + ), + None => bail!( + "worker {name} exited before connecting to engine — \ + check logs: workers-dev logs {name}" + ), + } } time::sleep(Duration::from_millis(500)).await; } @@ -218,9 +393,18 @@ impl Orchestrator { } pub async fn worker_views(&self) -> Result> { - let engine = status::fetch_engine_workers(&self.config) - .await - .unwrap_or_default(); + Ok(self.dashboard_snapshot().await.0) + } + + /// Like `worker_views`, but also reports an engine-query error (if any) so + /// the dashboard can show "engine unreachable" instead of silently blanking + /// every engine status. Never fails: on engine error the views still render + /// (all disconnected) alongside the error string. + pub async fn dashboard_snapshot(&self) -> (Vec, Option) { + let (engine, engine_error) = match self.engine_workers().await { + Ok(list) => (list, None), + Err(err) => (Vec::new(), Some(format!("{err:#}"))), + }; let engine_by_name: HashMap = engine .into_iter() .filter_map(|w| w.name.clone().map(|name| (name, w))) @@ -233,7 +417,7 @@ impl Orchestrator { let spec = self.config.worker_spec(worker).expect("spec"); views.push(build_view(spec, rt, engine_by_name.get(worker))); } - Ok(views) + (views, engine_error) } pub async fn logs_tail(&self, name: &str, n: usize) -> Result> { @@ -242,7 +426,10 @@ impl Orchestrator { Ok(rt.logs.tail(n)) } - pub async fn subscribe_logs(&self, name: &str) -> Result> { + pub async fn subscribe_logs( + &self, + name: &str, + ) -> Result> { let runtimes = self.runtimes.read().await; let rt = runtimes.get(name).context("unknown worker")?; Ok(rt.subscribe_logs()) @@ -256,10 +443,7 @@ fn build_view( ) -> WorkerView { let process = rt.proc_state.label().to_string(); let (engine_status, uptime) = if let Some(w) = engine { - ( - w.status.clone(), - format_uptime(w.connected_at_ms), - ) + (w.status.clone(), format_uptime(w.connected_at_ms)) } else { ("—".to_string(), "—".to_string()) }; @@ -284,9 +468,8 @@ fn build_view( process_status: process, engine_status, local_pid: rt.pid(), - engine_pid: None, uptime, - last_logs: rt.logs.tail(DASHBOARD_LOG_LINES), + exit_code: rt.exit_code, } } @@ -403,12 +586,97 @@ async fn wait_for_exit(worker: &str, expected_pid: Option, runtimes: Shared rt.proc_state = if status.success() { ProcState::Stopped } else { - let code = status.code().map(|c| c.to_string()).unwrap_or_else(|| "?".into()); - rt.logs - .push(format!("process exited with status {code}")); + let code = status + .code() + .map(|c| c.to_string()) + .unwrap_or_else(|| "?".into()); + rt.logs.push(format!("process exited with status {code}")); ProcState::Crashed }; } return; } } + +/// Stop a worker and the process group it leads. `cargo run` forks the worker +/// binary as a child; SIGKILLing only cargo would orphan the worker (it keeps +/// running and connected to the engine). Workers are spawned in their own +/// process group (see `start_one`), so signal the whole group: SIGTERM for a +/// graceful stop, then SIGKILL if it hasn't exited within ~1s. +#[cfg(unix)] +async fn terminate_process_group(child: &mut tokio::process::Child) { + let Some(pid) = child.id() else { + let _ = child.start_kill(); + return; + }; + // The child is its own group leader, so its pid is the pgid; the negative + // pid targets the whole group. + let pgid = pid as i32; + unsafe { + libc::kill(-pgid, libc::SIGTERM); + } + for _ in 0..20 { + match child.try_wait() { + Ok(Some(_)) => return, + Ok(None) => time::sleep(Duration::from_millis(50)).await, + Err(_) => break, + } + } + unsafe { + libc::kill(-pgid, libc::SIGKILL); + } +} + +#[cfg(not(unix))] +async fn terminate_process_group(child: &mut tokio::process::Child) { + let _ = child.start_kill(); +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::process::Stdio; + + /// A worker started via `cargo run` forks the real worker binary as a + /// child. Group-killing must take BOTH down — killing only the supervisor + /// orphans the worker. Simulate with a parent shell that backgrounds a long + /// sleep (the "worker") in its own process group, then assert the + /// backgrounded pid is dead after `terminate_process_group`. + #[tokio::test] + async fn terminate_process_group_kills_forked_child() { + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg("sleep 300 & echo $! ; wait"); + cmd.stdout(Stdio::piped()); + cmd.process_group(0); + let mut child = cmd.spawn().expect("spawn sh"); + + let stdout = child.stdout.take().expect("stdout"); + let mut reader = BufReader::new(stdout).lines(); + let line = reader + .next_line() + .await + .expect("read pid line") + .expect("pid line present"); + let worker_pid: i32 = line.trim().parse().expect("parse worker pid"); + + // The backgrounded "worker" is alive before we stop the supervisor. + assert_eq!( + unsafe { libc::kill(worker_pid, 0) }, + 0, + "worker should be running" + ); + + terminate_process_group(&mut child).await; + let _ = child.wait().await; + + let mut dead = false; + for _ in 0..40 { + if unsafe { libc::kill(worker_pid, 0) } != 0 { + dead = true; + break; + } + time::sleep(Duration::from_millis(50)).await; + } + assert!(dead, "forked worker {worker_pid} was orphaned, not killed"); + } +} diff --git a/workers-dev/src/runtime.rs b/workers-dev/src/runtime.rs index c6469a43b2..6dbb90ed8e 100644 --- a/workers-dev/src/runtime.rs +++ b/workers-dev/src/runtime.rs @@ -75,6 +75,11 @@ impl WorkerRuntime { } } + /// PID of the spawned child — the `cargo run` supervisor, not the worker + /// binary it forks. The engine doesn't expose the worker's own pid via + /// `engine::workers::list`, so this is the closest stable handle. + // ponytail: supervisor pid is fine for the dashboard; run the built binary + // directly (instead of `cargo run`) if the true worker pid is ever needed. pub fn pid(&self) -> Option { self.child.as_ref().and_then(|c| c.id()) } @@ -109,4 +114,3 @@ pub fn new_runtimes(workers: &[String]) -> SharedRuntimes { } Arc::new(RwLock::new(map)) } - diff --git a/workers-dev/src/status.rs b/workers-dev/src/status.rs index 2758948b8a..7b2605aece 100644 --- a/workers-dev/src/status.rs +++ b/workers-dev/src/status.rs @@ -1,13 +1,9 @@ -use std::process::Stdio; -use std::time::Duration; - -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; +use iii_sdk::protocol::TriggerRequest; +use iii_sdk::IIIClient; use serde::Deserialize; -use tokio::process::Command; -use tokio::time::timeout; use crate::discover::WorkerGroup; -use crate::config::Config; #[derive(Debug, Clone, Deserialize)] pub struct EngineWorkersResponse { @@ -32,43 +28,31 @@ pub struct WorkerView { pub process_status: String, pub engine_status: String, pub local_pid: Option, - pub engine_pid: Option, pub uptime: String, - pub last_logs: Vec, + /// Exit code of the last run when the process crashed. Surfaced inline so a + /// failure is visible in the table without opening the log pane. + pub exit_code: Option, } -pub async fn fetch_engine_workers(config: &Config) -> Result> { - let mut cmd = Command::new("iii"); - cmd.args([ - "trigger", - "engine::workers::list", - "--json", - "{}", - "--address", - &config.engine_host, - "--port", - &config.engine_port.to_string(), - ]); - cmd.stdout(Stdio::piped()); - cmd.stderr(Stdio::piped()); - - let output = timeout(Duration::from_secs(10), cmd.output()) +/// Query the engine for its connected workers over the shared persistent +/// connection. Reuses one WebSocket instead of spawning `iii trigger` per +/// poll, so the engine no longer logs a register/unregister pair every tick. +pub async fn fetch_engine_workers( + client: &IIIClient, + timeout_ms: u64, +) -> Result> { + let result = client + .trigger(TriggerRequest { + function_id: "engine::workers::list".to_string(), + payload: serde_json::json!({}), + action: None, + timeout_ms: Some(timeout_ms), + }) .await - .context("timed out waiting for iii trigger engine::workers::list")? - .context("spawn iii trigger (is iii on PATH?)")?; - - let out = String::from_utf8_lossy(&output.stdout).to_string(); - let err = String::from_utf8_lossy(&output.stderr).to_string(); - - if !output.status.success() { - anyhow::bail!( - "iii trigger engine::workers::list failed: {}", - err.trim() - ); - } + .map_err(|e| anyhow!("engine::workers::list failed (is the engine running?): {e}"))?; - let parsed: EngineWorkersResponse = serde_json::from_str(out.trim()) - .with_context(|| format!("parse engine workers response: {}", out.trim()))?; + let parsed: EngineWorkersResponse = + serde_json::from_value(result).context("parse engine workers response")?; Ok(parsed.workers) } @@ -90,8 +74,7 @@ pub fn print_status_table(views: &[WorkerView]) { last_group = Some(v.group); } let pid = v - .engine_pid - .or(v.local_pid) + .local_pid .map(|p| p.to_string()) .unwrap_or_else(|| "—".to_string()); let name = if v.spawnable { diff --git a/workers-dev/src/tui/mod.rs b/workers-dev/src/tui/mod.rs index 2417f6720b..587edd44c6 100644 --- a/workers-dev/src/tui/mod.rs +++ b/workers-dev/src/tui/mod.rs @@ -1,6 +1,7 @@ mod theme; use std::io; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -11,7 +12,7 @@ use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Modifier, Style}; +use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, TableState}; use ratatui::Frame; @@ -27,12 +28,88 @@ use theme::{ overlay_bg_style, process_style, selection_row_style, status_style, }; +/// Spinner frames cycled through for workers in the compiling state. +const SPINNER: [&str; 4] = ["◐", "◓", "◑", "◒"]; +/// Lines of scrollback the log pane can page through (matches the ring buffer). +const LOG_SCROLLBACK: usize = crate::config::LOG_RING_CAPACITY; +/// Lines moved per PageUp/PageDown in the log pane. +const LOG_PAGE: usize = 10; +/// Log-pane height bounds (lines); adjustable with +/-. +const LOG_HEIGHT_MIN: u16 = 6; +const LOG_HEIGHT_MAX: u16 = 60; +const LOG_HEIGHT_DEFAULT: u16 = 18; +/// Minimum height (incl. border + header row) the worker table keeps, so the +/// log pane can never squeeze the primary content off-screen. +const MIN_TABLE_HEIGHT: u16 = 9; +/// Two-column (master/detail) sizing. The worker list is content-fit on the +/// left — 66 cols fits all five columns exactly — and the log pane flexes to +/// fill the rest on the right. Below the combined minimum the two panes stack +/// vertically instead, so neither is crushed on a narrow terminal. +const TABLE_PANE_WIDTH: u16 = 66; +const MIN_LOG_PANE_WIDTH: u16 = 36; +/// Floor for the user-dragged divider (+/-): below this the list loses its +/// less-critical right columns (PID, uptime) to hand width to the logs. +const TABLE_WIDTH_MIN: u16 = 50; +/// 1-col gutter keeps the two pane borders from fusing into a double seam. +const PANE_GUTTER: u16 = 1; +const TWO_COL_MIN_WIDTH: u16 = TABLE_PANE_WIDTH + PANE_GUTTER + MIN_LOG_PANE_WIDTH; + +/// Footer help, by available width. The narrowest tier always keeps the two +/// keys a lost user needs (help, quit). +const HELP_FULL: &str = + " ↑↓ select · s start · x stop · r restart · / filter · f follow · ? keys · q quit "; +const HELP_MID: &str = " s start · x stop · r restart · / filter · ? keys · q quit "; +const HELP_MIN: &str = " / filter · ? keys · q quit "; + enum UiMode { Dashboard, - ConfirmRestart(String), + /// Editing the worker filter; keystrokes append to `filter`. + Filter, + /// Full key reference overlay; any key returns to the dashboard. + Help, + ConfirmRestart { + name: String, + dependents: Vec, + }, Busy(String), } +enum ModeKind { + Dashboard, + Filter, + Help, + Confirm, + Busy, +} + +fn mode_kind(mode: &UiMode) -> ModeKind { + match mode { + UiMode::Dashboard => ModeKind::Dashboard, + UiMode::Filter => ModeKind::Filter, + UiMode::Help => ModeKind::Help, + UiMode::ConfirmRestart { .. } => ModeKind::Confirm, + UiMode::Busy(_) => ModeKind::Busy, + } +} + +/// Handles the key handlers use to launch worker actions and report their +/// failures back to the UI. Action errors would otherwise vanish: a detached +/// task's `eprintln!` is invisible (or corrupting) under the alt-screen. +struct Actions { + orchestrator: Arc, + in_flight: Arc, + errors: tokio::sync::mpsc::UnboundedSender, +} + +/// Latest dashboard data produced by the background poller. Carries the +/// engine-query error (if any) so the UI can show "engine unreachable" rather +/// than silently rendering every worker as disconnected. +#[derive(Clone, Default)] +struct DashboardState { + views: Vec, + engine_error: Option, +} + enum DisplayRowKind { Header(WorkerGroup), Worker(usize), @@ -42,6 +119,28 @@ struct DisplayRow { kind: DisplayRowKind, } +/// Everything `draw_ui` needs for one frame, bundled to keep the signature sane. +struct UiCtx<'a> { + engine_url: &'a str, + views: &'a [WorkerView], + engine_error: Option<&'a str>, + display_rows: &'a [DisplayRow], + mode: &'a UiMode, + filter: &'a str, + selected_name: Option<&'a str>, + log_lines: &'a [String], + log_scroll: usize, + follow: bool, + log_height: u16, + /// Width of the worker-list pane in the two-column layout (user-dragged + /// with +/-); ignored in the stacked fallback. + table_width: u16, + spinner_frame: usize, + color_enabled: bool, + /// Transient action-failure banner shown in the footer. + error: Option<&'a str>, +} + pub async fn run(orchestrator: Arc) -> Result<()> { enable_raw_mode()?; execute!(io::stdout(), EnterAlternateScreen)?; @@ -51,79 +150,196 @@ pub async fn run(orchestrator: Arc) -> Result<()> { let backend = ratatui::backend::CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - let mut views: Vec = orchestrator.worker_views().await?; - let mut display_rows = build_display_rows(&views); - let mut table_state = TableState::default(); - table_state.select(Some(first_worker_row(&display_rows).unwrap_or(0))); + // Poll the engine on a background task so a slow or unreachable engine + // query can't freeze keyboard input. + let (initial_views, initial_err) = orchestrator.dashboard_snapshot().await; + let (state_tx, mut state_rx) = tokio::sync::watch::channel(DashboardState { + views: initial_views, + engine_error: initial_err, + }); + let poll_interval = Duration::from_millis(orchestrator.config.poll_interval_ms); + let poller = { + let orchestrator = orchestrator.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(poll_interval).await; + let (views, engine_error) = orchestrator.dashboard_snapshot().await; + if state_tx + .send(DashboardState { + views, + engine_error, + }) + .is_err() + { + break; // UI gone + } + } + }) + }; + // Number of worker actions still running; gates the Busy overlay and + // prevents overlapping actions on the same worker. + let in_flight = Arc::new(AtomicUsize::new(0)); + // Detached worker actions report failures here so the UI can show them + // (a task's eprintln would be lost/garbled under the alt-screen). + let (err_tx, mut err_rx) = tokio::sync::mpsc::unbounded_channel::(); + let actions = Actions { + orchestrator: orchestrator.clone(), + in_flight: in_flight.clone(), + errors: err_tx, + }; + + let mut state = state_rx.borrow().clone(); + let mut table_state = TableState::default(); let mut mode = UiMode::Dashboard; - let poll = Duration::from_millis(orchestrator.config.poll_interval_ms); - let mut last_poll = Instant::now(); + let mut filter = String::new(); + let mut follow = true; + let mut log_scroll: usize = 0; + let mut log_height: u16 = LOG_HEIGHT_DEFAULT; + let mut table_width: u16 = TABLE_PANE_WIDTH; + let mut spinner_frame: usize = 0; + let mut error_banner: Option<(String, Instant)> = None; let mut running = true; + let mut needs_redraw = true; + let mut last_busy_tick = Instant::now(); + let mut last_redraw = Instant::now(); let color_enabled = orchestrator.config.color_mode.enabled_for_tui(); + { + let rows = build_display_rows(&state.views, &filter); + table_state.select(first_worker_row(&rows)); + } + while running { - display_rows = build_display_rows(&views); - terminal.draw(|f| { - draw_ui( - f, - &orchestrator.config.engine_url, - &views, - &display_rows, - &mut table_state, - &mode, - color_enabled, - ); - })?; + let display_rows = build_display_rows(&state.views, &filter); + clamp_selection(&mut table_state, &display_rows); - if last_poll.elapsed() >= poll { - if let Ok(v) = orchestrator.worker_views().await { - views = v; + let compiling = state.views.iter().any(|v| v.display_status == "compiling"); + + if needs_redraw { + if compiling { + spinner_frame = spinner_frame.wrapping_add(1); } - last_poll = Instant::now(); - if matches!(mode, UiMode::Busy(_)) { + let selected_name = + selected_worker(&display_rows, &state.views, table_state.selected()); + let log_lines = match &selected_name { + Some(n) => orchestrator + .logs_tail(n, LOG_SCROLLBACK) + .await + .unwrap_or_default(), + None => Vec::new(), + }; + let ctx = UiCtx { + engine_url: &orchestrator.config.engine_url, + views: &state.views, + engine_error: state.engine_error.as_deref(), + display_rows: &display_rows, + mode: &mode, + filter: &filter, + selected_name: selected_name.as_deref(), + log_lines: &log_lines, + log_scroll, + follow, + log_height, + table_width, + spinner_frame, + color_enabled, + error: error_banner.as_ref().map(|(s, _)| s.as_str()), + }; + terminal.draw(|f| draw_ui(f, &mut table_state, &ctx))?; + needs_redraw = false; + last_redraw = Instant::now(); + } + + // Clear the Busy overlay once actions finish (on the poll cadence so + // brief informational messages stay readable), never while running. + if last_busy_tick.elapsed() >= poll_interval { + last_busy_tick = Instant::now(); + if matches!(mode, UiMode::Busy(_)) && in_flight.load(Ordering::SeqCst) == 0 { mode = UiMode::Dashboard; + needs_redraw = true; } } - if event::poll(Duration::from_millis(100))? { - if let Event::Key(key) = event::read()? { - match &mode { - UiMode::ConfirmRestart(worker) => { - let worker = worker.clone(); - match key.code { - KeyCode::Char('y') | KeyCode::Enter => { - spawn_restart(orchestrator.clone(), worker.clone()); - mode = UiMode::Busy(format!("restarting {worker} + dependents…")); - } - KeyCode::Char('n') | KeyCode::Esc => { + // Animate the spinner while compiling and refresh live logs while + // following; otherwise stay idle until something actually changes. + let spinner_due = compiling && last_redraw.elapsed() >= Duration::from_millis(120); + let live_logs_due = follow + && matches!(mode_kind(&mode), ModeKind::Dashboard | ModeKind::Filter) + && last_redraw.elapsed() >= Duration::from_millis(500); + if spinner_due || live_logs_due { + needs_redraw = true; + } + + if event::poll(Duration::from_millis(120))? { + match event::read()? { + Event::Key(key) => { + needs_redraw = true; + error_banner = None; // any keypress acknowledges the banner + match mode_kind(&mode) { + ModeKind::Filter => { + handle_filter_key( + key, + &mut filter, + &mut mode, + &mut table_state, + &state.views, + ); + } + ModeKind::Help => mode = UiMode::Dashboard, // any key closes help + ModeKind::Confirm => handle_confirm_key(key, &mut mode, &actions), + ModeKind::Busy => { + if key.code == KeyCode::Esc && in_flight.load(Ordering::SeqCst) == 0 + { mode = UiMode::Dashboard; } - _ => {} } - } - UiMode::Busy(_) => { - if key.code == KeyCode::Esc { - mode = UiMode::Dashboard; + ModeKind::Dashboard => { + let two_col = terminal + .size() + .map(|s| s.width >= TWO_COL_MIN_WIDTH) + .unwrap_or(true); + running = handle_dashboard_key( + &actions, + key, + &display_rows, + &state.views, + &mut table_state, + &mut mode, + &mut filter, + &mut follow, + &mut log_scroll, + &mut log_height, + &mut table_width, + two_col, + ); } } - UiMode::Dashboard => { - running = handle_dashboard_key( - orchestrator.clone(), - key, - &mut table_state, - &display_rows, - &views, - &mut mode, - ) - .await?; - } } + // Repaint on resize so the layout reflows immediately. + Event::Resize(_, _) => needs_redraw = true, + _ => {} } } + + // Surface any action failure as a footer banner; expire it after 8s. + while let Ok(err) = err_rx.try_recv() { + error_banner = Some((err, Instant::now())); + needs_redraw = true; + } + if matches!(&error_banner, Some((_, t)) if t.elapsed() >= Duration::from_secs(8)) { + error_banner = None; + needs_redraw = true; + } + + if state_rx.has_changed().unwrap_or(false) { + state = state_rx.borrow_and_update().clone(); + needs_redraw = true; + } } + poller.abort(); Ok(()) } .await; @@ -134,117 +350,226 @@ pub async fn run(orchestrator: Arc) -> Result<()> { } #[allow(clippy::too_many_arguments)] -async fn handle_dashboard_key( - orchestrator: Arc, +fn handle_dashboard_key( + actions: &Actions, key: KeyEvent, - table_state: &mut TableState, display_rows: &[DisplayRow], views: &[WorkerView], + table_state: &mut TableState, mode: &mut UiMode, -) -> Result { - let worker_name = selected_worker(display_rows, views, table_state.selected().unwrap_or(0)); + filter: &mut String, + follow: &mut bool, + log_scroll: &mut usize, + log_height: &mut u16, + table_width: &mut u16, + two_col: bool, +) -> bool { + let selected = table_state.selected(); + let worker_name = selected_worker(display_rows, views, selected); match key.code { - KeyCode::Char('q') | KeyCode::Esc => return Ok(false), - KeyCode::Up | KeyCode::Char('k') => { - if let Some(i) = move_selection(display_rows, table_state.selected().unwrap_or(0), false) - { - table_state.select(Some(i)); + KeyCode::Char('q') => return false, + // Esc clears an active filter first; only quits when nothing to clear. + KeyCode::Esc => { + if filter.is_empty() { + return false; } + filter.clear(); } - KeyCode::Down | KeyCode::Char('j') => { - if let Some(i) = move_selection(display_rows, table_state.selected().unwrap_or(0), true) { + KeyCode::Up | KeyCode::Down | KeyCode::Char('k') | KeyCode::Char('j') => { + let down = matches!(key.code, KeyCode::Down | KeyCode::Char('j')); + if let Some(i) = move_selection(display_rows, selected.unwrap_or(0), down) { table_state.select(Some(i)); + // Re-follow the newly selected worker's live tail. + *follow = true; + *log_scroll = 0; + } + } + KeyCode::Char('/') => *mode = UiMode::Filter, + KeyCode::Char('?') => *mode = UiMode::Help, + KeyCode::Char('f') => { + *follow = !*follow; + if *follow { + *log_scroll = 0; + } + } + KeyCode::PageUp => { + *follow = false; + *log_scroll = (*log_scroll + LOG_PAGE).min(LOG_SCROLLBACK); + } + KeyCode::PageDown => { + *log_scroll = log_scroll.saturating_sub(LOG_PAGE); + if *log_scroll == 0 { + *follow = true; + } + } + // `+` always gives the logs more room. Two-column: drag the divider left + // (shrink the list). Stacked: grow the log pane's height. `-` reverses it. + KeyCode::Char('+') | KeyCode::Char('=') => { + if two_col { + *table_width = table_width.saturating_sub(4).max(TABLE_WIDTH_MIN); + } else { + *log_height = (*log_height + 2).min(LOG_HEIGHT_MAX); + } + } + KeyCode::Char('-') | KeyCode::Char('_') => { + if two_col { + *table_width = (*table_width + 4).min(TABLE_PANE_WIDTH); + } else { + *log_height = log_height.saturating_sub(2).max(LOG_HEIGHT_MIN); } } KeyCode::Char('r') => { if let Some(name) = worker_name { if views.iter().any(|v| v.name == name && v.spawnable) { - *mode = UiMode::ConfirmRestart(name); + let dependents = actions + .orchestrator + .graph + .reverse_dependents(&name) + .unwrap_or_default(); + *mode = UiMode::ConfirmRestart { name, dependents }; } else { - *mode = UiMode::Busy( - "worker not startable from workers-dev (use iii worker add)".into(), - ); + *mode = not_startable_msg(); } } } KeyCode::Char('s') => { if let Some(name) = worker_name { if views.iter().any(|v| v.name == name && v.spawnable) { - spawn_start(orchestrator.clone(), vec![name.clone()]); + spawn_start(actions, vec![name.clone()]); *mode = UiMode::Busy(format!("starting {name}…")); } else { - *mode = UiMode::Busy( - "worker not startable from workers-dev (use iii worker add)".into(), - ); + *mode = not_startable_msg(); } } } KeyCode::Char('x') => { if let Some(name) = worker_name { - spawn_stop(orchestrator.clone(), vec![name.clone()]); - *mode = UiMode::Busy(format!("stopping {name}…")); - } - } - KeyCode::Char('l') => { - if let Some(name) = worker_name { - disable_raw_mode()?; - execute!(io::stdout(), LeaveAlternateScreen)?; - println!("Following logs for {name} (Ctrl+C to return)…\n"); - let _ = crate::commands::run_logs(orchestrator.clone(), name, true, 20).await; - enable_raw_mode()?; - execute!(io::stdout(), EnterAlternateScreen)?; + // Only stop workers with a live local child; `external` and + // `elsewhere` rows have no process workers-dev can kill. + if views.iter().any(|v| v.name == name && v.local_pid.is_some()) { + spawn_stop(actions, vec![name.clone()]); + *mode = UiMode::Busy(format!("stopping {name}…")); + } else { + *mode = UiMode::Busy("nothing to stop: not running under workers-dev".into()); + } } } KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { - spawn_start_harness_stack(orchestrator.clone()); + spawn_start_harness_stack(actions); *mode = UiMode::Busy("starting harness stack…".to_string()); } KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { - spawn_start(orchestrator.clone(), orchestrator.config.workers.clone()); + let names = actions.orchestrator.config.workers.clone(); + spawn_start(actions, names); *mode = UiMode::Busy("starting all managed workers…".to_string()); } _ => {} } - Ok(true) + true } -fn spawn_start(orchestrator: Arc, names: Vec) { - tokio::spawn(async move { - if let Err(err) = orchestrator.start_workers(&names, false).await { - eprintln!("workers-dev: start failed: {err:#}"); +fn handle_filter_key( + key: KeyEvent, + filter: &mut String, + mode: &mut UiMode, + table_state: &mut TableState, + views: &[WorkerView], +) { + match key.code { + KeyCode::Enter => *mode = UiMode::Dashboard, // keep the filter + KeyCode::Esc => { + filter.clear(); + *mode = UiMode::Dashboard; } - }); + KeyCode::Backspace => { + filter.pop(); + } + KeyCode::Char(c) => filter.push(c), + _ => {} + } + // Keep the highlight on a visible row as the match set changes. + let rows = build_display_rows(views, filter); + table_state.select(first_worker_row(&rows)); } -fn spawn_start_harness_stack(orchestrator: Arc) { - tokio::spawn(async move { - if let Err(err) = orchestrator.start_harness_stack(false).await { - eprintln!("workers-dev: start failed: {err:#}"); +fn handle_confirm_key(key: KeyEvent, mode: &mut UiMode, actions: &Actions) { + let UiMode::ConfirmRestart { name, .. } = mode else { + return; + }; + let name = name.clone(); + match key.code { + KeyCode::Char('y') | KeyCode::Enter => { + spawn_restart(actions, name.clone()); + *mode = UiMode::Busy(format!("restarting {name} + dependents…")); } - }); + KeyCode::Char('n') | KeyCode::Esc => *mode = UiMode::Dashboard, + _ => {} + } +} + +fn not_startable_msg() -> UiMode { + UiMode::Busy("worker not startable from workers-dev (use iii worker add)".into()) } -fn spawn_stop(orchestrator: Arc, names: Vec) { +/// Run a worker action on a detached task, counting it as in-flight for the +/// whole duration so the UI keeps the Busy overlay up and refuses overlapping +/// actions until it finishes. Failures are sent back to the UI (not printed, +/// which would corrupt the alt-screen). +fn spawn_action(actions: &Actions, fut: F) +where + F: std::future::Future> + Send + 'static, +{ + actions.in_flight.fetch_add(1, Ordering::SeqCst); + let in_flight = actions.in_flight.clone(); + let errors = actions.errors.clone(); tokio::spawn(async move { - if let Err(err) = orchestrator.stop_workers(&names).await { - eprintln!("workers-dev: stop failed: {err:#}"); + if let Err(err) = fut.await { + let _ = errors.send(format!("{err:#}")); } + in_flight.fetch_sub(1, Ordering::SeqCst); }); } -fn spawn_restart(orchestrator: Arc, worker: String) { - tokio::spawn(async move { - if let Err(err) = orchestrator.restart_worker(&worker).await { - eprintln!("workers-dev: restart failed: {err:#}"); - } +fn spawn_start(actions: &Actions, names: Vec) { + let orchestrator = actions.orchestrator.clone(); + spawn_action(actions, async move { + orchestrator.start_workers(&names, false).await + }); +} + +fn spawn_start_harness_stack(actions: &Actions) { + let orchestrator = actions.orchestrator.clone(); + spawn_action(actions, async move { + orchestrator.start_harness_stack(false).await }); } -fn build_display_rows(views: &[WorkerView]) -> Vec { +fn spawn_stop(actions: &Actions, names: Vec) { + let orchestrator = actions.orchestrator.clone(); + spawn_action( + actions, + async move { orchestrator.stop_workers(&names).await }, + ); +} + +fn spawn_restart(actions: &Actions, worker: String) { + let orchestrator = actions.orchestrator.clone(); + spawn_action(actions, async move { + orchestrator.restart_worker(&worker).await + }); +} + +/// Build the table rows, applying the name filter (case-insensitive). A group +/// header is emitted only when that group has at least one matching worker. +fn build_display_rows(views: &[WorkerView], filter: &str) -> Vec { + let needle = filter.to_lowercase(); let mut rows = Vec::new(); let mut last_group = None; for (idx, view) in views.iter().enumerate() { + if !needle.is_empty() && !view.name.to_lowercase().contains(&needle) { + continue; + } if last_group != Some(view.group) { rows.push(DisplayRow { kind: DisplayRowKind::Header(view.group), @@ -259,7 +584,23 @@ fn build_display_rows(views: &[WorkerView]) -> Vec { } fn first_worker_row(display_rows: &[DisplayRow]) -> Option { - display_rows.iter().position(|row| matches!(row.kind, DisplayRowKind::Worker(_))) + display_rows + .iter() + .position(|row| matches!(row.kind, DisplayRowKind::Worker(_))) +} + +/// Snap the selection back onto a visible worker row when the current one +/// scrolled out (e.g. the filter changed) so the highlight is never stranded. +fn clamp_selection(table_state: &mut TableState, display_rows: &[DisplayRow]) { + let valid = matches!( + table_state.selected().and_then(|i| display_rows.get(i)), + Some(DisplayRow { + kind: DisplayRowKind::Worker(_) + }) + ); + if !valid { + table_state.select(first_worker_row(display_rows)); + } } fn move_selection(display_rows: &[DisplayRow], current: usize, down: bool) -> Option { @@ -284,25 +625,14 @@ fn move_selection(display_rows: &[DisplayRow], current: usize, down: bool) -> Op fn selected_worker( display_rows: &[DisplayRow], views: &[WorkerView], - row: usize, + row: Option, ) -> Option { - match display_rows.get(row)?.kind { + match display_rows.get(row?)?.kind { DisplayRowKind::Worker(idx) => views.get(idx).map(|v| v.name.clone()), DisplayRowKind::Header(_) => None, } } -fn selected_worker_view<'a>( - display_rows: &[DisplayRow], - views: &'a [WorkerView], - row: usize, -) -> Option<&'a WorkerView> { - match display_rows.get(row)?.kind { - DisplayRowKind::Worker(idx) => views.get(idx), - DisplayRowKind::Header(_) => None, - } -} - fn styled_if(enabled: bool, style: Style) -> Style { if enabled { style @@ -311,136 +641,249 @@ fn styled_if(enabled: bool, style: Style) -> Style { } } -fn draw_ui( - f: &mut Frame, - engine_url: &str, - views: &[WorkerView], - display_rows: &[DisplayRow], - table_state: &mut TableState, - mode: &UiMode, - color_enabled: bool, -) { +fn draw_ui(f: &mut Frame, table_state: &mut TableState, ctx: &UiCtx) { + let area = f.area(); + // Header and footer span full width; the body between them carries the + // two panes. let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), - Constraint::Min(12), - Constraint::Length(22), + Constraint::Min(MIN_TABLE_HEIGHT), Constraint::Length(1), ]) - .split(f.area()); + .split(area); + let body = chunks[1]; + + draw_header(f, chunks[0], ctx); - let header = Paragraph::new(Line::from(vec![ + if body.width >= TWO_COL_MIN_WIDTH { + // Master/detail: the worker list (content-fit) on the left, its logs + // flexing to fill the rest on the right. Wide terminals give the logs + // the horizontal room they actually benefit from instead of squeezing + // them into a short strip under a half-empty table. + // Divider position: the user's preferred list width (+/-), never so wide + // it starves the logs below their minimum. + let table_w = ctx + .table_width + .clamp(TABLE_WIDTH_MIN, TABLE_PANE_WIDTH) + .min(body.width.saturating_sub(MIN_LOG_PANE_WIDTH + PANE_GUTTER)); + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(table_w), + Constraint::Length(PANE_GUTTER), + Constraint::Min(MIN_LOG_PANE_WIDTH), + ]) + .split(body); + draw_table(f, cols[0], table_state, ctx); + draw_log_pane(f, cols[2], ctx); + } else { + // Too narrow for two columns: stack them, with a user-resizable (+/-) + // log pane that can never starve the list below MIN_TABLE_HEIGHT. + let log_h = ctx + .log_height + .min(body.height.saturating_sub(MIN_TABLE_HEIGHT)) + .max(LOG_HEIGHT_MIN.min(body.height)); + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(MIN_TABLE_HEIGHT), Constraint::Length(log_h)]) + .split(body); + draw_table(f, rows[0], table_state, ctx); + draw_log_pane(f, rows[1], ctx); + } + + draw_footer(f, chunks[2], ctx); + + // Modals center over the whole content area so they keep their width in + // either layout. + if let UiMode::ConfirmRestart { name, dependents } = ctx.mode { + draw_confirm_overlay(f, body, name, dependents, ctx.color_enabled); + } + if matches!(ctx.mode, UiMode::Help) { + draw_help_overlay(f, area, ctx.color_enabled); + } +} + +fn draw_header(f: &mut Frame, area: Rect, ctx: &UiCtx) { + let color = ctx.color_enabled; + let mut spans = vec![ Span::styled( "workers-dev", - styled_if(color_enabled, header_accent_style()) - .add_modifier(Modifier::BOLD), + styled_if(color, header_accent_style()).add_modifier(Modifier::BOLD), ), - Span::raw(" engine "), - Span::styled(engine_url, styled_if(color_enabled, engine_url_style())), - ])) - .block(Block::default().borders(Borders::ALL).title(" workers-dev ")); - f.render_widget(header, chunks[0]); + Span::raw(" "), + Span::styled(ctx.engine_url, styled_if(color, engine_url_style())), + ]; + + if ctx.engine_error.is_some() { + spans.push(Span::styled( + " ⚠ unreachable", + styled_if(color, Style::default().fg(Color::Red)).add_modifier(Modifier::BOLD), + )); + } else { + let (mut connected, mut compiling, mut crashed, mut stopped) = (0u32, 0u32, 0u32, 0u32); + for v in ctx.views { + match v.display_status.as_str() { + "connected" => connected += 1, + "compiling" => compiling += 1, + "crashed" => crashed += 1, + _ => stopped += 1, + } + } + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("●{connected}"), + styled_if(color, Style::default().fg(Color::Green)), + )); + if compiling > 0 { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("◐{compiling}"), + styled_if(color, Style::default().fg(Color::Yellow)), + )); + } + if crashed > 0 { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("✗{crashed}"), + styled_if(color, Style::default().fg(Color::Red)), + )); + } + if stopped > 0 { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("○{stopped}"), + styled_if(color, muted_cell_style()), + )); + } + } - let rows: Vec = display_rows + if !ctx.filter.is_empty() { + spans.push(Span::styled( + format!(" filter:{}", ctx.filter), + styled_if(color, Style::default().fg(Color::Yellow)), + )); + } + + // No box title: the accent "workers-dev" span already names the pane. + let header = Paragraph::new(Line::from(spans)).block(Block::default().borders(Borders::ALL)); + f.render_widget(header, area); +} + +fn draw_table(f: &mut Frame, area: Rect, table_state: &mut TableState, ctx: &UiCtx) { + let color = ctx.color_enabled; + if ctx.display_rows.is_empty() { + let msg = if ctx.filter.is_empty() { + "(no workers discovered)".to_string() + } else { + format!("no workers match \"{}\" · Esc to clear", ctx.filter) + }; + let placeholder = Paragraph::new(Line::from(Span::styled( + format!(" {msg}"), + styled_if(color, hint_style()), + ))) + .block(Block::default().borders(Borders::ALL).title(" Workers ")); + f.render_widget(placeholder, area); + return; + } + let rows: Vec = ctx + .display_rows .iter() .map(|row| match row.kind { - DisplayRowKind::Header(group) => Row::new(vec![Cell::from(format!( - "── {} ──", - group.label() - )) - .style(styled_if(color_enabled, group_header_style(group)))]), + DisplayRowKind::Header(group) => { + Row::new(vec![Cell::from(format!("── {} ──", group.label())) + .style(styled_if(color, group_header_style(group)))]) + } DisplayRowKind::Worker(idx) => { - let v = &views[idx]; - let icon = status_icon(&v.display_status); - let name_cell = if v.spawnable { - Cell::from(Span::styled( - format!("{icon} {}", v.name), - styled_if(color_enabled, status_style(&v.display_status)), - )) - } else { - Cell::from(Line::from(vec![ - Span::styled( - format!("{icon} {} ", v.name), - styled_if(color_enabled, status_style(&v.display_status)), - ), - Span::styled( - "(iii worker add)", - styled_if(color_enabled, non_spawnable_style()), - ), - ])) - }; + let v = &ctx.views[idx]; + let icon = status_icon(&v.display_status, ctx.spinner_frame); + // Name cell is just glyph + name. The wide "(iii worker add)" + // label and the crash exit code moved to the Process column, so + // the name sits right next to its status instead of behind a + // column sized for the longest label. + let name_cell = Cell::from(Span::styled( + format!("{icon} {}", v.name), + styled_if(color, status_style(&v.display_status)), + )); + let (process_text, process_st) = process_cell(v, color); let pid = v .local_pid .map(|p| p.to_string()) .unwrap_or_else(|| "—".to_string()); Row::new(vec![ name_cell, - Cell::from(Span::styled( - v.process_status.clone(), - styled_if(color_enabled, process_style(&v.process_status)), - )), + Cell::from(Span::styled(process_text, process_st)), Cell::from(Span::styled( v.engine_status.clone(), - styled_if(color_enabled, engine_style(&v.engine_status)), + styled_if(color, engine_style(&v.engine_status)), )), Cell::from(Span::styled( pid.clone(), - styled_if(color_enabled, muted_cell_style_for(&pid)), + styled_if(color, muted_cell_style_for(&pid)), )), Cell::from(Span::styled( v.uptime.clone(), - styled_if(color_enabled, muted_cell_style_for(&v.uptime)), + styled_if(color, muted_cell_style_for(&v.uptime)), )), ]) } }) .collect(); + // Content-fit, left-packed widths: the worker name sits right next to its + // status. Worker only has to fit a name now (label/exit moved to Process), + // so it's tight; Uptime (Min) absorbs the right slack. let table = Table::new( rows, [ - Constraint::Percentage(38), - Constraint::Percentage(14), - Constraint::Percentage(14), - Constraint::Percentage(12), - Constraint::Percentage(22), + Constraint::Length(24), + Constraint::Length(10), + Constraint::Length(11), + Constraint::Length(7), + Constraint::Min(8), ], ) .header( - Row::new(vec!["Worker", "Process", "Engine", "PID", "Uptime"]).style( - Style::default().add_modifier(Modifier::BOLD), - ), + Row::new(vec!["Worker", "Process", "Engine", "PID", "Uptime"]) + .style(Style::default().add_modifier(Modifier::BOLD)), ) .block(Block::default().borders(Borders::ALL).title(" Workers ")) - .row_highlight_style(styled_if(color_enabled, selection_row_style())); - f.render_stateful_widget(table, chunks[1], table_state); - - let selected_row = table_state.selected().unwrap_or(0); - render_log_pane( - f, - chunks[2], - selected_worker_view(display_rows, views, selected_row), - color_enabled, - ); - - let (help, overlay) = match mode { - UiMode::Dashboard => ( - " ↑↓ select s start x stop r restart l logs Ctrl+u harness stack Ctrl+a all q quit ", - None, - ), - UiMode::ConfirmRestart(name) => ( - " ↑↓ select s start x stop r restart l logs Ctrl+u harness stack Ctrl+a all q quit ", - Some(format!("Restart {name} and dependents? y/Enter yes n/Esc no")), - ), - UiMode::Busy(msg) => (msg.as_str(), None), - }; - let footer = Paragraph::new(help).style(styled_if(color_enabled, footer_style())); - f.render_widget(footer, chunks[3]); + .row_highlight_style(styled_if(color, selection_row_style())); + f.render_stateful_widget(table, area, table_state); +} - if let Some(text) = overlay { - draw_overlay(f, chunks[1], &text, color_enabled); +/// Process-column text + style. Carries the management/crash detail that used +/// to crowd the name cell: `external` for workers this tool doesn't start, +/// `elsewhere` for ones connected to the engine but started outside this tool, +/// and the exit code on a crash. +fn process_cell(v: &WorkerView, color: bool) -> (String, Style) { + if !v.spawnable { + return ( + "external".to_string(), + styled_if(color, non_spawnable_style()), + ); } + if v.process_status == "crashed" { + let txt = v + .exit_code + .map(|n| format!("exit {n}")) + .unwrap_or_else(|| "crashed".to_string()); + return (txt, styled_if(color, Style::default().fg(Color::Red))); + } + // Connected to the engine but with no process of ours: it's running, just + // started elsewhere. Say so rather than printing "stopped" beside the + // engine's "connected", which reads as a contradiction. + if v.process_status == "stopped" && v.engine_status == "connected" { + return ( + "elsewhere".to_string(), + styled_if(color, non_spawnable_style()), + ); + } + ( + v.process_status.clone(), + styled_if(color, process_style(&v.process_status)), + ) } fn muted_cell_style_for(value: &str) -> Style { @@ -451,65 +894,198 @@ fn muted_cell_style_for(value: &str) -> Style { } } -fn render_log_pane(f: &mut Frame, area: Rect, worker: Option<&WorkerView>, color_enabled: bool) { +fn draw_log_pane(f: &mut Frame, area: Rect, ctx: &UiCtx) { + let color = ctx.color_enabled; let inner_width = area.width.saturating_sub(2) as usize; let inner_height = area.height.saturating_sub(2) as usize; - let title = worker.map(|v| { - Line::from(vec![ - Span::raw(" logs: "), - Span::styled(v.name.clone(), styled_if(color_enabled, log_title_style())), - Span::raw(" "), - ]) - }); + let total = ctx.log_lines.len(); + let max_scroll = total.saturating_sub(inner_height); + let scroll = if ctx.follow { + 0 + } else { + ctx.log_scroll.min(max_scroll) + }; + let end = total.saturating_sub(scroll); + let start = end.saturating_sub(inner_height); - let mut lines: Vec = if let Some(v) = worker { - if v.last_logs.is_empty() { - vec![Line::from(Span::styled( - "(no output yet — press s to start, or `workers-dev logs -f`)", - styled_if(color_enabled, hint_style()), - ))] + let mut lines: Vec = if total == 0 { + // No selection (e.g. the filter hid every worker) needs a different hint + // than a selected worker that simply hasn't produced output yet. + let msg = if ctx.selected_name.is_none() { + "(select a worker to view logs)" } else { - v.last_logs - .iter() - .map(|line| logs::log_line_to_ratatui(line, inner_width, color_enabled)) - .collect() - } + "(no output yet — press s to start this worker, or Ctrl+u to start the harness stack)" + }; + vec![Line::from(Span::styled(msg, styled_if(color, hint_style())))] } else { - vec![Line::from("")] + ctx.log_lines[start..end] + .iter() + .map(|line| logs::log_line_to_ratatui(line, inner_width, color)) + .collect() }; - while lines.len() < inner_height { lines.push(Line::from(" ".repeat(inner_width.max(1)))); } - lines.truncate(inner_height); + lines.truncate(inner_height.max(1)); - let mut block = Block::default().borders(Borders::ALL); - if let Some(title_line) = title { - block = block.title(title_line); + let mut title = vec![Span::raw(" logs")]; + if let Some(name) = ctx.selected_name { + title.push(Span::raw(": ")); + title.push(Span::styled( + name.to_string(), + styled_if(color, log_title_style()), + )); + } + title.push(Span::raw(" ")); + if ctx.follow { + title.push(Span::styled( + "▶ live ", + styled_if(color, Style::default().fg(Color::Green)), + )); } else { - block = block.title(" logs "); + title.push(Span::styled( + format!("⏸ scrolled +{scroll} "), + styled_if(color, Style::default().fg(Color::Yellow)), + )); + } + + let pane = Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .title(Line::from(title)), + ); + f.render_widget(pane, area); +} + +fn draw_footer(f: &mut Frame, area: Rect, ctx: &UiCtx) { + let color = ctx.color_enabled; + if let Some(err) = ctx.error { + let banner = Paragraph::new(format!(" ⚠ {err} ")) + .style(styled_if(color, Style::default().fg(Color::Red)).add_modifier(Modifier::BOLD)); + f.render_widget(banner, area); + return; } - let logs = Paragraph::new(lines).block(block); - f.render_widget(logs, area); + let (text, style) = match ctx.mode { + UiMode::Busy(msg) => (msg.clone(), styled_if(color, footer_style())), + UiMode::Filter => ( + format!(" filter: {}_ (Enter apply · Esc clear) ", ctx.filter), + styled_if(color, Style::default().fg(Color::Yellow)), + ), + _ => { + let help = if area.width >= 86 { + HELP_FULL + } else if area.width >= 64 { + HELP_MID + } else { + HELP_MIN + }; + (help.to_string(), styled_if(color, footer_style())) + } + }; + f.render_widget(Paragraph::new(text).style(style), area); } -fn draw_overlay(f: &mut Frame, area: Rect, message: &str, color_enabled: bool) { - let popup = centered_rect(60, 20, area); +fn draw_confirm_overlay( + f: &mut Frame, + area: Rect, + name: &str, + dependents: &[String], + color_enabled: bool, +) { + let mut lines = vec![Line::from(format!("Restart {name} and its dependents?"))]; + if dependents.is_empty() { + lines.push(Line::from(Span::styled( + "no dependents — only this worker restarts", + styled_if(color_enabled, hint_style()), + ))); + } else { + lines.push(Line::from(Span::styled( + format!("also restarts: {}", dependents.join(", ")), + styled_if(color_enabled, Style::default().fg(Color::Yellow)), + ))); + } + lines.push(Line::from("")); + lines.push(Line::from("y/Enter yes n/Esc no")); + + let popup = centered_rect(64, 40, area); f.render_widget(Clear, popup); f.render_widget( - Paragraph::new(message) + Paragraph::new(lines) .style(styled_if(color_enabled, confirm_prompt_style())) .block( Block::default() .borders(Borders::ALL) - .title(" confirm ") + .title(" confirm restart ") .style(styled_if(color_enabled, overlay_bg_style())), ), popup, ); } +fn draw_help_overlay(f: &mut Frame, area: Rect, color: bool) { + let keys = [ + ("↑ ↓ k j", "select worker"), + ("s", "start selected worker"), + ("x", "stop selected worker"), + ("r", "restart selected + dependents"), + ("f", "toggle live log follow"), + ("PgUp PgDn", "scroll logs"), + ("+ -", "resize the log pane"), + ("/", "filter workers by name"), + ("Ctrl+u", "start the harness stack"), + ("Ctrl+a", "start all managed workers"), + ("?", "toggle this help"), + ("q", "quit (workers keep running)"), + ]; + let mut lines = vec![Line::from("")]; + for (k, d) in keys { + lines.push(Line::from(vec![ + Span::styled( + format!(" {k:<12}"), + styled_if(color, Style::default().fg(Color::Cyan)), + ), + Span::raw(d), + ])); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " status", + styled_if(color, Style::default().add_modifier(Modifier::BOLD)), + ))); + lines.push(Line::from(vec![ + Span::styled(" ● ", styled_if(color, Style::default().fg(Color::Green))), + Span::raw("connected "), + Span::styled("◐ ", styled_if(color, Style::default().fg(Color::Yellow))), + Span::raw("compiling / disconnected"), + ])); + lines.push(Line::from(vec![ + Span::styled(" ✗ ", styled_if(color, Style::default().fg(Color::Red))), + Span::raw("crashed "), + Span::styled("○ ", styled_if(color, muted_cell_style())), + Span::raw("stopped"), + ])); + lines.push(Line::from(Span::styled( + " external = installed via `iii worker add`", + styled_if(color, hint_style()), + ))); + lines.push(Line::from(Span::styled( + " elsewhere = connected, started outside workers-dev", + styled_if(color, hint_style()), + ))); + let popup = centered_rect(58, 75, area); + f.render_widget(Clear, popup); + f.render_widget( + Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .title(" keys · any key to close ") + .style(styled_if(color, overlay_bg_style())), + ), + popup, + ); +} + fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { let popup_layout = Layout::default() .direction(Direction::Vertical) @@ -529,10 +1105,11 @@ fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { .split(popup_layout[1])[1] } -fn status_icon(status: &str) -> &'static str { +fn status_icon(status: &str, spinner_frame: usize) -> &'static str { match status { "connected" => "●", - "disconnected" | "compiling" => "◐", + "compiling" => SPINNER[spinner_frame % SPINNER.len()], + "disconnected" => "◐", "crashed" => "✗", _ => "○", } diff --git a/workers-dev/src/tui/theme.rs b/workers-dev/src/tui/theme.rs index 33415220f5..eaa804d1e1 100644 --- a/workers-dev/src/tui/theme.rs +++ b/workers-dev/src/tui/theme.rs @@ -9,11 +9,11 @@ pub fn header_accent_style() -> Style { } pub fn engine_url_style() -> Style { - Style::default().fg(Color::Gray) + muted_cell_style() } pub fn footer_style() -> Style { - Style::default().fg(Color::DarkGray) + muted_cell_style() } /// Selected row background: ~10% blue tint on a dark terminal (no fg override). @@ -24,7 +24,7 @@ pub fn selection_row_style() -> Style { pub fn group_header_style(group: WorkerGroup) -> Style { match group { WorkerGroup::HarnessStack => Style::default().fg(Color::Cyan), - WorkerGroup::Other => Style::default().fg(Color::DarkGray), + WorkerGroup::Other => muted_cell_style(), } } @@ -33,7 +33,7 @@ pub fn status_style(display_status: &str) -> Style { "connected" => Style::default().fg(Color::Green), "compiling" | "disconnected" => Style::default().fg(Color::Yellow), "crashed" => Style::default().fg(Color::Red), - _ => Style::default().fg(Color::DarkGray), + _ => muted_cell_style(), } } @@ -42,28 +42,33 @@ pub fn process_style(process_status: &str) -> Style { "running" => Style::default().fg(Color::Green), "compiling" => Style::default().fg(Color::Yellow), "crashed" => Style::default().fg(Color::Red), - _ => Style::default().fg(Color::DarkGray), + _ => muted_cell_style(), } } pub fn engine_style(engine_status: &str) -> Style { match engine_status { "connected" => Style::default().fg(Color::Green), - "—" => Style::default().fg(Color::DarkGray), + "—" => muted_cell_style(), _ => Style::default().fg(Color::Yellow), } } +/// Canonical muted style. Applies DIM to the terminal's default foreground +/// instead of a fixed gray, so muted text de-emphasizes relative to the active +/// theme on both dark and light backgrounds — a fixed ANSI gray always fails one +/// polarity. Terminals without DIM fall back to normal-intensity default fg: +/// still readable, just not muted. pub fn muted_cell_style() -> Style { - Style::default().fg(Color::DarkGray) + Style::default().add_modifier(Modifier::DIM) } pub fn non_spawnable_style() -> Style { - Style::default().fg(Color::DarkGray) + muted_cell_style() } pub fn hint_style() -> Style { - Style::default().fg(Color::DarkGray) + muted_cell_style() } pub fn confirm_prompt_style() -> Style {