From 8c184cd4a08f5eb3cb09c373a7d123635f81a48d Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Fri, 26 Jun 2026 21:09:00 -0300 Subject: [PATCH 1/9] fix(workers-dev): stop engine register/unregister storm + orphaned workers The TUI polled engine status by spawning `iii trigger engine::workers::list` every poll tick; each spawn opened a one-shot WebSocket the engine logged as a worker register/unregister pair, flooding engine logs every ~2s. Replace it with one persistent iii-sdk connection (labeled `workers-dev`), lazily opened and reused for every poll, so the engine sees one register at connect and one unregister at clean exit. Also fixes several issues found while reviewing the worker: - Run each worker in its own process group and group-kill (SIGTERM then SIGKILL) on stop. `cargo run` forks the worker binary as a child, so killing only cargo orphaned the worker (it kept running, still connected). This also repairs --stop-on-exit, which was silently broken. - Poll the engine off the UI thread via a watch channel so a slow or unreachable engine can't freeze keyboard input; shorten the query timeout to 3s. - Surface "engine unreachable" in the TUI header and `status` output instead of silently rendering every worker as disconnected. - Serialize TUI start/stop/restart via an in-flight counter so fast keypresses can't interleave actions on the same worker. - `workers-dev up` now boots `iii -c harness/engine.config.yaml` (detached) when the engine isn't reachable. --- workers-dev/Cargo.lock | 1734 +++++++++++++++++++++++++++++-- workers-dev/Cargo.toml | 4 +- workers-dev/README.md | 2 + workers-dev/src/commands/mod.rs | 19 +- workers-dev/src/config.rs | 24 +- workers-dev/src/discover.rs | 20 +- workers-dev/src/logs.rs | 39 +- workers-dev/src/main.rs | 12 +- workers-dev/src/orchestrator.rs | 262 ++++- workers-dev/src/runtime.rs | 5 + workers-dev/src/status.rs | 58 +- workers-dev/src/tui/mod.rs | 153 ++- 12 files changed, 2070 insertions(+), 262 deletions(-) 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..b04b7c2bbd 100644 --- a/workers-dev/README.md +++ b/workers-dev/README.md @@ -35,6 +35,8 @@ Workers are **discovered automatically** from top-level `*/iii.worker.yaml` in t 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. +A worker can read **Process: stopped** while **Engine: connected**. That means it's connected to the engine but was not started by this `workers-dev` (e.g. started elsewhere, or via `iii worker add`) — `workers-dev` only tracks processes it spawned itself. + ## Usage ```bash diff --git a/workers-dev/src/commands/mod.rs b/workers-dev/src/commands/mod.rs index 39ba4b7df6..cbddbc6e11 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?; @@ -88,6 +86,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..55b4e3a1ec 100644 --- a/workers-dev/src/config.rs +++ b/workers-dev/src/config.rs @@ -9,6 +9,10 @@ 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; @@ -17,8 +21,6 @@ pub const DASHBOARD_LOG_LINES: usize = 20; 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,10 +69,14 @@ 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. + let engine_url = format!("ws://{engine_host}:{engine_port}"); let discovered_names = order_worker_names(&worker_specs); let workers = file_cfg.workers.unwrap_or(discovered_names); @@ -83,7 +89,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 +100,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 @@ -183,7 +189,9 @@ pub fn parse_engine_url(url: &str, port_override: Option) -> Result<(String 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}"))?; + .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..e7a70078d9 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, @@ -76,9 +76,10 @@ pub fn discover_repo_workers(repo_root: &Path) -> Result> { .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(""); diff --git a/workers-dev/src/logs.rs b/workers-dev/src/logs.rs index 6a84e2320c..8665929075 100644 --- a/workers-dev/src/logs.rs +++ b/workers-dev/src/logs.rs @@ -15,13 +15,6 @@ pub fn normalize_log_line(raw: &str) -> String { 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, @@ -79,21 +72,21 @@ 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), + truncate_chars(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), + truncate_chars(rest, rest_width), log_kind_style(kind, true), )); return Line::from(spans); @@ -101,7 +94,7 @@ pub fn log_line_to_ratatui(line: &str, max_width: usize, color_enabled: bool) -> 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 +133,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 +142,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(()) } diff --git a/workers-dev/src/main.rs b/workers-dev/src/main.rs index 7fb48cb0ea..536616ca26 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, @@ -131,5 +139,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..485f064967 100644 --- a/workers-dev/src/orchestrator.rs +++ b/workers-dev/src/orchestrator.rs @@ -3,6 +3,7 @@ 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; @@ -18,21 +19,139 @@ 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(), }) } + /// 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::iii::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::iii::IIIConnectionState::Connected + { + time::sleep(Duration::from_millis(50)).await; + } + client + }) + .await + } + + /// 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<()> { - status::fetch_engine_workers(&self.config) + self.engine_workers() .await .with_context(|| { format!( @@ -124,11 +243,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 +306,7 @@ impl Orchestrator { }; if let Some(mut child) = child { - let _ = child.start_kill(); + terminate_process_group(&mut child).await; let _ = child.wait().await; } @@ -197,13 +326,39 @@ impl Orchestrator { if Instant::now() >= deadline { bail!("timed out waiting for {name} to connect to engine"); } - let engine = status::fetch_engine_workers(&self.config).await.unwrap_or_default(); + let engine = self.engine_workers().await.unwrap_or_default(); 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 +373,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 +397,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> { @@ -284,7 +448,6 @@ fn build_view( process_status: process, engine_status, local_pid: rt.pid(), - engine_pid: None, uptime, last_logs: rt.logs.tail(DASHBOARD_LOG_LINES), } @@ -412,3 +575,82 @@ async fn wait_for_exit(worker: &str, expected_pid: Option, runtimes: Shared 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..ccf6d30e60 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()) } diff --git a/workers-dev/src/status.rs b/workers-dev/src/status.rs index 2758948b8a..9c9db9af12 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,26 @@ 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, } -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 +69,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..fa645879c4 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; @@ -33,6 +34,15 @@ enum UiMode { Busy(String), } +/// 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), @@ -51,25 +61,53 @@ 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); + // Seed the dashboard, then 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, 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 (start/stop/restart) still running. The Busy + // overlay clears only when this hits zero, and a new action can't be + // fired while it's non-zero — preventing interleaved start/stop on the + // same worker from a fast keypress. + let in_flight = Arc::new(AtomicUsize::new(0)); + + let mut state = state_rx.borrow().clone(); + let mut display_rows = build_display_rows(&state.views); let mut table_state = TableState::default(); table_state.select(Some(first_worker_row(&display_rows).unwrap_or(0))); let mut mode = UiMode::Dashboard; - let poll = Duration::from_millis(orchestrator.config.poll_interval_ms); - let mut last_poll = Instant::now(); + let mut last_busy_tick = Instant::now(); let mut running = true; let color_enabled = orchestrator.config.color_mode.enabled_for_tui(); while running { - display_rows = build_display_rows(&views); + state = state_rx.borrow().clone(); + display_rows = build_display_rows(&state.views); terminal.draw(|f| { draw_ui( f, &orchestrator.config.engine_url, - &views, + &state.views, + state.engine_error.as_deref(), &display_rows, &mut table_state, &mode, @@ -77,12 +115,12 @@ pub async fn run(orchestrator: Arc) -> Result<()> { ); })?; - if last_poll.elapsed() >= poll { - if let Ok(v) = orchestrator.worker_views().await { - views = v; - } - last_poll = Instant::now(); - if matches!(mode, UiMode::Busy(_)) { + // Clear the Busy overlay once actions finish. Tick on the poll + // cadence so brief informational messages stay readable, but never + // clear while an action is still 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; } } @@ -94,7 +132,11 @@ pub async fn run(orchestrator: Arc) -> Result<()> { let worker = worker.clone(); match key.code { KeyCode::Char('y') | KeyCode::Enter => { - spawn_restart(orchestrator.clone(), worker.clone()); + spawn_restart( + orchestrator.clone(), + in_flight.clone(), + worker.clone(), + ); mode = UiMode::Busy(format!("restarting {worker} + dependents…")); } KeyCode::Char('n') | KeyCode::Esc => { @@ -104,17 +146,22 @@ pub async fn run(orchestrator: Arc) -> Result<()> { } } UiMode::Busy(_) => { - if key.code == KeyCode::Esc { + // Esc dismisses an informational message, but not an + // action that's still running. + if key.code == KeyCode::Esc + && in_flight.load(Ordering::SeqCst) == 0 + { mode = UiMode::Dashboard; } } UiMode::Dashboard => { running = handle_dashboard_key( orchestrator.clone(), + in_flight.clone(), key, &mut table_state, &display_rows, - &views, + &state.views, &mut mode, ) .await?; @@ -124,6 +171,7 @@ pub async fn run(orchestrator: Arc) -> Result<()> { } } + poller.abort(); Ok(()) } .await; @@ -136,6 +184,7 @@ pub async fn run(orchestrator: Arc) -> Result<()> { #[allow(clippy::too_many_arguments)] async fn handle_dashboard_key( orchestrator: Arc, + in_flight: Arc, key: KeyEvent, table_state: &mut TableState, display_rows: &[DisplayRow], @@ -171,7 +220,7 @@ async fn handle_dashboard_key( 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(orchestrator.clone(), in_flight.clone(), vec![name.clone()]); *mode = UiMode::Busy(format!("starting {name}…")); } else { *mode = UiMode::Busy( @@ -182,7 +231,7 @@ async fn handle_dashboard_key( } KeyCode::Char('x') => { if let Some(name) = worker_name { - spawn_stop(orchestrator.clone(), vec![name.clone()]); + spawn_stop(orchestrator.clone(), in_flight.clone(), vec![name.clone()]); *mode = UiMode::Busy(format!("stopping {name}…")); } } @@ -197,11 +246,15 @@ async fn handle_dashboard_key( } } KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { - spawn_start_harness_stack(orchestrator.clone()); + spawn_start_harness_stack(orchestrator.clone(), in_flight.clone()); *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()); + spawn_start( + orchestrator.clone(), + in_flight.clone(), + orchestrator.config.workers.clone(), + ); *mode = UiMode::Busy("starting all managed workers…".to_string()); } _ => {} @@ -209,35 +262,41 @@ async fn handle_dashboard_key( Ok(true) } -fn spawn_start(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. +fn spawn_action(in_flight: Arc, fut: F) +where + F: std::future::Future> + Send + 'static, +{ + in_flight.fetch_add(1, Ordering::SeqCst); tokio::spawn(async move { - if let Err(err) = orchestrator.start_workers(&names, false).await { - eprintln!("workers-dev: start failed: {err:#}"); + if let Err(err) = fut.await { + eprintln!("workers-dev: {err:#}"); } + in_flight.fetch_sub(1, Ordering::SeqCst); }); } -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 spawn_start(orchestrator: Arc, in_flight: Arc, names: Vec) { + spawn_action(in_flight, async move { + orchestrator.start_workers(&names, false).await }); } -fn spawn_stop(orchestrator: Arc, names: Vec) { - tokio::spawn(async move { - if let Err(err) = orchestrator.stop_workers(&names).await { - eprintln!("workers-dev: stop failed: {err:#}"); - } +fn spawn_start_harness_stack(orchestrator: Arc, in_flight: Arc) { + spawn_action(in_flight, async move { + orchestrator.start_harness_stack(false).await }); } -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_stop(orchestrator: Arc, in_flight: Arc, names: Vec) { + spawn_action(in_flight, async move { orchestrator.stop_workers(&names).await }); +} + +fn spawn_restart(orchestrator: Arc, in_flight: Arc, worker: String) { + spawn_action(in_flight, async move { + orchestrator.restart_worker(&worker).await }); } @@ -311,10 +370,12 @@ fn styled_if(enabled: bool, style: Style) -> Style { } } +#[allow(clippy::too_many_arguments)] fn draw_ui( f: &mut Frame, engine_url: &str, views: &[WorkerView], + engine_error: Option<&str>, display_rows: &[DisplayRow], table_state: &mut TableState, mode: &UiMode, @@ -330,16 +391,22 @@ fn draw_ui( ]) .split(f.area()); - let header = Paragraph::new(Line::from(vec![ + let mut header_spans = vec![ Span::styled( "workers-dev", - styled_if(color_enabled, header_accent_style()) - .add_modifier(Modifier::BOLD), + styled_if(color_enabled, 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 ")); + ]; + if engine_error.is_some() { + header_spans.push(Span::styled( + " ⚠ unreachable", + styled_if(color_enabled, Style::default().fg(Color::Red)).add_modifier(Modifier::BOLD), + )); + } + let header = Paragraph::new(Line::from(header_spans)) + .block(Block::default().borders(Borders::ALL).title(" workers-dev ")); f.render_widget(header, chunks[0]); let rows: Vec = display_rows From 1a0acd3fbdcc81036785d2dabfd7b51083460d79 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Sat, 27 Jun 2026 08:23:10 -0300 Subject: [PATCH 2/9] feat(workers-dev): scrollable logs, health summary, filter, and TUI polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the dashboard TUI and fixes DX gaps found dogfooding it: - Scrollable in-place log pane with a follow toggle (PgUp/PgDn, f); no longer ejects the dashboard to tail logs. - At-a-glance health summary in the header (connected/compiling/crashed/stopped) and a `?` overlay with the full key reference. - Filter workers by name with `/` (group headers kept only for matching groups). - Inline crash exit codes in the worker table. - Event-driven rendering: redraw only on input, a new snapshot, a spinner tick, or a live-log refresh — idle no longer repaints continuously. - Animated spinner for compiling workers; resizable log/table split (+/-). - Restart confirm dialog lists the dependents that will also restart. - Worker-action failures now surface as a footer banner instead of eprintln, which was lost or garbled under the alt-screen. - Repaint on terminal resize (the event-driven loop had ignored resize events). Drops the per-worker log tail from the poll snapshot (logs are fetched on demand for the selected worker), making each status poll cheaper. --- workers-dev/README.md | 12 +- workers-dev/src/config.rs | 1 - workers-dev/src/orchestrator.rs | 4 +- workers-dev/src/status.rs | 4 +- workers-dev/src/tui/mod.rs | 811 ++++++++++++++++++++++---------- 5 files changed, 579 insertions(+), 253 deletions(-) diff --git a/workers-dev/README.md b/workers-dev/README.md index b04b7c2bbd..717a8ee16d 100644 --- a/workers-dev/README.md +++ b/workers-dev/README.md @@ -67,16 +67,20 @@ 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) | +| `+`/`-` | Grow / shrink the log pane | +| `/` | 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). +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/config.rs b/workers-dev/src/config.rs index 55b4e3a1ec..fd02771363 100644 --- a/workers-dev/src/config.rs +++ b/workers-dev/src/config.rs @@ -15,7 +15,6 @@ pub const DEFAULT_CONNECT_TIMEOUT_MS: u64 = 120_000; 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 { diff --git a/workers-dev/src/orchestrator.rs b/workers-dev/src/orchestrator.rs index 485f064967..20a0fd04a6 100644 --- a/workers-dev/src/orchestrator.rs +++ b/workers-dev/src/orchestrator.rs @@ -8,7 +8,7 @@ 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; @@ -449,7 +449,7 @@ fn build_view( engine_status, local_pid: rt.pid(), uptime, - last_logs: rt.logs.tail(DASHBOARD_LOG_LINES), + exit_code: rt.exit_code, } } diff --git a/workers-dev/src/status.rs b/workers-dev/src/status.rs index 9c9db9af12..ef16a7444b 100644 --- a/workers-dev/src/status.rs +++ b/workers-dev/src/status.rs @@ -29,7 +29,9 @@ pub struct WorkerView { pub engine_status: String, pub local_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, } /// Query the engine for its connected workers over the shared persistent diff --git a/workers-dev/src/tui/mod.rs b/workers-dev/src/tui/mod.rs index fa645879c4..df1c28c182 100644 --- a/workers-dev/src/tui/mod.rs +++ b/workers-dev/src/tui/mod.rs @@ -28,12 +28,59 @@ 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; + +const HELP: &str = " ↑↓ select · s start · x stop · r restart · / filter · f follow · ? 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. @@ -52,6 +99,25 @@ 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, + 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)?; @@ -61,10 +127,10 @@ pub async fn run(orchestrator: Arc) -> Result<()> { let backend = ratatui::backend::CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - // Seed the dashboard, then poll the engine on a background task so a - // slow or unreachable engine query can't freeze keyboard input. + // 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, state_rx) = tokio::sync::watch::channel(DashboardState { + let (state_tx, mut state_rx) = tokio::sync::watch::channel(DashboardState { views: initial_views, engine_error: initial_err, }); @@ -82,93 +148,151 @@ pub async fn run(orchestrator: Arc) -> Result<()> { }) }; - // Number of worker actions (start/stop/restart) still running. The Busy - // overlay clears only when this hits zero, and a new action can't be - // fired while it's non-zero — preventing interleaved start/stop on the - // same worker from a fast keypress. + // 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 display_rows = build_display_rows(&state.views); let mut table_state = TableState::default(); - table_state.select(Some(first_worker_row(&display_rows).unwrap_or(0))); - let mut mode = UiMode::Dashboard; - let mut last_busy_tick = 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 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 { - state = state_rx.borrow().clone(); - display_rows = build_display_rows(&state.views); - terminal.draw(|f| { - draw_ui( - f, - &orchestrator.config.engine_url, - &state.views, - state.engine_error.as_deref(), - &display_rows, - &mut table_state, - &mode, + let display_rows = build_display_rows(&state.views, &filter); + clamp_selection(&mut table_state, &display_rows); + + let compiling = state.views.iter().any(|v| v.display_status == "compiling"); + + if needs_redraw { + if compiling { + spinner_frame = spinner_frame.wrapping_add(1); + } + 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, + 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. Tick on the poll - // cadence so brief informational messages stay readable, but never - // clear while an action is still running. + // 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(), - in_flight.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(_) => { - // Esc dismisses an informational message, but not an - // action that's still running. - if key.code == KeyCode::Esc - && in_flight.load(Ordering::SeqCst) == 0 - { - mode = UiMode::Dashboard; + ModeKind::Dashboard => { + 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, + ); } } - UiMode::Dashboard => { - running = handle_dashboard_key( - orchestrator.clone(), - in_flight.clone(), - key, - &mut table_state, - &display_rows, - &state.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(); @@ -182,128 +306,205 @@ pub async fn run(orchestrator: Arc) -> Result<()> { } #[allow(clippy::too_many_arguments)] -async fn handle_dashboard_key( - orchestrator: Arc, - in_flight: 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, +) -> 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; + } + } + KeyCode::Char('+') | KeyCode::Char('=') => { + *log_height = (*log_height + 2).min(LOG_HEIGHT_MAX); + } + KeyCode::Char('-') | KeyCode::Char('_') => { + *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(), in_flight.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(), in_flight.clone(), vec![name.clone()]); + spawn_stop(actions, 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)?; - } - } KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { - spawn_start_harness_stack(orchestrator.clone(), in_flight.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(), - in_flight.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 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 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()) } /// 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. -fn spawn_action(in_flight: Arc, fut: F) +/// 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, { - in_flight.fetch_add(1, Ordering::SeqCst); + 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) = fut.await { - eprintln!("workers-dev: {err:#}"); + let _ = errors.send(format!("{err:#}")); } in_flight.fetch_sub(1, Ordering::SeqCst); }); } -fn spawn_start(orchestrator: Arc, in_flight: Arc, names: Vec) { - spawn_action(in_flight, async move { +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(orchestrator: Arc, in_flight: Arc) { - spawn_action(in_flight, async move { +fn spawn_start_harness_stack(actions: &Actions) { + let orchestrator = actions.orchestrator.clone(); + spawn_action(actions, async move { orchestrator.start_harness_stack(false).await }); } -fn spawn_stop(orchestrator: Arc, in_flight: Arc, names: Vec) { - spawn_action(in_flight, async move { orchestrator.stop_workers(&names).await }); +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(orchestrator: Arc, in_flight: Arc, worker: String) { - spawn_action(in_flight, async move { +fn spawn_restart(actions: &Actions, worker: String) { + let orchestrator = actions.orchestrator.clone(); + spawn_action(actions, async move { orchestrator.restart_worker(&worker).await }); } -fn build_display_rows(views: &[WorkerView]) -> Vec { +/// 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), @@ -318,7 +519,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 { @@ -343,25 +560,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 @@ -370,94 +576,146 @@ fn styled_if(enabled: bool, style: Style) -> Style { } } -#[allow(clippy::too_many_arguments)] -fn draw_ui( - f: &mut Frame, - engine_url: &str, - views: &[WorkerView], - engine_error: Option<&str>, - 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 chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), - Constraint::Min(12), - Constraint::Length(22), + Constraint::Min(LOG_HEIGHT_MIN), + Constraint::Length(ctx.log_height), Constraint::Length(1), ]) .split(f.area()); - let mut header_spans = vec![ + draw_header(f, chunks[0], ctx); + draw_table(f, chunks[1], table_state, ctx); + draw_log_pane(f, chunks[2], ctx); + draw_footer(f, chunks[3], ctx); + + if let UiMode::ConfirmRestart { name, dependents } = ctx.mode { + draw_confirm_overlay(f, chunks[1], name, dependents, ctx.color_enabled); + } + if matches!(ctx.mode, UiMode::Help) { + draw_help_overlay(f, 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())), + Span::raw(" "), + Span::styled(ctx.engine_url, styled_if(color, engine_url_style())), ]; - if engine_error.is_some() { - header_spans.push(Span::styled( + + if ctx.engine_error.is_some() { + spans.push(Span::styled( " ⚠ unreachable", - styled_if(color_enabled, Style::default().fg(Color::Red)).add_modifier(Modifier::BOLD), + 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()))); + } + } + + if !ctx.filter.is_empty() { + spans.push(Span::styled( + format!(" filter:{}", ctx.filter), + styled_if(color, Style::default().fg(Color::Yellow)), )); } - let header = Paragraph::new(Line::from(header_spans)) + + let header = Paragraph::new(Line::from(spans)) .block(Block::default().borders(Borders::ALL).title(" workers-dev ")); - f.render_widget(header, chunks[0]); + f.render_widget(header, area); +} - let rows: Vec = display_rows +fn draw_table(f: &mut Frame, area: Rect, table_state: &mut TableState, ctx: &UiCtx) { + let color = ctx.color_enabled; + 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)))]), + .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); + let mut name_spans = vec![Span::styled( + format!("{icon} {}", v.name), + styled_if(color, status_style(&v.display_status)), + )]; + if v.display_status == "crashed" { + if let Some(code) = v.exit_code { + name_spans.push(Span::styled( + format!(" exit {code}"), + styled_if(color, Style::default().fg(Color::Red)), + )); + } + } + if !v.spawnable { + name_spans.push(Span::styled( + " (iii worker add)", + styled_if(color, non_spawnable_style()), + )); + } let pid = v .local_pid .map(|p| p.to_string()) .unwrap_or_else(|| "—".to_string()); Row::new(vec![ - name_cell, + Cell::from(Line::from(name_spans)), Cell::from(Span::styled( v.process_status.clone(), - styled_if(color_enabled, process_style(&v.process_status)), + styled_if(color, process_style(&v.process_status)), )), 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)), )), ]) } @@ -475,39 +733,12 @@ fn draw_ui( ], ) .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]); - - if let Some(text) = overlay { - draw_overlay(f, chunks[1], &text, color_enabled); - } + .row_highlight_style(styled_if(color, selection_row_style())); + f.render_stateful_widget(table, area, table_state); } fn muted_cell_style_for(value: &str) -> Style { @@ -518,65 +749,154 @@ 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 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()), - ))] - } else { - v.last_logs - .iter() - .map(|line| logs::log_line_to_ratatui(line, inner_width, color_enabled)) - .collect() - } + let total = ctx.log_lines.len(); + let max_scroll = total.saturating_sub(inner_height); + let scroll = if ctx.follow { + 0 } else { - vec![Line::from("")] + ctx.log_scroll.min(max_scroll) + }; + let end = total.saturating_sub(scroll); + let start = end.saturating_sub(inner_height); + + let mut lines: Vec = if total == 0 { + vec![Line::from(Span::styled( + "(no output yet — press s to start this worker, or Ctrl+u to start the harness stack)", + styled_if(color, hint_style()), + ))] + } else { + 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 logs = Paragraph::new(lines).block(block); - f.render_widget(logs, area); + + let pane = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title(Line::from(title))); + f.render_widget(pane, area); } -fn draw_overlay(f: &mut Frame, area: Rect, message: &str, color_enabled: bool) { - let popup = centered_rect(60, 20, 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 (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)), + ), + _ => (HELP.to_string(), styled_if(color, footer_style())), + }; + f.render_widget(Paragraph::new(text).style(style), 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"), + ("+ -", "grow / shrink 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), + ])); + } + 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) @@ -596,10 +916,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" => "✗", _ => "○", } From 2c2e5d334ea421cd21f51bd3cd3b12012a2b5d7e Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Sat, 27 Jun 2026 09:07:02 -0300 Subject: [PATCH 3/9] fix(workers-dev): TUI layout/responsive polish from design critique MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cap the log pane so the worker list (primary content) stays visible: on short terminals it no longer starves the list to ~2 rows while the log pane sits empty; the table flexes to fill on tall terminals. - Footer adapts to width in three tiers, always keeping `? keys · q quit`, which previously truncated off at narrow widths. - Content-fit column widths so Process/Engine/PID/Uptime align in a tight cluster instead of floating across the row behind a wide Uptime column. - "No workers match …" placeholder when a filter yields nothing. - Status-glyph legend (connected/compiling/crashed/stopped) in the ? overlay. - Drop the duplicated "workers-dev" header box title. --- workers-dev/src/tui/mod.rs | 85 ++++++++++++++++++++++++++++++++------ 1 file changed, 73 insertions(+), 12 deletions(-) diff --git a/workers-dev/src/tui/mod.rs b/workers-dev/src/tui/mod.rs index df1c28c182..9788ba3658 100644 --- a/workers-dev/src/tui/mod.rs +++ b/workers-dev/src/tui/mod.rs @@ -38,8 +38,16 @@ const LOG_PAGE: usize = 10; 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; -const HELP: &str = " ↑↓ select · s start · x stop · r restart · / filter · f follow · ? keys · q quit "; +/// 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, @@ -577,15 +585,25 @@ fn styled_if(enabled: bool, style: Style) -> Style { } fn draw_ui(f: &mut Frame, table_state: &mut TableState, ctx: &UiCtx) { + let area = f.area(); + // Cap the log pane so the worker list (primary content) always keeps + // MIN_TABLE_HEIGHT. On tall terminals the table flexes to fill and the log + // pane holds at the user's preferred height; on short ones the log pane + // yields instead of starving the list to a couple of rows. + let avail = area.height.saturating_sub(4); // header (3) + footer (1) + let log_h = ctx + .log_height + .min(avail.saturating_sub(MIN_TABLE_HEIGHT)) + .max(LOG_HEIGHT_MIN.min(avail)); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), - Constraint::Min(LOG_HEIGHT_MIN), - Constraint::Length(ctx.log_height), + Constraint::Min(MIN_TABLE_HEIGHT), + Constraint::Length(log_h), Constraint::Length(1), ]) - .split(f.area()); + .split(area); draw_header(f, chunks[0], ctx); draw_table(f, chunks[1], table_state, ctx); @@ -658,13 +676,27 @@ fn draw_header(f: &mut Frame, area: Rect, ctx: &UiCtx) { )); } - let header = Paragraph::new(Line::from(spans)) - .block(Block::default().borders(Borders::ALL).title(" workers-dev ")); + // 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() @@ -722,14 +754,17 @@ fn draw_table(f: &mut Frame, area: Rect, table_state: &mut TableState, ctx: &UiC }) .collect(); + // Content-fit widths, left-packed: status sits right next to the worker + // name instead of floating across the row. Worker is wide enough for the + // longest "(iii worker add)" label; 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(38), + Constraint::Length(10), + Constraint::Length(11), + Constraint::Length(7), + Constraint::Min(8), ], ) .header( @@ -817,7 +852,16 @@ fn draw_footer(f: &mut Frame, area: Rect, ctx: &UiCtx) { format!(" filter: {}_ (Enter apply · Esc clear) ", ctx.filter), styled_if(color, Style::default().fg(Color::Yellow)), ), - _ => (HELP.to_string(), styled_if(color, footer_style())), + _ => { + 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); } @@ -884,6 +928,23 @@ fn draw_help_overlay(f: &mut Frame, area: Rect, color: bool) { 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"), + ])); let popup = centered_rect(58, 75, area); f.render_widget(Clear, popup); f.render_widget( From 03a4be8feff490c7c3d51dfaa6ae8265a2c2e20e Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Sat, 27 Jun 2026 10:20:30 -0300 Subject: [PATCH 4/9] fix(workers-dev): tighten the worker table column layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Worker column was 38 wide only to hold two things stuffed into the name cell, which pushed every worker's status far to the right. Move both into the Process column where management/process state belongs: - Non-startable workers show "external" (was a repeated "(iii worker add)" suffix on the name). This also resolves the "Process: stopped / Engine: connected" contradiction — a connected unmanaged worker now reads "external / connected". - A crash shows "exit N" in the Process column (was trailing the name). The name cell is now just glyph + name, so the Worker column shrinks to 24 and the status block sits right next to the name. At narrow widths all five columns now fit. The ? overlay gains an "external" legend line. --- workers-dev/src/tui/mod.rs | 65 +++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/workers-dev/src/tui/mod.rs b/workers-dev/src/tui/mod.rs index 9788ba3658..3aa277d73a 100644 --- a/workers-dev/src/tui/mod.rs +++ b/workers-dev/src/tui/mod.rs @@ -709,34 +709,22 @@ fn draw_table(f: &mut Frame, area: Rect, table_state: &mut TableState, ctx: &UiC DisplayRowKind::Worker(idx) => { let v = &ctx.views[idx]; let icon = status_icon(&v.display_status, ctx.spinner_frame); - let mut name_spans = vec![Span::styled( + // 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)), - )]; - if v.display_status == "crashed" { - if let Some(code) = v.exit_code { - name_spans.push(Span::styled( - format!(" exit {code}"), - styled_if(color, Style::default().fg(Color::Red)), - )); - } - } - if !v.spawnable { - name_spans.push(Span::styled( - " (iii worker add)", - styled_if(color, non_spawnable_style()), - )); - } + )); + 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![ - Cell::from(Line::from(name_spans)), - Cell::from(Span::styled( - v.process_status.clone(), - styled_if(color, process_style(&v.process_status)), - )), + name_cell, + Cell::from(Span::styled(process_text, process_st)), Cell::from(Span::styled( v.engine_status.clone(), styled_if(color, engine_style(&v.engine_status)), @@ -754,13 +742,13 @@ fn draw_table(f: &mut Frame, area: Rect, table_state: &mut TableState, ctx: &UiC }) .collect(); - // Content-fit widths, left-packed: status sits right next to the worker - // name instead of floating across the row. Worker is wide enough for the - // longest "(iii worker add)" label; Uptime (Min) absorbs the right slack. + // 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::Length(38), + Constraint::Length(24), Constraint::Length(10), Constraint::Length(11), Constraint::Length(7), @@ -776,6 +764,29 @@ fn draw_table(f: &mut Frame, area: Rect, table_state: &mut TableState, ctx: &UiC f.render_stateful_widget(table, area, table_state); } +/// Process-column text + style. Carries the management/crash detail that used +/// to crowd the name cell: `external` for workers this tool doesn't start, 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))); + } + ( + v.process_status.clone(), + styled_if(color, process_style(&v.process_status)), + ) +} + fn muted_cell_style_for(value: &str) -> Style { if value == "—" { muted_cell_style() @@ -945,6 +956,10 @@ fn draw_help_overlay(f: &mut Frame, area: Rect, color: bool) { 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()), + ))); let popup = centered_rect(58, 75, area); f.render_widget(Clear, popup); f.render_widget( From 95c9c1dc95ce5cde5365ae82b153317fd4e58be3 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Sat, 27 Jun 2026 11:33:51 -0300 Subject: [PATCH 5/9] feat(workers-dev): two-column master/detail TUI layout + polish Rework the dashboard into a two-column layout on wide terminals: the worker list (content-fit at 66 cols) on the left, the selected worker's logs flexing to fill the rest on the right. Below ~103 cols the two panes stack vertically as before, so neither is crushed. Header and footer span full width; modals re-anchor to the body so they keep their width in either layout. Critique-driven polish on top of the rework: - Process column shows "elsewhere" when a worker is engine-connected but not spawned here, instead of the contradictory "stopped" + "connected". - +/- drags the column divider in two-column mode (50..66) and still resizes the log height when stacked. - 1-col gutter between panes so their borders no longer fuse into a seam. - Muted text uses ANSI gray (7) instead of bright-black (8), which themes define with readable contrast. --- workers-dev/README.md | 6 +- workers-dev/src/tui/mod.rs | 121 +++++++++++++++++++++++++++++------ workers-dev/src/tui/theme.rs | 19 +++--- 3 files changed, 115 insertions(+), 31 deletions(-) diff --git a/workers-dev/README.md b/workers-dev/README.md index 717a8ee16d..b46582dfa4 100644 --- a/workers-dev/README.md +++ b/workers-dev/README.md @@ -35,7 +35,7 @@ Workers are **discovered automatically** from top-level `*/iii.worker.yaml` in t 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. -A worker can read **Process: stopped** while **Engine: connected**. That means it's connected to the engine but was not started by this `workers-dev` (e.g. started elsewhere, or via `iii worker add`) — `workers-dev` only tracks processes it spawned itself. +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 @@ -73,13 +73,15 @@ Use `--color never` or `NO_COLOR=1` to force plain output. Default `--color auto | `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) | -| `+`/`-` | Grow / shrink the log pane | +| `+`/`-` | 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 | +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/tui/mod.rs b/workers-dev/src/tui/mod.rs index 3aa277d73a..8cdfd1ad0a 100644 --- a/workers-dev/src/tui/mod.rs +++ b/workers-dev/src/tui/mod.rs @@ -41,6 +41,18 @@ 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). @@ -120,6 +132,9 @@ struct UiCtx<'a> { 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. @@ -175,6 +190,7 @@ pub async fn run(orchestrator: Arc) -> Result<()> { 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; @@ -220,6 +236,7 @@ pub async fn run(orchestrator: Arc) -> Result<()> { log_scroll, follow, log_height, + table_width, spinner_frame, color_enabled, error: error_banner.as_ref().map(|(s, _)| s.as_str()), @@ -266,6 +283,10 @@ pub async fn run(orchestrator: Arc) -> Result<()> { } } ModeKind::Dashboard => { + let two_col = terminal + .size() + .map(|s| s.width >= TWO_COL_MIN_WIDTH) + .unwrap_or(true); running = handle_dashboard_key( &actions, key, @@ -277,6 +298,8 @@ pub async fn run(orchestrator: Arc) -> Result<()> { &mut follow, &mut log_scroll, &mut log_height, + &mut table_width, + two_col, ); } } @@ -325,6 +348,8 @@ fn handle_dashboard_key( 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); @@ -365,11 +390,21 @@ fn handle_dashboard_key( *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('=') => { - *log_height = (*log_height + 2).min(LOG_HEIGHT_MAX); + 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('_') => { - *log_height = log_height.saturating_sub(2).max(LOG_HEIGHT_MIN); + 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 { @@ -586,35 +621,65 @@ fn styled_if(enabled: bool, style: Style) -> Style { fn draw_ui(f: &mut Frame, table_state: &mut TableState, ctx: &UiCtx) { let area = f.area(); - // Cap the log pane so the worker list (primary content) always keeps - // MIN_TABLE_HEIGHT. On tall terminals the table flexes to fill and the log - // pane holds at the user's preferred height; on short ones the log pane - // yields instead of starving the list to a couple of rows. - let avail = area.height.saturating_sub(4); // header (3) + footer (1) - let log_h = ctx - .log_height - .min(avail.saturating_sub(MIN_TABLE_HEIGHT)) - .max(LOG_HEIGHT_MIN.min(avail)); + // 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(MIN_TABLE_HEIGHT), - Constraint::Length(log_h), Constraint::Length(1), ]) .split(area); + let body = chunks[1]; draw_header(f, chunks[0], ctx); - draw_table(f, chunks[1], table_state, ctx); - draw_log_pane(f, chunks[2], ctx); - draw_footer(f, chunks[3], ctx); + 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, chunks[1], name, dependents, ctx.color_enabled); + draw_confirm_overlay(f, body, name, dependents, ctx.color_enabled); } if matches!(ctx.mode, UiMode::Help) { - draw_help_overlay(f, f.area(), ctx.color_enabled); + draw_help_overlay(f, area, ctx.color_enabled); } } @@ -765,8 +830,9 @@ fn draw_table(f: &mut Frame, area: Rect, table_state: &mut TableState, ctx: &UiC } /// Process-column text + style. Carries the management/crash detail that used -/// to crowd the name cell: `external` for workers this tool doesn't start, and -/// the exit code on a crash. +/// 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 ( @@ -781,6 +847,15 @@ fn process_cell(v: &WorkerView, color: bool) -> (String, Style) { .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)), @@ -922,7 +997,7 @@ fn draw_help_overlay(f: &mut Frame, area: Rect, color: bool) { ("r", "restart selected + dependents"), ("f", "toggle live log follow"), ("PgUp PgDn", "scroll logs"), - ("+ -", "grow / shrink log pane"), + ("+ -", "resize the log pane"), ("/", "filter workers by name"), ("Ctrl+u", "start the harness stack"), ("Ctrl+a", "start all managed workers"), @@ -957,7 +1032,11 @@ fn draw_help_overlay(f: &mut Frame, area: Rect, color: bool) { Span::raw("stopped"), ])); lines.push(Line::from(Span::styled( - " external = installed via `iii worker add`", + " 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); diff --git a/workers-dev/src/tui/theme.rs b/workers-dev/src/tui/theme.rs index 33415220f5..c8ea8cb607 100644 --- a/workers-dev/src/tui/theme.rs +++ b/workers-dev/src/tui/theme.rs @@ -13,7 +13,7 @@ pub fn engine_url_style() -> Style { } pub fn footer_style() -> Style { - Style::default().fg(Color::DarkGray) + Style::default().fg(Color::Gray) } /// 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 => Style::default().fg(Color::Gray), } } @@ -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), + _ => Style::default().fg(Color::Gray), } } @@ -42,28 +42,31 @@ 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), + _ => Style::default().fg(Color::Gray), } } pub fn engine_style(engine_status: &str) -> Style { match engine_status { "connected" => Style::default().fg(Color::Green), - "—" => Style::default().fg(Color::DarkGray), + "—" => Style::default().fg(Color::Gray), _ => Style::default().fg(Color::Yellow), } } +/// Muted text uses ANSI gray (7), not bright-black/DarkGray (8): terminal themes +/// define 7 with readable contrast, whereas 8 is frequently dim enough to fail +/// AA on dark backgrounds. pub fn muted_cell_style() -> Style { - Style::default().fg(Color::DarkGray) + Style::default().fg(Color::Gray) } pub fn non_spawnable_style() -> Style { - Style::default().fg(Color::DarkGray) + Style::default().fg(Color::Gray) } pub fn hint_style() -> Style { - Style::default().fg(Color::DarkGray) + Style::default().fg(Color::Gray) } pub fn confirm_prompt_style() -> Style { From 8d352649d5394e285e13b66ace0cbe3391e4cc47 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Sat, 27 Jun 2026 20:18:56 -0300 Subject: [PATCH 6/9] fix(workers-dev): theme-agnostic muted text via DIM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Muted styles now apply Modifier::DIM to the default foreground instead of a fixed ANSI gray, so they de-emphasize correctly on both light- and dark-background terminals. A single canonical muted_cell_style() backs the rest (footer, hints, "external"/"elsewhere", "—", stopped, Other header). --- workers-dev/src/tui/theme.rs | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/workers-dev/src/tui/theme.rs b/workers-dev/src/tui/theme.rs index c8ea8cb607..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::Gray) + 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::Gray), + 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::Gray), + _ => muted_cell_style(), } } @@ -42,31 +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::Gray), + _ => muted_cell_style(), } } pub fn engine_style(engine_status: &str) -> Style { match engine_status { "connected" => Style::default().fg(Color::Green), - "—" => Style::default().fg(Color::Gray), + "—" => muted_cell_style(), _ => Style::default().fg(Color::Yellow), } } -/// Muted text uses ANSI gray (7), not bright-black/DarkGray (8): terminal themes -/// define 7 with readable contrast, whereas 8 is frequently dim enough to fail -/// AA on dark backgrounds. +/// 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::Gray) + Style::default().add_modifier(Modifier::DIM) } pub fn non_spawnable_style() -> Style { - Style::default().fg(Color::Gray) + muted_cell_style() } pub fn hint_style() -> Style { - Style::default().fg(Color::Gray) + muted_cell_style() } pub fn confirm_prompt_style() -> Style { From faa356e059a3f76cbda1ddc2fae59cb9421a29db Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Mon, 29 Jun 2026 10:02:03 -0300 Subject: [PATCH 7/9] refactor(workers-dev): import SDK runtime types from canonical iii_sdk::runtime WorkerMetadata and IIIConnectionState were imported via the legacy iii_sdk::iii re-export; 0.20 canonicalizes them under iii_sdk::runtime (where all sibling workers already import them). No behavior change. --- workers-dev/src/orchestrator.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/workers-dev/src/orchestrator.rs b/workers-dev/src/orchestrator.rs index 20a0fd04a6..4381de5ecf 100644 --- a/workers-dev/src/orchestrator.rs +++ b/workers-dev/src/orchestrator.rs @@ -53,7 +53,7 @@ impl Orchestrator { let client = register_worker( &self.config.engine_url, InitOptions { - metadata: Some(iii_sdk::iii::WorkerMetadata { + metadata: Some(iii_sdk::runtime::WorkerMetadata { name: "workers-dev".to_string(), ..Default::default() }), @@ -63,7 +63,7 @@ impl Orchestrator { let deadline = Instant::now() + Duration::from_millis(2000); while Instant::now() < deadline && client.get_connection_state() - != iii_sdk::iii::IIIConnectionState::Connected + != iii_sdk::runtime::IIIConnectionState::Connected { time::sleep(Duration::from_millis(50)).await; } From 574ca62c8356337825ffbd1da2891812b3c12e8c Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Mon, 29 Jun 2026 10:12:57 -0300 Subject: [PATCH 8/9] style(workers-dev): apply cargo fmt --- workers-dev/src/commands/mod.rs | 3 +- workers-dev/src/config.rs | 13 ++++---- workers-dev/src/discover.rs | 10 ++++-- workers-dev/src/graph.rs | 34 +++++++++------------ workers-dev/src/logs.rs | 34 ++++++++++----------- workers-dev/src/main.rs | 4 +-- workers-dev/src/orchestrator.rs | 54 +++++++++++++++++++-------------- workers-dev/src/runtime.rs | 1 - workers-dev/src/status.rs | 9 ++++-- workers-dev/src/tui/mod.rs | 50 ++++++++++++++++++++++-------- 10 files changed, 124 insertions(+), 88 deletions(-) diff --git a/workers-dev/src/commands/mod.rs b/workers-dev/src/commands/mod.rs index cbddbc6e11..0617bc6eb4 100644 --- a/workers-dev/src/commands/mod.rs +++ b/workers-dev/src/commands/mod.rs @@ -74,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; diff --git a/workers-dev/src/config.rs b/workers-dev/src/config.rs index fd02771363..06648f1c49 100644 --- a/workers-dev/src/config.rs +++ b/workers-dev/src/config.rs @@ -173,7 +173,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() + ) } } @@ -186,11 +189,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} (port must be a number, e.g. 49134)") - })?; + 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 e7a70078d9..952799381b 100644 --- a/workers-dev/src/discover.rs +++ b/workers-dev/src/discover.rs @@ -72,8 +72,8 @@ 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 { eprintln!( @@ -165,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 8665929075..ac11151d48 100644 --- a/workers-dev/src/logs.rs +++ b/workers-dev/src/logs.rs @@ -6,11 +6,7 @@ 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() } @@ -42,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") { @@ -78,10 +75,7 @@ pub fn log_line_to_ratatui(line: &str, max_width: usize, color_enabled: bool) -> if let Some((ts, rest)) = split_tracing_timestamp(line) { let kind = classify_log_line(line); let mut spans = vec![ - Span::styled( - truncate_chars(ts, max_width), - log_timestamp_style(true), - ), + Span::styled(truncate_chars(ts, max_width), log_timestamp_style(true)), Span::raw(" "), ]; let rest_width = max_width.saturating_sub(ts.chars().count().min(max_width) + 1); @@ -200,7 +194,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)] @@ -210,16 +208,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] @@ -229,7 +227,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!( diff --git a/workers-dev/src/main.rs b/workers-dev/src/main.rs index 536616ca26..7546bc2665 100644 --- a/workers-dev/src/main.rs +++ b/workers-dev/src/main.rs @@ -122,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, diff --git a/workers-dev/src/orchestrator.rs b/workers-dev/src/orchestrator.rs index 4381de5ecf..378d6a9282 100644 --- a/workers-dev/src/orchestrator.rs +++ b/workers-dev/src/orchestrator.rs @@ -89,7 +89,8 @@ impl Orchestrator { /// 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 { + let Ok((host, port)) = crate::config::parse_engine_url(&self.config.engine_url, None) + else { return false; }; matches!( @@ -147,18 +148,19 @@ impl Orchestrator { return Ok(()); } } - bail!("started the engine but it did not become reachable within {}ms", self.config.connect_timeout_ms); + 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 - ) - })?; + self.engine_workers().await.with_context(|| { + format!( + "engine not reachable at {} (start with: iii -c harness/engine.config.yaml)", + self.config.engine_url + ) + })?; Ok(()) } @@ -320,14 +322,16 @@ 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); loop { if Instant::now() >= deadline { bail!("timed out waiting for {name} to connect to engine"); } let engine = self.engine_workers().await.unwrap_or_default(); - if engine.iter().any(|w| w.name.as_deref() == Some(name) && w.status == "connected") { + 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; @@ -406,7 +410,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()) @@ -420,10 +427,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()) }; @@ -566,9 +570,11 @@ 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 }; } @@ -638,7 +644,11 @@ mod tests { 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"); + assert_eq!( + unsafe { libc::kill(worker_pid, 0) }, + 0, + "worker should be running" + ); terminate_process_group(&mut child).await; let _ = child.wait().await; diff --git a/workers-dev/src/runtime.rs b/workers-dev/src/runtime.rs index ccf6d30e60..6dbb90ed8e 100644 --- a/workers-dev/src/runtime.rs +++ b/workers-dev/src/runtime.rs @@ -114,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 ef16a7444b..7b2605aece 100644 --- a/workers-dev/src/status.rs +++ b/workers-dev/src/status.rs @@ -37,7 +37,10 @@ pub struct WorkerView { /// 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> { +pub async fn fetch_engine_workers( + client: &IIIClient, + timeout_ms: u64, +) -> Result> { let result = client .trigger(TriggerRequest { function_id: "engine::workers::list".to_string(), @@ -48,8 +51,8 @@ pub async fn fetch_engine_workers(client: &IIIClient, timeout_ms: u64) -> Result .await .map_err(|e| anyhow!("engine::workers::list failed (is the engine running?): {e}"))?; - let parsed: EngineWorkersResponse = serde_json::from_value(result) - .context("parse engine workers response")?; + let parsed: EngineWorkersResponse = + serde_json::from_value(result).context("parse engine workers response")?; Ok(parsed.workers) } diff --git a/workers-dev/src/tui/mod.rs b/workers-dev/src/tui/mod.rs index 8cdfd1ad0a..7f23e021f4 100644 --- a/workers-dev/src/tui/mod.rs +++ b/workers-dev/src/tui/mod.rs @@ -164,7 +164,13 @@ pub async fn run(orchestrator: Arc) -> Result<()> { 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() { + if state_tx + .send(DashboardState { + views, + engine_error, + }) + .is_err() + { break; // UI gone } } @@ -273,12 +279,19 @@ pub async fn run(orchestrator: Arc) -> Result<()> { 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); + 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 { + if key.code == KeyCode::Esc && in_flight.load(Ordering::SeqCst) == 0 + { mode = UiMode::Dashboard; } } @@ -528,7 +541,10 @@ fn spawn_start_harness_stack(actions: &Actions) { fn spawn_stop(actions: &Actions, names: Vec) { let orchestrator = actions.orchestrator.clone(); - spawn_action(actions, async move { orchestrator.stop_workers(&names).await }); + spawn_action( + actions, + async move { orchestrator.stop_workers(&names).await }, + ); } fn spawn_restart(actions: &Actions, worker: String) { @@ -730,7 +746,10 @@ fn draw_header(f: &mut Frame, area: Rect, ctx: &UiCtx) { } if stopped > 0 { spans.push(Span::raw(" ")); - spans.push(Span::styled(format!("○{stopped}"), styled_if(color, muted_cell_style()))); + spans.push(Span::styled( + format!("○{stopped}"), + styled_if(color, muted_cell_style()), + )); } } @@ -766,11 +785,10 @@ fn draw_table(f: &mut Frame, area: Rect, table_state: &mut TableState, ctx: &UiC .display_rows .iter() .map(|row| match row.kind { - DisplayRowKind::Header(group) => Row::new(vec![Cell::from(format!( - "── {} ──", - group.label() - )) - .style(styled_if(color, 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 = &ctx.views[idx]; let icon = status_icon(&v.display_status, ctx.spinner_frame); @@ -904,7 +922,10 @@ fn draw_log_pane(f: &mut Frame, area: Rect, ctx: &UiCtx) { 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::styled( + name.to_string(), + styled_if(color, log_title_style()), + )); } title.push(Span::raw(" ")); if ctx.follow { @@ -919,8 +940,11 @@ fn draw_log_pane(f: &mut Frame, area: Rect, ctx: &UiCtx) { )); } - let pane = Paragraph::new(lines) - .block(Block::default().borders(Borders::ALL).title(Line::from(title))); + let pane = Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .title(Line::from(title)), + ); f.render_widget(pane, area); } From b3ab1cb71e8a00f35494676cac7a90938f6b66fc Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Mon, 29 Jun 2026 12:50:11 -0300 Subject: [PATCH 9/9] fix(workers-dev): address CodeRabbit review on PR #368 - config: preserve wss:// scheme when canonicalizing engine_url - config: skip (warn) configured workers missing/mismatched in discovery instead of aborting startup later in WorkerGraph::load - logs: stop a max-width timestamp overflowing the log pane by one column - orchestrator: surface engine-query failures in wait_connected timeout - tui: only allow stop on workers with a live local process - tui: distinct empty-log hint when no worker is selected - README: use the "Process: external" label the TUI actually renders --- workers-dev/README.md | 2 +- workers-dev/src/config.rs | 34 ++++++++++++++++++++++++++--- workers-dev/src/logs.rs | 38 +++++++++++++++++++++++++-------- workers-dev/src/orchestrator.rs | 20 +++++++++++++++-- workers-dev/src/tui/mod.rs | 22 +++++++++++++------ 5 files changed, 95 insertions(+), 21 deletions(-) diff --git a/workers-dev/README.md b/workers-dev/README.md index b46582dfa4..d3b4aee586 100644 --- a/workers-dev/README.md +++ b/workers-dev/README.md @@ -33,7 +33,7 @@ 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**. diff --git a/workers-dev/src/config.rs b/workers-dev/src/config.rs index 06648f1c49..d6a9e0491e 100644 --- a/workers-dev/src/config.rs +++ b/workers-dev/src/config.rs @@ -74,11 +74,39 @@ impl Config { 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. - let engine_url = format!("ws://{engine_host}:{engine_port}"); + // 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)); diff --git a/workers-dev/src/logs.rs b/workers-dev/src/logs.rs index ac11151d48..70d2cba645 100644 --- a/workers-dev/src/logs.rs +++ b/workers-dev/src/logs.rs @@ -74,15 +74,19 @@ pub fn log_line_to_ratatui(line: &str, max_width: usize, color_enabled: bool) -> if let Some((ts, rest)) = split_tracing_timestamp(line) { let kind = classify_log_line(line); - let mut spans = vec![ - Span::styled(truncate_chars(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( - truncate_chars(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); } @@ -252,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/orchestrator.rs b/workers-dev/src/orchestrator.rs index 378d6a9282..b1d3364fcb 100644 --- a/workers-dev/src/orchestrator.rs +++ b/workers-dev/src/orchestrator.rs @@ -323,11 +323,27 @@ impl Orchestrator { pub async fn wait_connected(&self, name: &str) -> Result<()> { 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 = self.engine_workers().await.unwrap_or_default(); + 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") diff --git a/workers-dev/src/tui/mod.rs b/workers-dev/src/tui/mod.rs index 7f23e021f4..587edd44c6 100644 --- a/workers-dev/src/tui/mod.rs +++ b/workers-dev/src/tui/mod.rs @@ -445,8 +445,14 @@ fn handle_dashboard_key( } KeyCode::Char('x') => { if let Some(name) = worker_name { - spawn_stop(actions, vec![name.clone()]); - *mode = UiMode::Busy(format!("stopping {name}…")); + // 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) => { @@ -904,10 +910,14 @@ fn draw_log_pane(f: &mut Frame, area: Rect, ctx: &UiCtx) { let start = end.saturating_sub(inner_height); let mut lines: Vec = if total == 0 { - vec![Line::from(Span::styled( - "(no output yet — press s to start this worker, or Ctrl+u to start the harness stack)", - styled_if(color, hint_style()), - ))] + // 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 { + "(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 { ctx.log_lines[start..end] .iter()