From b972fabab41e483677aa6135ea8ef642a04a0a5c Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 11 May 2026 22:37:58 +0000 Subject: [PATCH 01/40] chore(main): release 1.700.0 (#9092) * chore(main): release 1.700.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 23 + backend/Cargo.lock | 554 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 349 insertions(+), 308 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98cdc3b50a..f978732508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [1.700.0](https://github.com/windmill-labs/windmill/compare/v1.699.0...v1.700.0) (2026-05-11) + + +### Features + +* **cli:** auto-infer args for `wmill app push` ([#9091](https://github.com/windmill-labs/windmill/issues/9091)) ([43b1800](https://github.com/windmill-labs/windmill/commit/43b18006f32fd5db54bbf8ae7ff0e0b314a517e5)) +* **forks:** prompt to delete forked children when deleting a fork ([#9097](https://github.com/windmill-labs/windmill/issues/9097)) ([e43a958](https://github.com/windmill-labs/windmill/commit/e43a958c5c6ae01a1fbecf3db63c6541a245be62)) +* **operators:** allow operators to access assets page ([#9095](https://github.com/windmill-labs/windmill/issues/9095)) ([20ecd90](https://github.com/windmill-labs/windmill/commit/20ecd904e7060c3cf90f2605740bb349b2a3e6ed)) +* **vault:** configurable JWT auth mount path and setup-doc fixes ([#9100](https://github.com/windmill-labs/windmill/issues/9100)) ([f8ba084](https://github.com/windmill-labs/windmill/commit/f8ba0840d74572c880cf458938365b3ec808c6fb)) + + +### Bug Fixes + +* add Input, Result, Trigger to reserved flow step IDs ([#9109](https://github.com/windmill-labs/windmill/issues/9109)) ([9f79a86](https://github.com/windmill-labs/windmill/commit/9f79a86a686708f66ccc512d4f132cb9a00397a7)), closes [#7139](https://github.com/windmill-labs/windmill/issues/7139) +* **frontend:** mark Path dirty when folder picker changes selection ([#9096](https://github.com/windmill-labs/windmill/issues/9096)) ([23bb1b5](https://github.com/windmill-labs/windmill/commit/23bb1b541e78846d5978153fd8d9bb4f01cec72b)) +* mask oauth client secret in instance settings ([#9112](https://github.com/windmill-labs/windmill/issues/9112)) ([ac3c155](https://github.com/windmill-labs/windmill/commit/ac3c155541eb5ca20d65c38ad13dca6c10a572c9)) +* populate raw_code for flowscript and appscript runs ([#9104](https://github.com/windmill-labs/windmill/issues/9104)) ([05172ac](https://github.com/windmill-labs/windmill/commit/05172ac3bdfc3472da5e9d8a825cdd479ba9e375)) + + +### Performance Improvements + +* lazy-load script editor history and hit partial index ([#9107](https://github.com/windmill-labs/windmill/issues/9107)) ([03e8bc8](https://github.com/windmill-labs/windmill/commit/03e8bc8c14258355d7d695333c1588807fbf8cd6)) + ## [1.699.0](https://github.com/windmill-labs/windmill/compare/v1.698.0...v1.699.0) (2026-05-08) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 82235725ee..023e947232 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -41,9 +41,9 @@ dependencies = [ [[package]] name = "aes" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher 0.4.4", @@ -57,7 +57,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ "aead", - "aes 0.8.3", + "aes 0.8.4", "cipher 0.4.4", "ctr", "ghash", @@ -164,7 +164,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -175,7 +175,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1323,9 +1323,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.4" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core 0.5.6", "axum-macros", @@ -1344,8 +1344,7 @@ dependencies = [ "multer", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", @@ -1522,7 +1521,7 @@ version = "0.71.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -1542,7 +1541,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -1585,11 +1584,11 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -1816,6 +1815,15 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -2091,6 +2099,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -2552,14 +2571,14 @@ dependencies = [ [[package]] name = "csv" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdc4883a9c96732e4733212c01447ebd805833b7275a73ca3ee080fd77afdaf" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" dependencies = [ "csv-core", "itoa", "ryu", - "serde", + "serde_core", ] [[package]] @@ -2592,7 +2611,7 @@ dependencies = [ "openssl-sys", "pkg-config", "vcpkg", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2652,16 +2671,6 @@ dependencies = [ "darling_macro 0.20.11", ] -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - [[package]] name = "darling" version = "0.23.0" @@ -2714,20 +2723,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim 0.11.1", - "syn 2.0.117", -] - [[package]] name = "darling_core" version = "0.23.0" @@ -2774,17 +2769,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core 0.21.3", - "quote", - "syn 2.0.117", -] - [[package]] name = "darling_macro" version = "0.23.0" @@ -4155,7 +4139,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4450,7 +4434,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4575,13 +4559,12 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] @@ -4602,7 +4585,7 @@ version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "rustc_version 0.4.1", ] @@ -5010,8 +4993,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", - "windows-result 0.3.4", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -5091,6 +5074,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -5367,21 +5351,22 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", "serde", + "serde_core", ] [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashify" @@ -5916,7 +5901,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.56.0", + "windows-core 0.62.2", ] [[package]] @@ -6067,7 +6052,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -6115,7 +6100,7 @@ version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d09b98f7eace8982db770e4408e7470b028ce513ac28fecdc6bf4c30fe92b62" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "cfg-if", "libc", ] @@ -6603,7 +6588,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e668df13f2e97f3eed52d9301f6b1c4c1ccfccc30eab9e6628e4a8c1fc3546" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "bytes", "lazy_static", "libgssapi-sys", @@ -6652,7 +6637,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "libc", "plain", "redox_syscall 0.7.5", @@ -6771,7 +6756,7 @@ version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" dependencies = [ - "hashbrown 0.16.0", + "hashbrown 0.16.1", ] [[package]] @@ -6800,9 +6785,9 @@ dependencies = [ [[package]] name = "lz4_flex" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db9a0d582c2874f68138a16ce1867e0ffde6c0bb0a0df85e1f36d04146db488a" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" [[package]] name = "lzma-sys" @@ -7247,7 +7232,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbb9f371618ce723f095c61fbcdc36e8936956d2b62832f9c7648689b338e052" dependencies = [ "base64 0.22.1", - "bitflags 2.9.4", + "bitflags 2.11.1", "btoi", "byteorder", "bytes", @@ -7289,7 +7274,7 @@ dependencies = [ "openssl-probe 0.2.1", "openssl-sys", "schannel", - "security-framework 3.6.0", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -7306,7 +7291,7 @@ version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "cfg-if", "libc", ] @@ -7317,7 +7302,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", @@ -7329,7 +7314,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", @@ -7341,7 +7326,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", @@ -7388,7 +7373,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7683,7 +7668,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -7791,7 +7776,7 @@ version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "libc", "once_cell", "onig_sys", @@ -7850,7 +7835,7 @@ version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", @@ -8695,11 +8680,11 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.23.4", + "toml_edit 0.25.11+spec-1.1.0", ] [[package]] @@ -8799,7 +8784,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "chrono", "flate2", "hex", @@ -8813,7 +8798,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "chrono", "hex", ] @@ -8900,7 +8885,7 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "getopts", "memchr", "unicase", @@ -8963,7 +8948,7 @@ checksum = "5a70b1b8b47e31d0498ecbc3c5470bb931399a8bfed1fd79d1717a61ce7f96e3" dependencies = [ "ahash 0.8.12", "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.16.1", "parking_lot", ] @@ -9085,6 +9070,17 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -9142,6 +9138,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_distr" version = "0.5.1" @@ -9167,7 +9169,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", ] [[package]] @@ -9281,7 +9283,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", ] [[package]] @@ -9290,7 +9292,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", ] [[package]] @@ -9532,11 +9534,11 @@ checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" [[package]] name = "retry-policies" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503c78f59814e2664c9980b739b19e40f549233ecf39928040ee54fdd431f614" +checksum = "dc05fbf560421a0357a750cbe78c7ca19d4923918490daabba313d5dbc871e47" dependencies = [ - "rand 0.8.5", + "rand 0.10.1", ] [[package]] @@ -9614,7 +9616,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bef41ebc9ebed2c1b1d90203e9d1756091e8a00bbc3107676151f39868ca0ee" dependencies = [ "async-trait", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "bytes", "chrono", @@ -9672,7 +9674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bf7840285c321c3ab20e752a9afb95548c75cd7f4632a0627cea3507e310c1" dependencies = [ "async-lock", - "hashbrown 0.16.0", + "hashbrown 0.16.1", "relative-path", "rquickjs-sys", ] @@ -9857,7 +9859,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -9870,11 +9872,11 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9953,7 +9955,7 @@ dependencies = [ "openssl-probe 0.2.1", "rustls-pki-types", "schannel", - "security-framework 3.6.0", + "security-framework 3.7.0", ] [[package]] @@ -9999,10 +10001,10 @@ dependencies = [ "rustls-native-certs 0.8.3", "rustls-platform-verifier-android", "rustls-webpki 0.103.13", - "security-framework 3.6.0", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10137,7 +10139,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "675656c1eabb620b921efea4f9199f97fc86e36dd6ffd1fbbe48d0f59a4987f5" dependencies = [ - "hashbrown 0.16.0", + "hashbrown 0.16.1", "serde", "serde_json", ] @@ -10338,7 +10340,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -10347,11 +10349,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.6.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -10560,19 +10562,19 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.14.1" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ "base64 0.22.1", + "bs58", "chrono", "hex", "indexmap 1.9.3", "indexmap 2.14.0", "schemars 0.9.0", "schemars 1.2.1", - "serde", - "serde_derive", + "serde_core", "serde_json", "serde_with_macros", "time", @@ -10580,11 +10582,11 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.14.1" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "327ada00f7d64abaac1e55a6911e90cf665aa051b9a561c7006c157f4633135e" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ - "darling 0.21.3", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -10851,7 +10853,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11062,7 +11064,7 @@ dependencies = [ "atoi", "base64 0.22.1", "bigdecimal", - "bitflags 2.9.4", + "bitflags 2.11.1", "byteorder", "bytes", "chrono", @@ -11107,7 +11109,7 @@ dependencies = [ "atoi", "base64 0.22.1", "bigdecimal", - "bitflags 2.9.4", + "bitflags 2.11.1", "byteorder", "chrono", "crc", @@ -11194,7 +11196,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11404,7 +11406,7 @@ version = "15.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c25af97d53cf8aab66a6c68f3418663313fc969ad267fc2a4d19402c329be1" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "is-macro", "num-bigint", "once_cell", @@ -11460,7 +11462,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "017d06ea85008234aa9fb34d805c7dc563f2ea6e03869ed5ac5a2dc27d561e4d" dependencies = [ "arrayvec", - "bitflags 2.9.4", + "bitflags 2.11.1", "either", "num-bigint", "phf 0.11.3", @@ -11782,7 +11784,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "byteorder", "enum-as-inner", "libc", @@ -11810,7 +11812,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -11869,7 +11871,7 @@ dependencies = [ "levenshtein_automata", "log", "lru 0.16.4", - "lz4_flex 0.13.0", + "lz4_flex 0.13.1", "measure_time", "memmap2", "once_cell", @@ -12021,7 +12023,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -12040,7 +12042,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -12556,11 +12558,11 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -12578,14 +12580,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.4" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7211ff1b8f0d3adae1663b7da9ffe396eabe1ca25f0b0bee42b0da29a9ddce93" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap 2.14.0", - "toml_datetime 0.7.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 0.7.15", + "winnow 1.0.2", ] [[package]] @@ -12638,7 +12640,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" dependencies = [ "async-trait", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "bytes", "h2 0.4.14", @@ -12725,7 +12727,7 @@ checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "async-compression", "base64 0.22.1", - "bitflags 2.9.4", + "bitflags 2.11.1", "bytes", "futures-core", "futures-util", @@ -13348,13 +13350,13 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.18.1" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.2", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -13365,7 +13367,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33995a1fee055ff743281cde33a41f0d618ee0bdbe8bdf6859e11864499c2595" dependencies = [ "bindgen 0.71.1", - "bitflags 2.9.4", + "bitflags 2.11.1", "fslock", "gzip-header", "home", @@ -13620,7 +13622,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap 2.14.0", "semver 1.0.28", @@ -13762,7 +13764,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -13786,14 +13788,14 @@ dependencies = [ [[package]] name = "windmill" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-nats", "aws-config", "aws-credential-types", "aws-sdk-sqs", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "chrono", "constant_time_eq 0.3.1", @@ -13867,7 +13869,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.699.0" +version = "1.700.0" dependencies = [ "async-trait", "aws-config", @@ -13897,9 +13899,9 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.699.0" +version = "1.700.0" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "serde", "serde_json", @@ -13910,7 +13912,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "argon2", @@ -13926,7 +13928,7 @@ dependencies = [ "aws-sdk-config", "aws-sigv4", "aws-smithy-types", - "axum 0.8.4", + "axum 0.8.9", "base32", "base64 0.22.1", "bytes", @@ -14053,9 +14055,9 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.699.0" +version = "1.700.0" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "http 1.4.0", "hyper 1.9.0", @@ -14076,9 +14078,9 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.699.0" +version = "1.700.0" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "serde", "serde_json", @@ -14089,10 +14091,10 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", - "axum 0.8.4", + "axum 0.8.9", "chrono", "http 1.4.0", "itertools 0.14.0", @@ -14115,7 +14117,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.699.0" +version = "1.700.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14125,9 +14127,9 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.699.0" +version = "1.700.0" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "itertools 0.14.0", "serde", @@ -14142,9 +14144,9 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.699.0" +version = "1.700.0" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "chrono", "ed25519-dalek", @@ -14164,10 +14166,10 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", - "axum 0.8.4", + "axum 0.8.9", "candle-core", "candle-nn", "candle-transformers", @@ -14187,9 +14189,9 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.699.0" +version = "1.700.0" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "serde", "sql-builder", @@ -14203,9 +14205,9 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.699.0" +version = "1.700.0" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "hyper 1.9.0", "serde", @@ -14224,9 +14226,9 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.699.0" +version = "1.700.0" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "globset", "lazy_static", @@ -14245,9 +14247,9 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.699.0" +version = "1.700.0" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "serde", "serde_json", @@ -14259,14 +14261,14 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-nats", "aws-config", "aws-credential-types", "aws-sdk-sqs", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "futures", "rand 0.9.0", @@ -14291,10 +14293,10 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "chrono", "http 1.4.0", @@ -14316,9 +14318,9 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.699.0" +version = "1.700.0" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "flate2", "reqwest 0.13.1", "serde", @@ -14334,10 +14336,10 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", - "axum 0.8.4", + "axum 0.8.9", "http 1.4.0", "indexmap 2.14.0", "itertools 0.14.0", @@ -14356,9 +14358,9 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.699.0" +version = "1.700.0" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "chrono-tz", "serde", @@ -14376,9 +14378,9 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.699.0" +version = "1.700.0" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "futures", "http 1.4.0", @@ -14406,10 +14408,10 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "bytes", "chrono", @@ -14434,7 +14436,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.699.0" +version = "1.700.0" dependencies = [ "lazy_static", "serde", @@ -14446,10 +14448,10 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.699.0" +version = "1.700.0" dependencies = [ "argon2", - "axum 0.8.4", + "axum 0.8.9", "chrono", "dashmap", "http 1.4.0", @@ -14471,9 +14473,9 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.699.0" +version = "1.700.0" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "serde", "serde_json", @@ -14485,9 +14487,9 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.699.0" +version = "1.700.0" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "hex", "http 1.4.0", @@ -14518,7 +14520,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.699.0" +version = "1.700.0" dependencies = [ "chrono", "lazy_static", @@ -14532,10 +14534,10 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", - "axum 0.8.4", + "axum 0.8.9", "k8s-openapi", "kube", "serde", @@ -14551,7 +14553,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.699.0" +version = "1.700.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14565,10 +14567,10 @@ dependencies = [ "aws-sdk-secretsmanager", "aws-sdk-sts", "aws-smithy-types-convert", - "axum 0.8.4", + "axum 0.8.9", "backon", "base64 0.22.1", - "bitflags 2.9.4", + "bitflags 2.11.1", "bytes", "chrono", "chrono-tz", @@ -14652,7 +14654,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.699.0" +version = "1.700.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14671,7 +14673,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.699.0" +version = "1.700.0" dependencies = [ "regex", "serde", @@ -14686,7 +14688,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14710,7 +14712,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "futures", @@ -14727,7 +14729,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.699.0" +version = "1.700.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14743,7 +14745,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-trait", @@ -14764,11 +14766,11 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "backon", "base64 0.22.1", "chrono", @@ -14795,12 +14797,12 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "arc-swap", "async-oauth2", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "chrono", "hex", @@ -14820,7 +14822,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-stream", @@ -14829,7 +14831,7 @@ dependencies = [ "aws-credential-types", "aws-sdk-sts", "aws-smithy-types-convert", - "axum 0.8.4", + "axum 0.8.9", "bytes", "chrono", "datafusion", @@ -14854,7 +14856,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "futures", @@ -14872,7 +14874,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.699.0" +version = "1.700.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14881,7 +14883,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "lazy_static", @@ -14893,7 +14895,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "serde_json", @@ -14905,7 +14907,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "gosyn", @@ -14917,7 +14919,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "lazy_static", @@ -14929,7 +14931,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "serde_json", @@ -14941,7 +14943,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "nu-parser", @@ -14952,7 +14954,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14963,7 +14965,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14975,7 +14977,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14986,7 +14988,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-recursion", @@ -15008,7 +15010,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "serde_json", @@ -15020,7 +15022,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "lazy_static", @@ -15034,7 +15036,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15051,7 +15053,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "lazy_static", @@ -15064,7 +15066,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "serde", @@ -15076,7 +15078,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "lazy_static", @@ -15094,7 +15096,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15110,7 +15112,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15126,7 +15128,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "serde", @@ -15137,11 +15139,11 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-recursion", - "axum 0.8.4", + "axum 0.8.9", "backon", "chrono", "chrono-tz", @@ -15174,7 +15176,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "const_format", @@ -15212,7 +15214,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.699.0" +version = "1.700.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15223,11 +15225,11 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-recursion", - "axum 0.8.4", + "axum 0.8.9", "chrono", "futures", "http 1.4.0", @@ -15253,11 +15255,11 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "chrono", "futures", "serde", @@ -15277,11 +15279,11 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "chrono", "http 1.4.0", "hyper 1.9.0", @@ -15310,11 +15312,11 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "bytes", "chrono", @@ -15343,11 +15345,11 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "lazy_static", "regex", @@ -15363,11 +15365,11 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "bytes", "chrono", @@ -15397,11 +15399,11 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "chrono", "constant_time_eq 0.3.1", @@ -15433,11 +15435,11 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "itertools 0.14.0", "rdkafka", @@ -15456,11 +15458,11 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "bytes", "itertools 0.14.0", @@ -15480,12 +15482,12 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-nats", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "itertools 0.14.0", "nkeys", @@ -15504,11 +15506,11 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "byteorder", "bytes", "chrono", @@ -15539,7 +15541,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-trait", @@ -15548,7 +15550,7 @@ dependencies = [ "aws-sdk-sqs", "aws-sdk-sts", "aws-smithy-types", - "axum 0.8.4", + "axum 0.8.9", "backon", "chrono", "itertools 0.14.0", @@ -15567,11 +15569,11 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "futures", "http 1.4.0", "itertools 0.14.0", @@ -15590,10 +15592,10 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", - "bitflags 2.9.4", + "bitflags 2.11.1", "chrono", "hex", "itertools 0.14.0", @@ -15609,7 +15611,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-once-cell", @@ -15620,7 +15622,7 @@ dependencies = [ "aws-credential-types", "aws-sdk-bedrockruntime", "aws-smithy-types", - "axum 0.8.4", + "axum 0.8.9", "backon", "base64 0.22.1", "bit-vec 0.6.3", @@ -15722,7 +15724,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.699.0" +version = "1.700.0" dependencies = [ "bytes", "futures", @@ -15817,6 +15819,19 @@ dependencies = [ "windows-strings 0.4.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-future" version = "0.2.1" @@ -15960,7 +15975,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "widestring", "windows-sys 0.52.0", ] @@ -16312,6 +16327,9 @@ name = "winnow" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +dependencies = [ + "memchr", +] [[package]] name = "winsafe" @@ -16383,7 +16401,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.9.4", + "bitflags 2.11.1", "indexmap 2.14.0", "log", "serde", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 326b777385..0b948de5bd 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.699.0" +version = "1.700.0" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.699.0" +version = "1.700.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index d337f4838d..73f8b83834 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.699.0" +version = "1.700.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.699.0" +version = "1.700.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.699.0" +version = "1.700.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.699.0" +version = "1.700.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 80d8c63983..162e192138 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.699.0" +version = "1.700.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 16448c622b..913f734d1f 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.699.0 + version: 1.700.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 3d7b371b24..e23b7ce185 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.699.0"; +export const VERSION = "v1.700.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index 372087c69a..a5e7f96b17 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -79,7 +79,7 @@ export { token, }; -export const VERSION = "1.699.0"; +export const VERSION = "1.700.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0ce5dc49ef..ad85302e2d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.699.0", + "version": "1.700.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.699.0", + "version": "1.700.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 82c7ad8fa9..d3097a5ff9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.699.0", + "version": "1.700.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 3e38043b9e..aeebba0577 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.699.0" +wmill = ">=1.700.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 630ce5fddf..ef418ea0a8 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.699.0 + version: 1.700.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 244a2e0d54..ae6080304c 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.699.0' + ModuleVersion = '1.700.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 5aa17d46c3..cdb26ee835 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.699.0" +version = "1.700.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 884ac84799..b49f05ac73 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.699.0", + "version": "1.700.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index ec86e6d07d..2a821e470c 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.699.0", + "version": "1.700.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index dc6966fffd..5c539588e2 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.699.0 +1.700.0 From cd65de49285ff60abdd94c883180ded65609f382 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 11 May 2026 23:14:20 +0000 Subject: [PATCH 02/40] fix: CE build broken by enterprise-gated compute_instance_hash (#9113) * [ee] fix(license): bump ee-repo-ref to fix CE build #9089 introduced a call from `windmill-api-settings/src/ee.rs::validate_license_key` (license-gated) to `windmill_common::ee::compute_instance_hash` (enterprise-gated). CE builds compile the caller (private + license) but not the callee, breaking the docker image build on main with E0425 since #9089 landed. Companion EE PR drops the enterprise gate from `compute_instance_hash`. The function only depends on common state (BASE_URL, get_instance_uid, canonical_base_url) and the parent `ee` module is already gated by `private`. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: update ee-repo-ref to f9494c6320bb5fd07c1e9e09734b7fd5fbe7aa38 This commit updates the EE repository reference after PR #569 was merged in windmill-ee-private. Previous ee-repo-ref: 0f495da1ef984cad22342c90691c0dc8b0a20faa New ee-repo-ref: f9494c6320bb5fd07c1e9e09734b7fd5fbe7aa38 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 91d8581950..8a34b69a0e 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -c6cd1afe2d9e04809b30751cd1687b28a65e62b1 +f9494c6320bb5fd07c1e9e09734b7fd5fbe7aa38 From 411ca47ffd20302fd1481201bc6292cb93903731 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 11 May 2026 23:23:08 +0000 Subject: [PATCH 03/40] chore(main): release 1.700.1 (#9114) * chore(main): release 1.700.1 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 7 + backend/Cargo.lock | 156 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 125 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f978732508..13a1cdc370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.700.1](https://github.com/windmill-labs/windmill/compare/v1.700.0...v1.700.1) (2026-05-11) + + +### Bug Fixes + +* CE build broken by enterprise-gated compute_instance_hash ([#9113](https://github.com/windmill-labs/windmill/issues/9113)) ([cd65de4](https://github.com/windmill-labs/windmill/commit/cd65de49285ff60abdd94c883180ded65609f382)) + ## [1.700.0](https://github.com/windmill-labs/windmill/compare/v1.699.0...v1.700.0) (2026-05-11) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 023e947232..56d42da02b 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -13788,7 +13788,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-nats", @@ -13869,7 +13869,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.700.0" +version = "1.700.1" dependencies = [ "async-trait", "aws-config", @@ -13899,7 +13899,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.700.0" +version = "1.700.1" dependencies = [ "axum 0.8.9", "chrono", @@ -13912,7 +13912,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "argon2", @@ -14055,7 +14055,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.700.0" +version = "1.700.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14078,7 +14078,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.700.0" +version = "1.700.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14091,7 +14091,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14117,7 +14117,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.700.0" +version = "1.700.1" dependencies = [ "reqwest 0.12.28", "serde", @@ -14127,7 +14127,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.700.0" +version = "1.700.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14144,7 +14144,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.700.0" +version = "1.700.1" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14166,7 +14166,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14189,7 +14189,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.700.0" +version = "1.700.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14205,7 +14205,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.700.0" +version = "1.700.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14226,7 +14226,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.700.0" +version = "1.700.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14247,7 +14247,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.700.0" +version = "1.700.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14261,7 +14261,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-nats", @@ -14293,7 +14293,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14318,7 +14318,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.700.0" +version = "1.700.1" dependencies = [ "axum 0.8.9", "flate2", @@ -14336,7 +14336,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14358,7 +14358,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.700.0" +version = "1.700.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14378,7 +14378,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.700.0" +version = "1.700.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14408,7 +14408,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14436,7 +14436,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.700.0" +version = "1.700.1" dependencies = [ "lazy_static", "serde", @@ -14448,7 +14448,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.700.0" +version = "1.700.1" dependencies = [ "argon2", "axum 0.8.9", @@ -14473,7 +14473,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.700.0" +version = "1.700.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14487,7 +14487,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.700.0" +version = "1.700.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14520,7 +14520,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.700.0" +version = "1.700.1" dependencies = [ "chrono", "lazy_static", @@ -14534,7 +14534,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14553,7 +14553,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.700.0" +version = "1.700.1" dependencies = [ "aes-gcm", "aho-corasick", @@ -14654,7 +14654,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.700.0" +version = "1.700.1" dependencies = [ "chrono", "itertools 0.14.0", @@ -14673,7 +14673,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.700.0" +version = "1.700.1" dependencies = [ "regex", "serde", @@ -14688,7 +14688,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14712,7 +14712,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "futures", @@ -14729,7 +14729,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.700.0" +version = "1.700.1" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14745,7 +14745,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-trait", @@ -14766,7 +14766,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-trait", @@ -14797,7 +14797,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "arc-swap", @@ -14822,7 +14822,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-stream", @@ -14856,7 +14856,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "futures", @@ -14874,7 +14874,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.700.0" +version = "1.700.1" dependencies = [ "convert_case 0.6.0", "serde", @@ -14883,7 +14883,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "lazy_static", @@ -14895,7 +14895,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "serde_json", @@ -14907,7 +14907,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "gosyn", @@ -14919,7 +14919,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "lazy_static", @@ -14931,7 +14931,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "serde_json", @@ -14943,7 +14943,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "nu-parser", @@ -14954,7 +14954,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14965,7 +14965,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14977,7 +14977,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "rustpython-ast", @@ -14988,7 +14988,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-recursion", @@ -15010,7 +15010,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "serde_json", @@ -15022,7 +15022,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "lazy_static", @@ -15036,7 +15036,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15053,7 +15053,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "lazy_static", @@ -15066,7 +15066,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "serde", @@ -15078,7 +15078,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "lazy_static", @@ -15096,7 +15096,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15112,7 +15112,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "rustpython-ast", @@ -15128,7 +15128,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "serde", @@ -15139,7 +15139,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-recursion", @@ -15176,7 +15176,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "const_format", @@ -15214,7 +15214,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.700.0" +version = "1.700.1" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15225,7 +15225,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-recursion", @@ -15255,7 +15255,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-trait", @@ -15279,7 +15279,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-trait", @@ -15312,7 +15312,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-trait", @@ -15345,7 +15345,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-trait", @@ -15365,7 +15365,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-trait", @@ -15399,7 +15399,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-trait", @@ -15435,7 +15435,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-trait", @@ -15458,7 +15458,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-trait", @@ -15482,7 +15482,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-nats", @@ -15506,7 +15506,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-trait", @@ -15541,7 +15541,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-trait", @@ -15569,7 +15569,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-trait", @@ -15592,7 +15592,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -15611,7 +15611,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-once-cell", @@ -15724,7 +15724,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.700.0" +version = "1.700.1" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 0b948de5bd..104fc6e136 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.700.0" +version = "1.700.1" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.700.0" +version = "1.700.1" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 73f8b83834..43780b0703 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.700.0" +version = "1.700.1" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.700.0" +version = "1.700.1" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.700.0" +version = "1.700.1" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.700.0" +version = "1.700.1" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 162e192138..e470ae9e38 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.700.0" +version = "1.700.1" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 913f734d1f..b1e8ceabdb 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.700.0 + version: 1.700.1 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index e23b7ce185..ea5a58f71b 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.700.0"; +export const VERSION = "v1.700.1"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index a5e7f96b17..8980331c31 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -79,7 +79,7 @@ export { token, }; -export const VERSION = "1.700.0"; +export const VERSION = "1.700.1"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ad85302e2d..8123f93c57 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.700.0", + "version": "1.700.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.700.0", + "version": "1.700.1", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index d3097a5ff9..8da5968bb0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.700.0", + "version": "1.700.1", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index aeebba0577..73496b676f 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.700.0" +wmill = ">=1.700.1" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index ef418ea0a8..be157a3961 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.700.0 + version: 1.700.1 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index ae6080304c..8dfc135d48 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.700.0' + ModuleVersion = '1.700.1' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index cdb26ee835..5d9917d249 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.700.0" +version = "1.700.1" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index b49f05ac73..cffc69e164 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.700.0", + "version": "1.700.1", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 2a821e470c..caad8ab8ad 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.700.0", + "version": "1.700.1", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 5c539588e2..e52350d307 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.700.0 +1.700.1 From 01e21c7f913eaf7dffc3d6a31501418ff2104c8b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 12 May 2026 07:00:58 +0000 Subject: [PATCH 04/40] fix: preserve negative integers in Bedrock tool schema conversion (#9116) json_to_document cast any i64 to u64 via `as` and wrapped in PosInt. Negative integers in a tool's JSON schema (e.g. "default": -1, "minimum": -1) became huge unsigned values like 18446744073709551615, which Bedrock rejected with `tools.N.custom.input_schema: int too big to convert`. Try as_u64 first to preserve full u64 range, fall back to as_i64 for negatives via NegInt, then as_f64. Co-authored-by: Claude Opus 4.7 (1M context) --- backend/windmill-ai/src/ai_bedrock.rs | 36 +++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/backend/windmill-ai/src/ai_bedrock.rs b/backend/windmill-ai/src/ai_bedrock.rs index 49166978e8..c05094b8f4 100644 --- a/backend/windmill-ai/src/ai_bedrock.rs +++ b/backend/windmill-ai/src/ai_bedrock.rs @@ -326,8 +326,10 @@ pub fn json_to_document(value: serde_json::Value) -> aws_smithy_types::Document } Value::Array(arr) => Document::Array(arr.into_iter().map(json_to_document).collect()), Value::Number(num) => { - if let Some(i) = num.as_i64() { - Document::Number(aws_smithy_types::Number::PosInt(i as u64)) + if let Some(u) = num.as_u64() { + Document::Number(aws_smithy_types::Number::PosInt(u)) + } else if let Some(i) = num.as_i64() { + Document::Number(aws_smithy_types::Number::NegInt(i)) } else if let Some(f) = num.as_f64() { Document::Number(aws_smithy_types::Number::Float(f)) } else { @@ -844,6 +846,36 @@ mod tests { } } + #[test] + fn json_to_document_preserves_negative_integers() { + let value = serde_json::json!(-1); + let doc = json_to_document(value); + assert!(matches!( + doc, + aws_smithy_types::Document::Number(aws_smithy_types::Number::NegInt(-1)) + )); + } + + #[test] + fn json_to_document_handles_large_u64_above_i64_max() { + let value = serde_json::json!(u64::MAX); + let doc = json_to_document(value); + assert!(matches!( + doc, + aws_smithy_types::Document::Number(aws_smithy_types::Number::PosInt(u)) if u == u64::MAX + )); + } + + #[test] + fn json_to_document_handles_positive_integers() { + let value = serde_json::json!(42); + let doc = json_to_document(value); + assert!(matches!( + doc, + aws_smithy_types::Document::Number(aws_smithy_types::Number::PosInt(42)) + )); + } + #[test] fn openai_messages_to_bedrock_adds_cache_points_when_enabled() { let messages = vec![ From 110384580eabfb929092d777b31b35aaf822487a Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Tue, 12 May 2026 11:42:07 +0200 Subject: [PATCH 05/40] refactor: add global ai chat mode with workspace-item draft tools (#9056) * docs: add global ai mode plan Co-Authored-By: Claude Opus 4.5 * feat: add global ai draft mode Co-Authored-By: Claude Opus 4.5 * fix: scope global ai mode to scripts and flows Co-Authored-By: Claude Opus 4.5 * refactor: simplify global ai workspace item shape Co-Authored-By: Claude Opus 4.5 * refactor: split global ai write tool into per-type tools Co-Authored-By: Claude Opus 4.5 * feat: add global ai schedule and trigger workspace item tools Co-Authored-By: Claude Opus 4.5 * feat: add dev-only /global_drafts route to inspect ai draft store Co-Authored-By: Claude Opus 4.5 * feat: add edit_script and patch_flow_json global ai tools Co-Authored-By: Claude Opus 4.5 * feat: add deploy_workspace_item global ai tool with confirmation Co-Authored-By: Claude Opus 4.5 * feat: emit open-resource action card after deploy_workspace_item Co-Authored-By: Claude Opus 4.5 * feat: add delete_workspace_item global ai tool with confirmation Co-Authored-By: Claude Opus 4.5 * chore(system_prompts): emit RESOURCES_BASE and resource/variable zod schemas Co-Authored-By: Claude Opus 4.5 * feat: add global ai resource and variable workspace item tools Co-Authored-By: Claude Opus 4.5 * fix: search_resource_types uses listResourceType to avoid embedding feature dep Co-Authored-By: Claude Opus 4.5 * Revert "fix: search_resource_types uses listResourceType to avoid embedding feature dep" This reverts commit 6d1d19514a04fc93257b6440cb9c934857b0dfa3. * feat: emit open-resource action card for variable and resource deploys * feat: add global ai raw app workspace item tools * feat: split raw-app prompt into chat-only authoring and cli prefix * feat: add init_app global ai tool to scaffold raw apps from templates * fix: pass write_flow value as JSON string for gemini compat * refactor: hoist countExactMatches and applyExactReplace to chat/shared * refactor: extract editableFlowJson module shared with global mode * fix(global): preserve flow schema and groups across draft and deploy * feat: extract inline scripts from flow reads and patches in global mode * refactor: add findAndReplace helper for match-validated text patches * refactor: extract getInlineRunnableContent helper for app file tools * refactor: extract assertNotGeneratedAppFile guard for /wmill.d.ts * feat: gate global ai mode behind localStorage flag for dev rollout * chore: bump svelte to ^5.55.5 in raw app template (sync with main) * fix: isolate global ai draft rollout * fix: preserve global ai deploy metadata * fix: harden global ai draft tools * chore: remove global ai plan doc * fix: align raw app prompt guidance --------- Co-authored-by: Claude Opus 4.5 --- cli/src/guidance/skills.gen.ts | 274 +- .../components/copilot/CustomAIPrompts.svelte | 5 +- .../copilot/chat/AIChatInput.svelte | 2 + .../copilot/chat/AIChatManager.svelte.ts | 71 +- .../chat/CreatedResourceActionDrawers.svelte | 17 + .../copilot/chat/ToolMessageActions.svelte | 18 +- .../lib/components/copilot/chat/app/core.ts | 46 +- .../lib/components/copilot/chat/flow/core.ts | 463 +--- .../copilot/chat/flow/editableFlowJson.ts | 412 +++ .../copilot/chat/flow/inlineScriptsUtils.ts | 4 +- .../copilot/chat/global/core.test.ts | 148 + .../components/copilot/chat/global/core.ts | 2381 +++++++++++++++++ .../chat/global/deployRequests.test.ts | 165 ++ .../copilot/chat/global/deployRequests.ts | 101 + .../copilot/chat/global/draftStore.svelte.ts | 192 ++ .../copilot/chat/global/draftStore.test.ts | 56 + .../components/copilot/chat/global/gate.ts | 26 + .../src/lib/components/copilot/chat/shared.ts | 57 +- .../copilot/chat/workspaceToolsZod.gen.ts | 21 + .../components/raw_apps}/templates.ts | 37 + .../components/settings/AIPromptsModal.svelte | 8 +- .../(root)/(logged)/apps_raw/add/+page.svelte | 6 +- .../(root)/(logged)/global_drafts/+page.js | 5 + .../(logged)/global_drafts/+page.svelte | 98 + system_prompts/auto-generated/index.d.ts | 2 + system_prompts/auto-generated/index.ts | 10 + system_prompts/auto-generated/prompts.ts | 383 +++ .../auto-generated/skills/raw-app/SKILL.md | 274 +- system_prompts/base/raw-app-cli.md | 226 ++ system_prompts/base/raw-app.md | 276 +- system_prompts/generate.py | 28 +- 31 files changed, 4862 insertions(+), 950 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/flow/editableFlowJson.ts create mode 100644 frontend/src/lib/components/copilot/chat/global/core.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/global/core.ts create mode 100644 frontend/src/lib/components/copilot/chat/global/deployRequests.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/global/deployRequests.ts create mode 100644 frontend/src/lib/components/copilot/chat/global/draftStore.svelte.ts create mode 100644 frontend/src/lib/components/copilot/chat/global/draftStore.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/global/gate.ts rename frontend/src/{routes/(root)/(logged)/apps_raw/add => lib/components/raw_apps}/templates.ts (93%) create mode 100644 frontend/src/routes/(root)/(logged)/global_drafts/+page.js create mode 100644 frontend/src/routes/(root)/(logged)/global_drafts/+page.svelte create mode 100644 system_prompts/base/raw-app-cli.md diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 656f24e5a9..2b0962e09d 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -5426,9 +5426,9 @@ name: raw-app description: MUST use when creating raw apps. --- -# Windmill Raw Apps +# Windmill Raw Apps — CLI workflow -Raw apps let you build custom frontends with React, Svelte, or Vue that connect to Windmill backend runnables and datatables. +This guide covers raw apps from the terminal: scaffolding via \`wmill app new\`, the on-disk layout, and the file-based conventions the CLI uses to represent backend runnables and data table configuration. The platform shape (how a raw app behaves at runtime — frontend bundling, runnable types, datatable SDK calls) is covered in the companion authoring guide. ## Creating a Raw App @@ -5497,7 +5497,7 @@ wmill app new This is the wizard. It only works when run by a human in a real terminal. Don't call it this way from an agent. -## App Structure +## On-disk app layout \`\`\` my_app{{RAW_APP_SUFFIX}}/ @@ -5517,11 +5517,7 @@ my_app{{RAW_APP_SUFFIX}}/ └── *.sql # SQL files to apply via dev server \`\`\` -## Backend Runnables - -Backend runnables are server-side scripts that your frontend can call. They live in the \`backend/\` folder. - -### Creating a Backend Runnable +## Backend runnables on disk Add a code file to the \`backend/\` folder: @@ -5531,7 +5527,7 @@ backend/. The runnable ID is the filename without extension. For example, \`get_user.ts\` creates a runnable with ID \`get_user\`. -### Supported Languages +### Supported languages (extension-driven) | Language | Extension | Example | |------------------|--------------|------------------| @@ -5553,27 +5549,14 @@ The runnable ID is the filename without extension. For example, \`get_user.ts\` | C# | \`.cs\` | \`myFunc.cs\` | | Java | \`.java\` | \`myFunc.java\` | -### Example Backend Runnable - -**backend/get_user.ts:** -\`\`\`typescript -import * as wmill from 'windmill-client'; - -export async function main(user_id: string) { - const sql = wmill.datatable(); - const user = await sql\`SELECT * FROM users WHERE id = \${user_id}\`.fetchOne(); - return user; -} -\`\`\` - -After creating, tell the user they can generate lock files by running: +After creating a runnable, tell the user they can generate lock files by running: \`\`\`bash wmill generate-metadata \`\`\` -### Optional YAML Configuration +### Optional YAML configuration -Add a \`.yaml\` file to configure fields or static values: +Add a \`.yaml\` file alongside the code to configure fields or static values: **backend/get_user.yaml:** \`\`\`yaml @@ -5584,7 +5567,7 @@ fields: value: "default_user" \`\`\` -### Referencing Existing Scripts +### Referencing existing scripts To use an existing Windmill script instead of inline code: @@ -5600,32 +5583,9 @@ type: flow path: f/my_folder/my_flow \`\`\` -### Calling Backend from Frontend +## Data tables — \`raw_app.yaml\` config -Import from the auto-generated \`wmill.ts\`: - -\`\`\`typescript -import { backend } from './wmill'; - -// Call a backend runnable -const user = await backend.get_user({ user_id: '123' }); -\`\`\` - -The \`wmill.ts\` file provides type-safe access to all backend runnables. - -## Data Tables - -Raw apps can query Windmill datatables (PostgreSQL databases managed by Windmill). - -### Critical Rules - -1. **ONLY USE WHITELISTED TABLES**: You can ONLY query tables listed in \`raw_app.yaml\` → \`data.tables\`. Tables not in this list are NOT accessible. - -2. **ADD TABLES BEFORE USING**: To use a new table, first add it to \`data.tables\` in \`raw_app.yaml\`. - -3. **USE CONFIGURED DATATABLE/SCHEMA**: Check the app's \`raw_app.yaml\` for the default datatable and schema. - -### Configuration in raw_app.yaml +The \`data\` block in \`raw_app.yaml\` controls which tables the app can query. \`\`\`yaml data: @@ -5637,9 +5597,153 @@ data: \`\`\` **Table reference formats:** -- \`\` - All tables in the datatable -- \`/\` - Specific table in public schema -- \`/:
\` - Table in specific schema +- \`\` — All tables in the datatable +- \`/
\` — Specific table in public schema +- \`/:
\` — Table in specific schema + +## SQL Migrations (sql_to_apply/) + +The \`sql_to_apply/\` folder is for creating/modifying database tables during development. + +### Workflow + +1. Create \`.sql\` files in \`sql_to_apply/\` +2. Run \`wmill app dev\` — the dev server watches this folder +3. When SQL files change, a modal appears in the browser to confirm execution +4. After creating tables, **add them to \`data.tables\`** in \`raw_app.yaml\` + +### Example migration + +**sql_to_apply/001_create_users.sql:** +\`\`\`sql +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT, + created_at TIMESTAMP DEFAULT NOW() +); +\`\`\` + +After applying, add to \`raw_app.yaml\`: +\`\`\`yaml +data: + tables: + - main/users +\`\`\` + +### Migration best practices + +- **Use idempotent SQL**: \`CREATE TABLE IF NOT EXISTS\`, etc. +- **Number files**: \`001_\`, \`002_\` for ordering +- **Always whitelist tables** after creation +- This folder is NOT synced — it's for local development only + +## CLI Commands + +\`wmill app new\` is the exception: you run it yourself, with flags, per the "Creating a Raw App" section above. + +For everything else, tell the user which command fits their intent and let them run it — these touch the workspace or local lock files, and the user should consent each time: + +| Command | Description | +|---------|-------------| +| \`wmill app dev\` | Start dev server with live reload (see the \`preview\` skill for the full open-the-app-in-the-IDE-pane procedure). | +| \`wmill app generate-agents\` | Refresh AGENTS.md and DATATABLES.md | +| \`wmill generate-metadata\` | Generate lock files for backend runnables | +| \`wmill sync push\` | Deploy app to Windmill | +| \`wmill sync pull\` | Pull latest from Windmill | + + + +# Windmill Raw Apps + +Raw apps let you build custom frontends with React, Svelte, or Vue that connect to Windmill backend runnables and datatables. + +## App shape + +A raw app has three logical parts: + +- **Frontend** — bundled with esbuild from \`index.tsx\` as the entrypoint. Files include the entrypoint, components (\`App.tsx\`), styles, etc. +- **Backend runnables** — server-side scripts the frontend calls, each addressed by a unique key. +- **Data** — optional whitelisted datatables (managed PostgreSQL) that the backend runnables can query. The frontend never queries the database directly; backend runnables are the only bridge. + +## Frontend + +### Entrypoint + +\`index.tsx\` is the bundling entrypoint. It typically renders a top-level \`App\` component. The bundler is esbuild. + +### Generated bindings (\`wmill.d.ts\` / \`wmill.ts\`) + +The frontend imports a generated module that mirrors the backend runnables. **Never write to it directly** — it gets regenerated whenever backend runnables change. Modifying it by hand will be overwritten. + +### Calling backend runnables + +Import the generated bindings and call the runnable like a function: + +\`\`\`typescript +import { backend } from './wmill'; + +// Call a backend runnable +const user = await backend.get_user({ user_id: '123' }); +\`\`\` + +The frontend cannot reach datatables, workspace items, or external services on its own — it goes through \`backend.(args)\` for everything server-side. + +## Backend runnables + +Each runnable has a unique key (used to call it from the frontend) and one of four types: + +| Type | What it is | +|---|---| +| \`inline\` | Custom code stored on the app itself. Most common for app-specific logic. | +| \`script\` | Reference to an existing workspace script by path. | +| \`flow\` | Reference to an existing workspace flow by path. | +| \`hubscript\` | Reference to a hub script by path. | + +### Inline runnables + +Inline runnables carry their own source code. For file-based raw apps, the runnable language is determined by the backend file extension. The script must expose a \`main\` function as its entrypoint. + +**TypeScript example** (\`backend/get_user.ts\`): + +\`\`\`typescript +import * as wmill from 'windmill-client'; + +export async function main(user_id: string) { + const sql = wmill.datatable(); + const user = await sql\`SELECT * FROM users WHERE id = \${user_id}\`.fetchOne(); + return user; +} +\`\`\` + +**Python example** (\`backend/get_user.py\`): + +\`\`\`python +import wmill + +def main(user_id: str): + db = wmill.datatable() + user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one() + return user +\`\`\` + +### Path runnables (script / flow / hubscript) + +When \`type\` is \`script\`, \`flow\`, or \`hubscript\`, the runnable just stores a \`path\` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface. + +### Static inputs + +\`staticInputs\` is an optional \`Record\` for arguments not overridable from the frontend. Useful with path runnables to pre-fill some args while leaving the rest to the frontend caller. + +## Data Tables + +Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the \`wmill\` client; the frontend never queries them directly. + +### Critical rules + +1. **Whitelisted tables only**: a runnable can only query tables listed in the app's \`data.tables\` config. Tables not in this list are not accessible. +2. **Add tables before using**: queries against unlisted tables fail at runtime. When you introduce a new table, register it in \`data.tables\` first. +3. **Use the configured datatable/schema**: the app's \`data\` config sets the default datatable and schema; reference them consistently across runnables. ### Querying in TypeScript (Bun/Deno) @@ -5680,65 +5784,13 @@ def main(user_id: str): return user \`\`\` -## SQL Migrations (sql_to_apply/) - -The \`sql_to_apply/\` folder is for creating/modifying database tables during development. - -### Workflow - -1. Create \`.sql\` files in \`sql_to_apply/\` -2. Run \`wmill app dev\` - the dev server watches this folder -3. When SQL files change, a modal appears in the browser to confirm execution -4. After creating tables, **add them to \`data.tables\`** in \`raw_app.yaml\` - -### Example Migration - -**sql_to_apply/001_create_users.sql:** -\`\`\`sql -CREATE TABLE IF NOT EXISTS users ( - id SERIAL PRIMARY KEY, - email TEXT NOT NULL UNIQUE, - name TEXT, - created_at TIMESTAMP DEFAULT NOW() -); -\`\`\` - -After applying, add to \`raw_app.yaml\`: -\`\`\`yaml -data: - tables: - - main/users -\`\`\` - -### Migration Best Practices - -- **Use idempotent SQL**: \`CREATE TABLE IF NOT EXISTS\`, etc. -- **Number files**: \`001_\`, \`002_\` for ordering -- **Always whitelist tables** after creation -- This folder is NOT synced - it's for local development only - -## CLI Commands - -\`wmill app new\` is the exception: you run it yourself, with flags, per the "Creating a Raw App" section above. - -For everything else, tell the user which command fits their intent and let them run it — these touch the workspace or local lock files, and the user should consent each time: - -| Command | Description | -|---------|-------------| -| \`wmill app dev\` | Start dev server with live reload (see the \`preview\` skill for the full open-the-app-in-the-IDE-pane procedure). | -| \`wmill app generate-agents\` | Refresh AGENTS.md and DATATABLES.md | -| \`wmill generate-metadata\` | Generate lock files for backend runnables | -| \`wmill sync push\` | Deploy app to Windmill | -| \`wmill sync pull\` | Pull latest from Windmill | - ## Best Practices -1. **Check DATATABLES.md** for existing tables before creating new ones -2. **Use parameterized queries** - never concatenate user input into SQL -3. **Keep runnables focused** - one function per file -4. **Use descriptive IDs** - \`get_user.ts\` not \`a.ts\` -5. **Always whitelist tables** - add to \`data.tables\` before querying -6. **Generate locks** - tell the user to run \`wmill generate-metadata\` after adding/modifying backend runnables +1. **Check existing tables** before creating new ones — reuse beats schema growth. +2. **Use parameterized queries** — never concatenate user input into SQL. +3. **Keep runnables focused** — one function per runnable; small surface area. +4. **Use descriptive keys** — \`get_user\`, not \`a\`. +5. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in \`data.tables\` first. `, "triggers": `--- name: triggers diff --git a/frontend/src/lib/components/copilot/CustomAIPrompts.svelte b/frontend/src/lib/components/copilot/CustomAIPrompts.svelte index eb4685a4d1..4c7cfbe47b 100644 --- a/frontend/src/lib/components/copilot/CustomAIPrompts.svelte +++ b/frontend/src/lib/components/copilot/CustomAIPrompts.svelte @@ -1,5 +1,5 @@
@@ -29,7 +30,7 @@
` - Specific table in public schema -- `/:
` - Table in specific schema +- `` — All tables in the datatable +- `/
` — Specific table in public schema +- `/:
` — Table in specific schema + +## SQL Migrations (sql_to_apply/) + +The `sql_to_apply/` folder is for creating/modifying database tables during development. + +### Workflow + +1. Create `.sql` files in `sql_to_apply/` +2. Run `wmill app dev` — the dev server watches this folder +3. When SQL files change, a modal appears in the browser to confirm execution +4. After creating tables, **add them to `data.tables`** in `raw_app.yaml` + +### Example migration + +**sql_to_apply/001_create_users.sql:** +```sql +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +After applying, add to `raw_app.yaml`: +```yaml +data: + tables: + - main/users +``` + +### Migration best practices + +- **Use idempotent SQL**: `CREATE TABLE IF NOT EXISTS`, etc. +- **Number files**: `001_`, `002_` for ordering +- **Always whitelist tables** after creation +- This folder is NOT synced — it's for local development only + +## CLI Commands + +`wmill app new` is the exception: you run it yourself, with flags, per the "Creating a Raw App" section above. + +For everything else, tell the user which command fits their intent and let them run it — these touch the workspace or local lock files, and the user should consent each time: + +| Command | Description | +|---------|-------------| +| `wmill app dev` | Start dev server with live reload (see the `preview` skill for the full open-the-app-in-the-IDE-pane procedure). | +| `wmill app generate-agents` | Refresh AGENTS.md and DATATABLES.md | +| `wmill generate-metadata` | Generate lock files for backend runnables | +| `wmill sync push` | Deploy app to Windmill | +| `wmill sync pull` | Pull latest from Windmill | + + + +# Windmill Raw Apps + +Raw apps let you build custom frontends with React, Svelte, or Vue that connect to Windmill backend runnables and datatables. + +## App shape + +A raw app has three logical parts: + +- **Frontend** — bundled with esbuild from `index.tsx` as the entrypoint. Files include the entrypoint, components (`App.tsx`), styles, etc. +- **Backend runnables** — server-side scripts the frontend calls, each addressed by a unique key. +- **Data** — optional whitelisted datatables (managed PostgreSQL) that the backend runnables can query. The frontend never queries the database directly; backend runnables are the only bridge. + +## Frontend + +### Entrypoint + +`index.tsx` is the bundling entrypoint. It typically renders a top-level `App` component. The bundler is esbuild. + +### Generated bindings (`wmill.d.ts` / `wmill.ts`) + +The frontend imports a generated module that mirrors the backend runnables. **Never write to it directly** — it gets regenerated whenever backend runnables change. Modifying it by hand will be overwritten. + +### Calling backend runnables + +Import the generated bindings and call the runnable like a function: + +```typescript +import { backend } from './wmill'; + +// Call a backend runnable +const user = await backend.get_user({ user_id: '123' }); +``` + +The frontend cannot reach datatables, workspace items, or external services on its own — it goes through `backend.(args)` for everything server-side. + +## Backend runnables + +Each runnable has a unique key (used to call it from the frontend) and one of four types: + +| Type | What it is | +|---|---| +| `inline` | Custom code stored on the app itself. Most common for app-specific logic. | +| `script` | Reference to an existing workspace script by path. | +| `flow` | Reference to an existing workspace flow by path. | +| `hubscript` | Reference to a hub script by path. | + +### Inline runnables + +Inline runnables carry their own source code. For file-based raw apps, the runnable language is determined by the backend file extension. The script must expose a `main` function as its entrypoint. + +**TypeScript example** (`backend/get_user.ts`): + +```typescript +import * as wmill from 'windmill-client'; + +export async function main(user_id: string) { + const sql = wmill.datatable(); + const user = await sql`SELECT * FROM users WHERE id = ${user_id}`.fetchOne(); + return user; +} +``` + +**Python example** (`backend/get_user.py`): + +```python +import wmill + +def main(user_id: str): + db = wmill.datatable() + user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one() + return user +``` + +### Path runnables (script / flow / hubscript) + +When `type` is `script`, `flow`, or `hubscript`, the runnable just stores a `path` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface. + +### Static inputs + +`staticInputs` is an optional `Record` for arguments not overridable from the frontend. Useful with path runnables to pre-fill some args while leaving the rest to the frontend caller. + +## Data Tables + +Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the `wmill` client; the frontend never queries them directly. + +### Critical rules + +1. **Whitelisted tables only**: a runnable can only query tables listed in the app's `data.tables` config. Tables not in this list are not accessible. +2. **Add tables before using**: queries against unlisted tables fail at runtime. When you introduce a new table, register it in `data.tables` first. +3. **Use the configured datatable/schema**: the app's `data` config sets the default datatable and schema; reference them consistently across runnables. ### Querying in TypeScript (Bun/Deno) @@ -257,62 +361,10 @@ def main(user_id: str): return user ``` -## SQL Migrations (sql_to_apply/) - -The `sql_to_apply/` folder is for creating/modifying database tables during development. - -### Workflow - -1. Create `.sql` files in `sql_to_apply/` -2. Run `wmill app dev` - the dev server watches this folder -3. When SQL files change, a modal appears in the browser to confirm execution -4. After creating tables, **add them to `data.tables`** in `raw_app.yaml` - -### Example Migration - -**sql_to_apply/001_create_users.sql:** -```sql -CREATE TABLE IF NOT EXISTS users ( - id SERIAL PRIMARY KEY, - email TEXT NOT NULL UNIQUE, - name TEXT, - created_at TIMESTAMP DEFAULT NOW() -); -``` - -After applying, add to `raw_app.yaml`: -```yaml -data: - tables: - - main/users -``` - -### Migration Best Practices - -- **Use idempotent SQL**: `CREATE TABLE IF NOT EXISTS`, etc. -- **Number files**: `001_`, `002_` for ordering -- **Always whitelist tables** after creation -- This folder is NOT synced - it's for local development only - -## CLI Commands - -`wmill app new` is the exception: you run it yourself, with flags, per the "Creating a Raw App" section above. - -For everything else, tell the user which command fits their intent and let them run it — these touch the workspace or local lock files, and the user should consent each time: - -| Command | Description | -|---------|-------------| -| `wmill app dev` | Start dev server with live reload (see the `preview` skill for the full open-the-app-in-the-IDE-pane procedure). | -| `wmill app generate-agents` | Refresh AGENTS.md and DATATABLES.md | -| `wmill generate-metadata` | Generate lock files for backend runnables | -| `wmill sync push` | Deploy app to Windmill | -| `wmill sync pull` | Pull latest from Windmill | - ## Best Practices -1. **Check DATATABLES.md** for existing tables before creating new ones -2. **Use parameterized queries** - never concatenate user input into SQL -3. **Keep runnables focused** - one function per file -4. **Use descriptive IDs** - `get_user.ts` not `a.ts` -5. **Always whitelist tables** - add to `data.tables` before querying -6. **Generate locks** - tell the user to run `wmill generate-metadata` after adding/modifying backend runnables +1. **Check existing tables** before creating new ones — reuse beats schema growth. +2. **Use parameterized queries** — never concatenate user input into SQL. +3. **Keep runnables focused** — one function per runnable; small surface area. +4. **Use descriptive keys** — `get_user`, not `a`. +5. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in `data.tables` first. diff --git a/system_prompts/base/raw-app-cli.md b/system_prompts/base/raw-app-cli.md new file mode 100644 index 0000000000..e7e8fe9881 --- /dev/null +++ b/system_prompts/base/raw-app-cli.md @@ -0,0 +1,226 @@ +# Windmill Raw Apps — CLI workflow + +This guide covers raw apps from the terminal: scaffolding via `wmill app new`, the on-disk layout, and the file-based conventions the CLI uses to represent backend runnables and data table configuration. The platform shape (how a raw app behaves at runtime — frontend bundling, runnable types, datatable SDK calls) is covered in the companion authoring guide. + +## Creating a Raw App + +**You — the AI agent — create the app yourself by running `wmill app new` with the right flags. Do NOT tell the user to "run `wmill app new` and follow the prompts" or wait for them to do it.** The bare `wmill app new` is an interactive wizard that hangs waiting for stdin in any non-TTY context (which includes you). Always pass flags. + +### Step 1 — Gather the three required values by asking the user + +You need three things to run the command: + +1. **summary** — a short description of the app +2. **path** — the windmill path, e.g. `f/folder/my_app` or `u/username/my_app` +3. **framework** — one of `react19` (recommended), `react18`, `svelte5`, `vue` + +If the user's request did not supply *every* one of these explicitly, ask. Do not guess values, do not invent paths, do not pick a framework on the user's behalf, do not "just use react19 because it's the default". + +Use whichever interactive question facility your runtime provides — a structured multi-choice tool if available, otherwise plain chat — and group all missing fields into a single round-trip so the user answers them at once: + +- For `framework` — multiple-choice with the four allowed values; mark `react19` as `(Recommended)` and put it first. +- For `summary` and `path` — provide one or two example values as multiple-choice options (the user can pick "Other" to type a free-form answer). + +Only proceed once you have concrete values for all three. If the user replies with something ambiguous, ask again rather than guessing. + +### Step 2 — Run the command yourself + +Once you have summary + path + framework, run it: + +```bash +wmill app new \ + --summary "Customer dashboard" \ + --path f/sales/dashboard \ + --framework react19 +``` + +That's the minimum. The datatable wizard and the "Open in Claude Desktop?" prompt are skipped silently because passing any of `--summary`/`--path`/`--framework` puts the command in non-interactive mode. + +### Optional flags + +Layer these in only when the user asked for them: + +| Flag | When to add it | +|---|---| +| `--datatable ` | The user wants this app wired to a specific Windmill datatable. Without it, the app is created with no datatable. | +| `--schema ` | Together with `--datatable`. Creates the schema with `CREATE SCHEMA IF NOT EXISTS` if it doesn't already exist. | +| `--overwrite` | The target directory already exists and the user said it's OK to replace. Without it, non-interactive mode aborts with an error so you don't clobber existing work. | +| `--no-open-in-desktop` | Already implied in non-interactive mode; only needed if you're somehow running interactively. | + +### Step 3 — Offer the visual preview + +After `wmill app new` and any initial edits to `App.tsx` / `index.tsx`, **offer** to open the visual preview as a one-sentence next step (e.g. "Want me to open the visual preview?"). Don't auto-open — opening the dev page has side effects (browser window, possibly a `launch.json` entry when an embedded preview tool is in play) the user should consent to. + +For apps the preview command runs from the app folder (`cd __raw_app && wmill app dev …`); the `preview` skill picks the proxy vs direct branch based on whether the runtime exposes a tool that can embed a localhost URL. If the user already asked to see/preview/visualize the app in their original request, skip the offer and just invoke the skill. + +### Anti-patterns to avoid + +- ❌ Running `wmill app new` with no flags (the prompt will hang). +- ❌ Telling the user to "run `wmill app new` and follow the prompts" — that's a step backwards from what you can do directly. +- ❌ Inventing a path/summary/framework instead of asking the user. +- ❌ Defaulting to `react19` because the user didn't say — even sensible defaults must be confirmed. +- ❌ Passing `--overwrite` automatically when the directory exists — confirm with the user first. + +### Interactive (only when a human is at the terminal) + +```bash +wmill app new +``` + +This is the wizard. It only works when run by a human in a real terminal. Don't call it this way from an agent. + +## On-disk app layout + +``` +my_app__raw_app/ +├── AGENTS.md # AI agent instructions (auto-generated) +├── DATATABLES.md # Database schemas (run 'wmill app generate-agents' to refresh) +├── raw_app.yaml # App configuration (summary, path, data settings) +├── index.tsx # Frontend entry point +├── App.tsx # Main React/Svelte/Vue component +├── index.css # Styles +├── package.json # Frontend dependencies +├── wmill.ts # Auto-generated backend type definitions (DO NOT EDIT) +├── backend/ # Backend runnables (server-side scripts) +│ ├── . # Code file (e.g., get_user.ts) +│ ├── .yaml # Optional: config for fields, or to reference existing scripts +│ └── .lock # Lock file (run 'wmill generate-metadata' to create/update) +└── sql_to_apply/ # SQL migrations (dev only, not synced) + └── *.sql # SQL files to apply via dev server +``` + +## Backend runnables on disk + +Add a code file to the `backend/` folder: + +``` +backend/. +``` + +The runnable ID is the filename without extension. For example, `get_user.ts` creates a runnable with ID `get_user`. + +### Supported languages (extension-driven) + +| Language | Extension | Example | +|------------------|--------------|------------------| +| TypeScript | `.ts` | `myFunc.ts` | +| TypeScript (Bun) | `.bun.ts` | `myFunc.bun.ts` | +| TypeScript (Deno)| `.deno.ts` | `myFunc.deno.ts` | +| Python | `.py` | `myFunc.py` | +| Go | `.go` | `myFunc.go` | +| Bash | `.sh` | `myFunc.sh` | +| PowerShell | `.ps1` | `myFunc.ps1` | +| PostgreSQL | `.pg.sql` | `myFunc.pg.sql` | +| MySQL | `.my.sql` | `myFunc.my.sql` | +| BigQuery | `.bq.sql` | `myFunc.bq.sql` | +| Snowflake | `.sf.sql` | `myFunc.sf.sql` | +| MS SQL | `.ms.sql` | `myFunc.ms.sql` | +| GraphQL | `.gql` | `myFunc.gql` | +| PHP | `.php` | `myFunc.php` | +| Rust | `.rs` | `myFunc.rs` | +| C# | `.cs` | `myFunc.cs` | +| Java | `.java` | `myFunc.java` | + +After creating a runnable, tell the user they can generate lock files by running: +```bash +wmill generate-metadata +``` + +### Optional YAML configuration + +Add a `.yaml` file alongside the code to configure fields or static values: + +**backend/get_user.yaml:** +```yaml +type: inline +fields: + user_id: + type: static + value: "default_user" +``` + +### Referencing existing scripts + +To use an existing Windmill script instead of inline code: + +**backend/existing_script.yaml:** +```yaml +type: script +path: f/my_folder/existing_script +``` + +For flows: +```yaml +type: flow +path: f/my_folder/my_flow +``` + +## Data tables — `raw_app.yaml` config + +The `data` block in `raw_app.yaml` controls which tables the app can query. + +```yaml +data: + datatable: main # Default datatable + schema: app_schema # Default schema (optional) + tables: + - main/users # Table in public schema + - main/app_schema:items # Table in specific schema +``` + +**Table reference formats:** +- `` — All tables in the datatable +- `/
` — Specific table in public schema +- `/:
` — Table in specific schema + +## SQL Migrations (sql_to_apply/) + +The `sql_to_apply/` folder is for creating/modifying database tables during development. + +### Workflow + +1. Create `.sql` files in `sql_to_apply/` +2. Run `wmill app dev` — the dev server watches this folder +3. When SQL files change, a modal appears in the browser to confirm execution +4. After creating tables, **add them to `data.tables`** in `raw_app.yaml` + +### Example migration + +**sql_to_apply/001_create_users.sql:** +```sql +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +After applying, add to `raw_app.yaml`: +```yaml +data: + tables: + - main/users +``` + +### Migration best practices + +- **Use idempotent SQL**: `CREATE TABLE IF NOT EXISTS`, etc. +- **Number files**: `001_`, `002_` for ordering +- **Always whitelist tables** after creation +- This folder is NOT synced — it's for local development only + +## CLI Commands + +`wmill app new` is the exception: you run it yourself, with flags, per the "Creating a Raw App" section above. + +For everything else, tell the user which command fits their intent and let them run it — these touch the workspace or local lock files, and the user should consent each time: + +| Command | Description | +|---------|-------------| +| `wmill app dev` | Start dev server with live reload (see the `preview` skill for the full open-the-app-in-the-IDE-pane procedure). | +| `wmill app generate-agents` | Refresh AGENTS.md and DATATABLES.md | +| `wmill generate-metadata` | Generate lock files for backend runnables | +| `wmill sync push` | Deploy app to Windmill | +| `wmill sync pull` | Pull latest from Windmill | + diff --git a/system_prompts/base/raw-app.md b/system_prompts/base/raw-app.md index 4114c55a60..04103e434b 100644 --- a/system_prompts/base/raw-app.md +++ b/system_prompts/base/raw-app.md @@ -2,132 +2,54 @@ Raw apps let you build custom frontends with React, Svelte, or Vue that connect to Windmill backend runnables and datatables. -## Creating a Raw App +## App shape -**You — the AI agent — create the app yourself by running `wmill app new` with the right flags. Do NOT tell the user to "run `wmill app new` and follow the prompts" or wait for them to do it.** The bare `wmill app new` is an interactive wizard that hangs waiting for stdin in any non-TTY context (which includes you). Always pass flags. +A raw app has three logical parts: -### Step 1 — Gather the three required values by asking the user +- **Frontend** — bundled with esbuild from `index.tsx` as the entrypoint. Files include the entrypoint, components (`App.tsx`), styles, etc. +- **Backend runnables** — server-side scripts the frontend calls, each addressed by a unique key. +- **Data** — optional whitelisted datatables (managed PostgreSQL) that the backend runnables can query. The frontend never queries the database directly; backend runnables are the only bridge. -You need three things to run the command: +## Frontend -1. **summary** — a short description of the app -2. **path** — the windmill path, e.g. `f/folder/my_app` or `u/username/my_app` -3. **framework** — one of `react19` (recommended), `react18`, `svelte5`, `vue` +### Entrypoint -If the user's request did not supply *every* one of these explicitly, ask. Do not guess values, do not invent paths, do not pick a framework on the user's behalf, do not "just use react19 because it's the default". +`index.tsx` is the bundling entrypoint. It typically renders a top-level `App` component. The bundler is esbuild. -Use whichever interactive question facility your runtime provides — a structured multi-choice tool if available, otherwise plain chat — and group all missing fields into a single round-trip so the user answers them at once: +### Generated bindings (`wmill.d.ts` / `wmill.ts`) -- For `framework` — multiple-choice with the four allowed values; mark `react19` as `(Recommended)` and put it first. -- For `summary` and `path` — provide one or two example values as multiple-choice options (the user can pick "Other" to type a free-form answer). +The frontend imports a generated module that mirrors the backend runnables. **Never write to it directly** — it gets regenerated whenever backend runnables change. Modifying it by hand will be overwritten. -Only proceed once you have concrete values for all three. If the user replies with something ambiguous, ask again rather than guessing. +### Calling backend runnables -### Step 2 — Run the command yourself +Import the generated bindings and call the runnable like a function: -Once you have summary + path + framework, run it: +```typescript +import { backend } from './wmill'; -```bash -wmill app new \ - --summary "Customer dashboard" \ - --path f/sales/dashboard \ - --framework react19 +// Call a backend runnable +const user = await backend.get_user({ user_id: '123' }); ``` -That's the minimum. The datatable wizard and the "Open in Claude Desktop?" prompt are skipped silently because passing any of `--summary`/`--path`/`--framework` puts the command in non-interactive mode. +The frontend cannot reach datatables, workspace items, or external services on its own — it goes through `backend.(args)` for everything server-side. -### Optional flags +## Backend runnables -Layer these in only when the user asked for them: +Each runnable has a unique key (used to call it from the frontend) and one of four types: -| Flag | When to add it | +| Type | What it is | |---|---| -| `--datatable ` | The user wants this app wired to a specific Windmill datatable. Without it, the app is created with no datatable. | -| `--schema ` | Together with `--datatable`. Creates the schema with `CREATE SCHEMA IF NOT EXISTS` if it doesn't already exist. | -| `--overwrite` | The target directory already exists and the user said it's OK to replace. Without it, non-interactive mode aborts with an error so you don't clobber existing work. | -| `--no-open-in-desktop` | Already implied in non-interactive mode; only needed if you're somehow running interactively. | +| `inline` | Custom code stored on the app itself. Most common for app-specific logic. | +| `script` | Reference to an existing workspace script by path. | +| `flow` | Reference to an existing workspace flow by path. | +| `hubscript` | Reference to a hub script by path. | -### Step 3 — Offer the visual preview +### Inline runnables -After `wmill app new` and any initial edits to `App.tsx` / `index.tsx`, **offer** to open the visual preview as a one-sentence next step (e.g. "Want me to open the visual preview?"). Don't auto-open — opening the dev page has side effects (browser window, possibly a `launch.json` entry when an embedded preview tool is in play) the user should consent to. +Inline runnables carry their own source code. For file-based raw apps, the runnable language is determined by the backend file extension. The script must expose a `main` function as its entrypoint. -For apps the preview command runs from the app folder (`cd __raw_app && wmill app dev …`); the `preview` skill picks the proxy vs direct branch based on whether the runtime exposes a tool that can embed a localhost URL. If the user already asked to see/preview/visualize the app in their original request, skip the offer and just invoke the skill. +**TypeScript example** (`backend/get_user.ts`): -### Anti-patterns to avoid - -- ❌ Running `wmill app new` with no flags (the prompt will hang). -- ❌ Telling the user to "run `wmill app new` and follow the prompts" — that's a step backwards from what you can do directly. -- ❌ Inventing a path/summary/framework instead of asking the user. -- ❌ Defaulting to `react19` because the user didn't say — even sensible defaults must be confirmed. -- ❌ Passing `--overwrite` automatically when the directory exists — confirm with the user first. - -### Interactive (only when a human is at the terminal) - -```bash -wmill app new -``` - -This is the wizard. It only works when run by a human in a real terminal. Don't call it this way from an agent. - -## App Structure - -``` -my_app__raw_app/ -├── AGENTS.md # AI agent instructions (auto-generated) -├── DATATABLES.md # Database schemas (run 'wmill app generate-agents' to refresh) -├── raw_app.yaml # App configuration (summary, path, data settings) -├── index.tsx # Frontend entry point -├── App.tsx # Main React/Svelte/Vue component -├── index.css # Styles -├── package.json # Frontend dependencies -├── wmill.ts # Auto-generated backend type definitions (DO NOT EDIT) -├── backend/ # Backend runnables (server-side scripts) -│ ├── . # Code file (e.g., get_user.ts) -│ ├── .yaml # Optional: config for fields, or to reference existing scripts -│ └── .lock # Lock file (run 'wmill generate-metadata' to create/update) -└── sql_to_apply/ # SQL migrations (dev only, not synced) - └── *.sql # SQL files to apply via dev server -``` - -## Backend Runnables - -Backend runnables are server-side scripts that your frontend can call. They live in the `backend/` folder. - -### Creating a Backend Runnable - -Add a code file to the `backend/` folder: - -``` -backend/. -``` - -The runnable ID is the filename without extension. For example, `get_user.ts` creates a runnable with ID `get_user`. - -### Supported Languages - -| Language | Extension | Example | -|------------------|--------------|------------------| -| TypeScript | `.ts` | `myFunc.ts` | -| TypeScript (Bun) | `.bun.ts` | `myFunc.bun.ts` | -| TypeScript (Deno)| `.deno.ts` | `myFunc.deno.ts` | -| Python | `.py` | `myFunc.py` | -| Go | `.go` | `myFunc.go` | -| Bash | `.sh` | `myFunc.sh` | -| PowerShell | `.ps1` | `myFunc.ps1` | -| PostgreSQL | `.pg.sql` | `myFunc.pg.sql` | -| MySQL | `.my.sql` | `myFunc.my.sql` | -| BigQuery | `.bq.sql` | `myFunc.bq.sql` | -| Snowflake | `.sf.sql` | `myFunc.sf.sql` | -| MS SQL | `.ms.sql` | `myFunc.ms.sql` | -| GraphQL | `.gql` | `myFunc.gql` | -| PHP | `.php` | `myFunc.php` | -| Rust | `.rs` | `myFunc.rs` | -| C# | `.cs` | `myFunc.cs` | -| Java | `.java` | `myFunc.java` | - -### Example Backend Runnable - -**backend/get_user.ts:** ```typescript import * as wmill from 'windmill-client'; @@ -138,80 +60,34 @@ export async function main(user_id: string) { } ``` -After creating, tell the user they can generate lock files by running: -```bash -wmill generate-metadata +**Python example** (`backend/get_user.py`): + +```python +import wmill + +def main(user_id: str): + db = wmill.datatable() + user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one() + return user ``` -### Optional YAML Configuration +### Path runnables (script / flow / hubscript) -Add a `.yaml` file to configure fields or static values: +When `type` is `script`, `flow`, or `hubscript`, the runnable just stores a `path` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface. -**backend/get_user.yaml:** -```yaml -type: inline -fields: - user_id: - type: static - value: "default_user" -``` +### Static inputs -### Referencing Existing Scripts - -To use an existing Windmill script instead of inline code: - -**backend/existing_script.yaml:** -```yaml -type: script -path: f/my_folder/existing_script -``` - -For flows: -```yaml -type: flow -path: f/my_folder/my_flow -``` - -### Calling Backend from Frontend - -Import from the auto-generated `wmill.ts`: - -```typescript -import { backend } from './wmill'; - -// Call a backend runnable -const user = await backend.get_user({ user_id: '123' }); -``` - -The `wmill.ts` file provides type-safe access to all backend runnables. +`staticInputs` is an optional `Record` for arguments not overridable from the frontend. Useful with path runnables to pre-fill some args while leaving the rest to the frontend caller. ## Data Tables -Raw apps can query Windmill datatables (PostgreSQL databases managed by Windmill). +Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the `wmill` client; the frontend never queries them directly. -### Critical Rules +### Critical rules -1. **ONLY USE WHITELISTED TABLES**: You can ONLY query tables listed in `raw_app.yaml` → `data.tables`. Tables not in this list are NOT accessible. - -2. **ADD TABLES BEFORE USING**: To use a new table, first add it to `data.tables` in `raw_app.yaml`. - -3. **USE CONFIGURED DATATABLE/SCHEMA**: Check the app's `raw_app.yaml` for the default datatable and schema. - -### Configuration in raw_app.yaml - -```yaml -data: - datatable: main # Default datatable - schema: app_schema # Default schema (optional) - tables: - - main/users # Table in public schema - - main/app_schema:items # Table in specific schema -``` - -**Table reference formats:** -- `` - All tables in the datatable -- `/
` - Specific table in public schema -- `/:
` - Table in specific schema +1. **Whitelisted tables only**: a runnable can only query tables listed in the app's `data.tables` config. Tables not in this list are not accessible. +2. **Add tables before using**: queries against unlisted tables fail at runtime. When you introduce a new table, register it in `data.tables` first. +3. **Use the configured datatable/schema**: the app's `data` config sets the default datatable and schema; reference them consistently across runnables. ### Querying in TypeScript (Bun/Deno) @@ -252,62 +128,10 @@ def main(user_id: str): return user ``` -## SQL Migrations (sql_to_apply/) - -The `sql_to_apply/` folder is for creating/modifying database tables during development. - -### Workflow - -1. Create `.sql` files in `sql_to_apply/` -2. Run `wmill app dev` - the dev server watches this folder -3. When SQL files change, a modal appears in the browser to confirm execution -4. After creating tables, **add them to `data.tables`** in `raw_app.yaml` - -### Example Migration - -**sql_to_apply/001_create_users.sql:** -```sql -CREATE TABLE IF NOT EXISTS users ( - id SERIAL PRIMARY KEY, - email TEXT NOT NULL UNIQUE, - name TEXT, - created_at TIMESTAMP DEFAULT NOW() -); -``` - -After applying, add to `raw_app.yaml`: -```yaml -data: - tables: - - main/users -``` - -### Migration Best Practices - -- **Use idempotent SQL**: `CREATE TABLE IF NOT EXISTS`, etc. -- **Number files**: `001_`, `002_` for ordering -- **Always whitelist tables** after creation -- This folder is NOT synced - it's for local development only - -## CLI Commands - -`wmill app new` is the exception: you run it yourself, with flags, per the "Creating a Raw App" section above. - -For everything else, tell the user which command fits their intent and let them run it — these touch the workspace or local lock files, and the user should consent each time: - -| Command | Description | -|---------|-------------| -| `wmill app dev` | Start dev server with live reload (see the `preview` skill for the full open-the-app-in-the-IDE-pane procedure). | -| `wmill app generate-agents` | Refresh AGENTS.md and DATATABLES.md | -| `wmill generate-metadata` | Generate lock files for backend runnables | -| `wmill sync push` | Deploy app to Windmill | -| `wmill sync pull` | Pull latest from Windmill | - ## Best Practices -1. **Check DATATABLES.md** for existing tables before creating new ones -2. **Use parameterized queries** - never concatenate user input into SQL -3. **Keep runnables focused** - one function per file -4. **Use descriptive IDs** - `get_user.ts` not `a.ts` -5. **Always whitelist tables** - add to `data.tables` before querying -6. **Generate locks** - tell the user to run `wmill generate-metadata` after adding/modifying backend runnables +1. **Check existing tables** before creating new ones — reuse beats schema growth. +2. **Use parameterized queries** — never concatenate user input into SQL. +3. **Keep runnables focused** — one function per runnable; small surface area. +4. **Use descriptive keys** — `get_user`, not `a`. +5. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in `data.tables` first. diff --git a/system_prompts/generate.py b/system_prompts/generate.py index f291995b88..87f0b63576 100644 --- a/system_prompts/generate.py +++ b/system_prompts/generate.py @@ -680,6 +680,8 @@ WORKSPACE_TOOL_ZOD_SCHEMAS = [ ('NewSqsTrigger', 'sqsTriggerRequestSchema'), ('GcpTriggerData', 'gcpTriggerRequestSchema'), ('AzureTriggerData', 'azureTriggerRequestSchema'), + ('CreateVariable', 'variableRequestSchema'), + ('CreateResource', 'resourceRequestSchema'), ] WORKSPACE_TOOL_TRIGGER_SCHEMAS = [ @@ -1486,11 +1488,17 @@ def generate_skills( # Ensure skills directory exists OUTPUT_SKILLS_DIR.mkdir(parents=True, exist_ok=True) - # Read base files for additional skills + # Read base files for additional skills. + # Note: raw-app.md is the chat-relevant authoring guide. The CLI workflow + # (wmill app new wizard, on-disk layout, sql_to_apply/, CLI commands) lives + # in raw-app-cli.md. Concatenated here for the skill so CLI users see CLI + # guidance first, then the platform shape. base_dir = SCRIPT_DIR / "base" + raw_app_cli_md = read_markdown_file(base_dir / "raw-app-cli.md") + raw_app_authoring_md = read_markdown_file(base_dir / "raw-app.md") base_content = { 'flow': f"{flow_cli}\n\n{flow_base}\n\n{openflow_content}", - 'raw_app': read_markdown_file(base_dir / "raw-app.md"), + 'raw_app': f"{raw_app_cli_md}\n\n{raw_app_authoring_md}", 'triggers': read_markdown_file(base_dir / "triggers.md"), 'schedules': read_markdown_file(base_dir / "schedules.md"), 'resources': read_markdown_file(base_dir / "resources.md"), @@ -1856,6 +1864,8 @@ def main(): script_base = read_markdown_file(base_dir / "script-base.md") flow_base = read_markdown_file(base_dir / "flow-base.md") + resources_base = read_markdown_file(base_dir / "resources.md") + raw_app_base = read_markdown_file(base_dir / "raw-app.md") workflow_as_code_base = read_markdown_file(base_dir / "workflow-as-code.md") flow_cli = read_markdown_file(base_dir / "flow-cli.md") flow_chat_special_modules = read_markdown_file(base_dir / "flow-chat-special-modules.md") @@ -1913,6 +1923,8 @@ def main(): # Base prompts 'SCRIPT_BASE': script_base, 'FLOW_BASE': flow_base, + 'RESOURCES_BASE': resources_base, + 'RAW_APP_BASE': raw_app_base, 'WORKFLOW_AS_CODE_BASE': workflow_as_code_base, 'FLOW_CHAT_SPECIAL_MODULES': flow_chat_special_modules, @@ -2001,6 +2013,16 @@ export function getFlowPrompt(): string { ].filter(Boolean).join('\\n\\n'); } +// Helper for resource & variable authoring +export function getResourcePrompt(): string { + return prompts.RESOURCES_BASE; +} + +// Helper for raw app authoring (chat consumers) +export function getRawAppPrompt(): string { + return prompts.RAW_APP_BASE; +} + // Helper to get datatable SDK reference for app mode export function getDatatableSdkReference(): string { return [ @@ -2037,6 +2059,8 @@ export function getWorkflowAsCodePrompt(language?: string): string { index_dts_content = """export * from './prompts'; export declare function getScriptPrompt(language: string): string; export declare function getFlowPrompt(): string; +export declare function getResourcePrompt(): string; +export declare function getRawAppPrompt(): string; export declare function getDatatableSdkReference(): string; export declare function getWorkflowAsCodePrompt(language?: string): string; """ From 07a4cb687206cbafaf8244d03148397ca3e4600c Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 12 May 2026 10:42:05 +0000 Subject: [PATCH 06/40] branch download UI on shouldDownloadViaClient instead of onclick interception (#9118) * fix(frontend): branch download UI on shouldDownloadViaClient instead of intercepting in onclick When OpenAPI.TOKEN is set, several download links rendered an `` to the API and relied on an `onclick` handler to call `e.preventDefault()` and route the request through `downloadViaClient`. This is fragile in embedded contexts (e.g. the whitelabel React SDK) where Svelte's hydrated event listener may not intercept the click in time, so the browser follows the unauthenticated `href` straight to the API. Mirror the drawer pattern already used in `LogViewer` and `FlowStatusViewerInner`: render a ` + {:else} + + Download {filename ? '' : 'as JSON'} + + {/if} {#if download_as_csv} convertJsonToCsv(result)} diff --git a/frontend/src/lib/components/LogViewer.svelte b/frontend/src/lib/components/LogViewer.svelte index 9d7b71b6e3..39089dec8b 100644 --- a/frontend/src/lib/components/LogViewer.svelte +++ b/frontend/src/lib/components/LogViewer.svelte @@ -208,11 +208,6 @@ let logsApiPath = $derived(`/w/${$workspaceStore}/jobs_u/get_logs/${jobId}`) let downloadHref = $derived(withExternalDomain(`${base}/api${logsApiPath}`)) let downloadName = $derived(`windmill_logs_${jobId}.txt`) - async function onDownloadClick(e: MouseEvent) { - if (!shouldDownloadViaClient()) return - e.preventDefault() - await downloadViaClient(logsApiPath, downloadName) - } let truncatedContent = $derived(truncateContent(content, loadedFromObjectStore, LOG_LIMIT)) let prefixInfo = $derived(findPrefixInfo(truncatedContent)) let downloadStartUrl = $derived(findStartUrl(truncatedContent, prefixInfo)) @@ -357,14 +352,21 @@
{#if jobId && download}
- - + {#if shouldDownloadViaClient()} + + {:else} + + + {/if}
{/if} diff --git a/frontend/src/lib/components/ParqetCsvTableRenderer.svelte b/frontend/src/lib/components/ParqetCsvTableRenderer.svelte index 91cc107791..fd08eb64be 100644 --- a/frontend/src/lib/components/ParqetCsvTableRenderer.svelte +++ b/frontend/src/lib/components/ParqetCsvTableRenderer.svelte @@ -203,16 +203,24 @@ {#if !disable_download && !s3resource.endsWith('.csv')} {@const csvApiPath = `/w/${workspaceId}/job_helpers/download_s3_parquet_file_as_csv?file_key=${encodeURIComponent(s3resource)}${storage ? `&storage=${storage}` : ''}`} {@const csvName = (s3resource.split('/').pop() ?? 'download') + '.csv'} - { - if (!shouldDownloadViaClient()) return - e.preventDefault() - await downloadViaClient(csvApiPath, csvName) - }}>
CSV
+ {#if shouldDownloadViaClient()} + + {:else} +
CSV
+ {/if} {/if} {#if nbRows != undefined} diff --git a/frontend/src/lib/components/common/fileDownload/FileDownload.svelte b/frontend/src/lib/components/common/fileDownload/FileDownload.svelte index 0cb48eb7e1..8d6529764b 100644 --- a/frontend/src/lib/components/common/fileDownload/FileDownload.svelte +++ b/frontend/src/lib/components/common/fileDownload/FileDownload.svelte @@ -26,26 +26,26 @@ ) let href = $derived(`${base}/api${apiPath}`) - async function onclick(e: MouseEvent) { - if (!shouldDownloadViaClient()) return - e.preventDefault() - await downloadViaClient(apiPath, filename) - } + const sharedClass = `relative center-center flex w-full text-center font-normal text-primary text-sm +border border-dashed border-gray-400 hover:border-blue-500 +focus-within:border-blue-500 hover:bg-blue-50 dark:hover:bg-frost-900 focus-within:bg-blue-50 +duration-200 rounded-lg p-1 gap-2` {#if s3object && s3object?.s3} - - - - {s3object?.storage ? `s3://${s3object.storage}/${s3object.s3}` : `s3:///${s3object.s3}`} - - + {#if shouldDownloadViaClient()} + + {:else} + + + + {s3object?.storage ? `s3://${s3object.storage}/${s3object.s3}` : `s3:///${s3object.s3}`} + + + {/if} {/if} diff --git a/frontend/src/lib/components/propertyPicker/ObjectViewer.svelte b/frontend/src/lib/components/propertyPicker/ObjectViewer.svelte index d954618518..9746991d6b 100644 --- a/frontend/src/lib/components/propertyPicker/ObjectViewer.svelte +++ b/frontend/src/lib/components/propertyPicker/ObjectViewer.svelte @@ -410,18 +410,26 @@ {#if getTypeAsString(jsonFiltered) === 's3object'} {@const s3DownloadApiPath = `/w/${$workspaceStore}/job_helpers/download_s3_file?file_key=${encodeURIComponent(jsonFiltered?.s3 ?? '')}${jsonFiltered?.storage ? `&storage=${jsonFiltered.storage}` : ''}`} {@const s3DownloadName = jsonFiltered?.s3.split('/').pop() ?? 'unnamed_download.file'} - { - if (!shouldDownloadViaClient()) return - e.preventDefault() - await downloadViaClient(s3DownloadApiPath, s3DownloadName) - }} - > - download - + {#if shouldDownloadViaClient()} + + {:else} + + download + + {/if} + {:else} + + + + {/if} {/if} {#if disableTooltips !== true} diff --git a/frontend/src/lib/components/JobArgs.svelte b/frontend/src/lib/components/JobArgs.svelte index 68ca3c078f..77c94de11e 100644 --- a/frontend/src/lib/components/JobArgs.svelte +++ b/frontend/src/lib/components/JobArgs.svelte @@ -13,6 +13,7 @@ import HighlightTheme from './HighlightTheme.svelte' import { deepEqual } from 'fast-equals' import { isWindmillTooBigObject } from './job_args' + import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' interface Props { id?: string | undefined @@ -27,6 +28,10 @@ let runLocally: Drawer | undefined = $state() let jsonStr = $state('') + const argsDownloadName = 'windmill-args.json' + let argsApiPath = $derived(id && workspace ? `/w/${workspace}/jobs_u/get_args/${id}` : undefined) + let argsDataHref = $derived(`data:text/json;charset=utf-8,${encodeURIComponent(jsonStr)}`) + function pythonCode() { return ` if __name__ == "__main__": @@ -60,10 +65,12 @@ ${Object.entries(args) {#if args && typeof args === 'object' && deepEqual( Object.keys(args ?? {}), ['reason'] ) && args['reason'] == 'PREPROCESSOR_ARGS_ARE_DISCARDED'} Preprocessor args are discarded {:else if id && workspace && args && typeof args === 'object' && deepEqual( Object.keys(args ?? {}), ['reason'] ) && args['reason'] == 'WINDMILL_TOO_BIG'} - The args are too big in size to be able to fetch alongside job. Please download the JSON file to view them. + The args are too big in size to be able to fetch alongside job. Please {#if shouldDownloadViaClient()}{:else}download the JSON file to view them{/if}. {:else}
@@ -120,17 +127,26 @@ ${Object.entries(args) {#snippet actions()} - + {#if argsApiPath && shouldDownloadViaClient()} + + {:else} + + {/if} + {:else} + + JSON is too large to be displayed in full. + + {/if} +
{:else} {/if} From cab0000f3a5e9a0b201a85da1a01b1f82df8a316 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 12 May 2026 12:07:45 +0000 Subject: [PATCH 08/40] fix: preserve explicit nulls for typed fields in bulk instance config (#9123) Setting a typed instance setting (e.g. `object_store_cache_config`) to `null` via `PUT /api/settings/instance_config` was silently ignored: `Json` deserialized null to `None`, and `to_settings_map` then dropped it via `skip_serializing_if = "Option::is_none"`, so `diff_global_settings` (Merge mode) never saw the deletion. Add `#[serde(remote = "Self")]` to `GlobalSettings` plus a manual `Deserialize` impl that captures top-level explicit nulls into `extra`, which already round-trips correctly via flatten. The handler stays fully typed. Co-authored-by: Claude Opus 4.7 (1M context) --- .../windmill-common/src/instance_config.rs | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index e9a9731f74..e19182be30 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -200,7 +200,15 @@ fn opaque_json_schema(_: &mut schemars::gen::SchemaGenerator) -> schemars::schem /// Typed global settings with schema validation. /// Known settings have explicit fields; unknown settings pass through via `extra`. +/// +/// `#[serde(remote = "Self")]` turns the derived (de)serializers into inherent +/// associated functions so we can wrap them with the manual `Deserialize` impl +/// below. The wrapper preserves explicit `null` values (which the typed +/// `Option` fields would otherwise silently drop on round-trip through +/// `to_settings_map`) by stashing them in `extra`, where they survive +/// re-serialization and reach `diff_global_settings` as proper deletes. #[derive(Deserialize, Serialize, Clone, Debug, Default)] +#[serde(remote = "Self")] #[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))] pub struct GlobalSettings { // Numeric settings @@ -373,6 +381,42 @@ pub struct GlobalSettings { pub extra: BTreeMap, } +impl Serialize for GlobalSettings { + fn serialize(&self, serializer: S) -> Result { + Self::serialize(self, serializer) + } +} + +impl<'de> Deserialize<'de> for GlobalSettings { + fn deserialize>(deserializer: D) -> Result { + // Capture top-level keys explicitly set to `null` so they survive the + // `to_settings_map` round-trip — typed `Option` fields all use + // `skip_serializing_if = "Option::is_none"`, which would otherwise + // silently drop the null. We stash the null entries in `extra`, which + // serializes them back out as `null` for `diff_global_settings` to + // route to deletes. + let mut value = serde_json::Value::deserialize(deserializer)?; + let null_keys: Vec = value + .as_object() + .map(|m| { + m.iter() + .filter_map(|(k, v)| v.is_null().then(|| k.clone())) + .collect() + }) + .unwrap_or_default(); + if let Some(obj) = value.as_object_mut() { + for k in &null_keys { + obj.remove(k); + } + } + let mut s = Self::deserialize(value).map_err(serde::de::Error::custom)?; + for k in null_keys { + s.extra.insert(k, serde_json::Value::Null); + } + Ok(s) + } +} + impl GlobalSettings { /// Convert to a flat `BTreeMap` suitable for DB sync. pub fn to_settings_map(&self) -> BTreeMap { @@ -1930,6 +1974,101 @@ mod tests { // to_settings_map edge cases // ----------------------------------------------------------------------- + /// Regression test for bulk-endpoint deletion of typed settings. + /// + /// A naive `#[derive(Deserialize)]` would route + /// `{"object_store_cache_config": null}` to the typed `Option<...>` field + /// as `None`, and `skip_serializing_if = "Option::is_none"` would then + /// strip it from `to_settings_map`, so `diff_global_settings` (Merge mode) + /// would never see the deletion. The manual `Deserialize` impl on + /// `GlobalSettings` captures explicit top-level nulls into `extra` so they + /// survive the round-trip and reach `diff_global_settings` as proper + /// deletes. + #[test] + fn explicit_null_on_typed_field_survives_round_trip() { + let json = serde_json::json!({ + "object_store_cache_config": null, + "secret_backend": null, + "smtp_settings": null, + "base_url": "https://x", + }); + let settings: GlobalSettings = + serde_json::from_value(json).expect("null should deserialize"); + assert!(settings.object_store_cache_config.is_none()); + assert!(settings.secret_backend.is_none()); + assert!(settings.smtp_settings.is_none()); + assert_eq!(settings.base_url.as_deref(), Some("https://x")); + let map = settings.to_settings_map(); + assert_eq!(map["object_store_cache_config"], serde_json::Value::Null); + assert_eq!(map["secret_backend"], serde_json::Value::Null); + assert_eq!(map["smtp_settings"], serde_json::Value::Null); + assert_eq!(map["base_url"], serde_json::json!("https://x")); + } + + /// Absent keys remain absent — critical so a PUT that only sets a single + /// field doesn't accidentally delete every other setting in Merge mode. + #[test] + fn absent_typed_fields_do_not_appear_in_map() { + let settings: GlobalSettings = + serde_json::from_value(serde_json::json!({"base_url": "https://x"})).unwrap(); + let map = settings.to_settings_map(); + assert!(!map.contains_key("object_store_cache_config")); + assert!(!map.contains_key("smtp_settings")); + assert_eq!(map.get("base_url"), Some(&serde_json::json!("https://x"))); + } + + /// End-to-end: deserialize → `to_settings_map` → diff in Merge mode with + /// an explicit null produces a delete (the scenario that silently failed + /// before the manual `Deserialize` impl). + #[test] + fn deserialize_then_diff_deletes_typed_null() { + let mut current = BTreeMap::new(); + current.insert( + "object_store_cache_config".to_string(), + serde_json::json!({"type": "S3", "bucket": "b"}), + ); + let desired: GlobalSettings = + serde_json::from_value(serde_json::json!({"object_store_cache_config": null})).unwrap(); + let desired_map = desired.to_settings_map(); + let diff = diff_global_settings(¤t, &desired_map, ApplyMode::Merge); + assert!(diff.upserts.is_empty()); + assert_eq!(diff.deletes, vec!["object_store_cache_config".to_string()]); + } + + /// Nested nulls inside a typed sub-struct are not promoted to top-level + /// deletes — only the top-level key matters, mirroring the per-key API. + #[test] + fn nested_null_inside_typed_field_is_not_treated_as_top_level_null() { + let settings: GlobalSettings = + serde_json::from_value(serde_json::json!({"smtp_settings": {"smtp_host": null}})) + .unwrap(); + let map = settings.to_settings_map(); + assert!( + map["smtp_settings"].is_object(), + "smtp_settings should be an object, not null" + ); + } + + /// Unknown/extra keys with explicit null still flow through `extra` — this + /// was already correct before the manual impl; guard against regression. + #[test] + fn explicit_null_on_extra_field_survives_round_trip() { + let settings: GlobalSettings = + serde_json::from_value(serde_json::json!({"unknown_legacy_setting": null})).unwrap(); + let map = settings.to_settings_map(); + assert_eq!(map["unknown_legacy_setting"], serde_json::Value::Null); + } + + /// Top-level non-object input must reject with a deserialize error rather + /// than silently producing defaults. Matches the previous derive behavior. + #[test] + fn non_object_top_level_input_errors() { + assert!(serde_json::from_value::(serde_json::json!(null)).is_err()); + assert!(serde_json::from_value::(serde_json::json!("s")).is_err()); + assert!(serde_json::from_value::(serde_json::json!(42)).is_err()); + assert!(serde_json::from_value::(serde_json::json!([])).is_err()); + } + #[test] fn to_settings_map_empty_defaults() { let settings = GlobalSettings::default(); From c6346aabe0a1770e9e4c11a4da23d3fa081b9e6b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 12 May 2026 12:32:07 +0000 Subject: [PATCH 09/40] chore(main): release 1.700.2 (#9117) * chore(main): release 1.700.2 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 8 + backend/Cargo.lock | 160 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 128 insertions(+), 120 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13a1cdc370..e297309df2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.700.2](https://github.com/windmill-labs/windmill/compare/v1.700.1...v1.700.2) (2026-05-12) + + +### Bug Fixes + +* preserve explicit nulls for typed fields in bulk instance config ([#9123](https://github.com/windmill-labs/windmill/issues/9123)) ([cab0000](https://github.com/windmill-labs/windmill/commit/cab0000f3a5e9a0b201a85da1a01b1f82df8a316)) +* preserve negative integers in Bedrock tool schema conversion ([#9116](https://github.com/windmill-labs/windmill/issues/9116)) ([01e21c7](https://github.com/windmill-labs/windmill/commit/01e21c7f913eaf7dffc3d6a31501418ff2104c8b)) + ## [1.700.1](https://github.com/windmill-labs/windmill/compare/v1.700.0...v1.700.1) (2026-05-11) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 56d42da02b..7a6433e5af 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -13788,7 +13788,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-nats", @@ -13869,7 +13869,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.700.1" +version = "1.700.2" dependencies = [ "async-trait", "aws-config", @@ -13899,7 +13899,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.700.1" +version = "1.700.2" dependencies = [ "axum 0.8.9", "chrono", @@ -13912,7 +13912,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "argon2", @@ -14055,7 +14055,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.700.1" +version = "1.700.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14078,7 +14078,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.700.1" +version = "1.700.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14091,7 +14091,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "axum 0.8.9", @@ -14117,7 +14117,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.700.1" +version = "1.700.2" dependencies = [ "reqwest 0.12.28", "serde", @@ -14127,7 +14127,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.700.1" +version = "1.700.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14144,7 +14144,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.700.1" +version = "1.700.2" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14166,7 +14166,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "axum 0.8.9", @@ -14189,7 +14189,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.700.1" +version = "1.700.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14205,7 +14205,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.700.1" +version = "1.700.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14226,7 +14226,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.700.1" +version = "1.700.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14247,7 +14247,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.700.1" +version = "1.700.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14261,7 +14261,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-nats", @@ -14293,7 +14293,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "axum 0.8.9", @@ -14318,7 +14318,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.700.1" +version = "1.700.2" dependencies = [ "axum 0.8.9", "flate2", @@ -14336,7 +14336,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "axum 0.8.9", @@ -14358,7 +14358,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.700.1" +version = "1.700.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14378,7 +14378,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.700.1" +version = "1.700.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14408,7 +14408,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "axum 0.8.9", @@ -14436,7 +14436,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.700.1" +version = "1.700.2" dependencies = [ "lazy_static", "serde", @@ -14448,7 +14448,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.700.1" +version = "1.700.2" dependencies = [ "argon2", "axum 0.8.9", @@ -14473,7 +14473,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.700.1" +version = "1.700.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14487,7 +14487,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.700.1" +version = "1.700.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14520,7 +14520,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.700.1" +version = "1.700.2" dependencies = [ "chrono", "lazy_static", @@ -14534,7 +14534,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "axum 0.8.9", @@ -14553,7 +14553,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.700.1" +version = "1.700.2" dependencies = [ "aes-gcm", "aho-corasick", @@ -14654,7 +14654,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.700.1" +version = "1.700.2" dependencies = [ "chrono", "itertools 0.14.0", @@ -14673,7 +14673,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.700.1" +version = "1.700.2" dependencies = [ "regex", "serde", @@ -14688,7 +14688,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14712,7 +14712,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "futures", @@ -14729,7 +14729,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.700.1" +version = "1.700.2" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14745,7 +14745,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-trait", @@ -14766,7 +14766,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-trait", @@ -14797,7 +14797,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "arc-swap", @@ -14822,7 +14822,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-stream", @@ -14856,7 +14856,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "futures", @@ -14874,7 +14874,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.700.1" +version = "1.700.2" dependencies = [ "convert_case 0.6.0", "serde", @@ -14883,7 +14883,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "lazy_static", @@ -14895,7 +14895,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "serde_json", @@ -14907,7 +14907,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "gosyn", @@ -14919,7 +14919,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "lazy_static", @@ -14931,7 +14931,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "serde_json", @@ -14943,7 +14943,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "nu-parser", @@ -14954,7 +14954,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14965,7 +14965,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14977,7 +14977,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "rustpython-ast", @@ -14988,7 +14988,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-recursion", @@ -15010,7 +15010,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "serde_json", @@ -15022,7 +15022,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "lazy_static", @@ -15036,7 +15036,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15053,7 +15053,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "lazy_static", @@ -15066,7 +15066,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "serde", @@ -15078,7 +15078,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "lazy_static", @@ -15096,7 +15096,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15112,7 +15112,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "rustpython-ast", @@ -15128,7 +15128,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "serde", @@ -15139,7 +15139,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-recursion", @@ -15176,7 +15176,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "const_format", @@ -15214,7 +15214,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.700.1" +version = "1.700.2" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15225,7 +15225,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-recursion", @@ -15255,7 +15255,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-trait", @@ -15279,7 +15279,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-trait", @@ -15312,7 +15312,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-trait", @@ -15345,7 +15345,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-trait", @@ -15365,7 +15365,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-trait", @@ -15399,7 +15399,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-trait", @@ -15435,7 +15435,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-trait", @@ -15458,7 +15458,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-trait", @@ -15482,7 +15482,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-nats", @@ -15506,7 +15506,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-trait", @@ -15541,7 +15541,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-trait", @@ -15569,7 +15569,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-trait", @@ -15592,7 +15592,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -15611,7 +15611,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-once-cell", @@ -15724,7 +15724,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.700.1" +version = "1.700.2" dependencies = [ "bytes", "futures", @@ -16558,9 +16558,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 104fc6e136..0babfe3810 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.700.1" +version = "1.700.2" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.700.1" +version = "1.700.2" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 43780b0703..954303108f 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.700.1" +version = "1.700.2" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.700.1" +version = "1.700.2" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.700.1" +version = "1.700.2" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.700.1" +version = "1.700.2" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index e470ae9e38..0131c16089 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.700.1" +version = "1.700.2" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index b1e8ceabdb..258e268f88 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.700.1 + version: 1.700.2 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index ea5a58f71b..22cc1f75eb 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.700.1"; +export const VERSION = "v1.700.2"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index 8980331c31..a755c16901 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -79,7 +79,7 @@ export { token, }; -export const VERSION = "1.700.1"; +export const VERSION = "1.700.2"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8123f93c57..2483fa4d97 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.700.1", + "version": "1.700.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.700.1", + "version": "1.700.2", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 8da5968bb0..32bdc019f0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.700.1", + "version": "1.700.2", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 73496b676f..9425a21af9 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.700.1" +wmill = ">=1.700.2" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index be157a3961..7769f50a1c 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.700.1 + version: 1.700.2 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 8dfc135d48..586ece80a7 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.700.1' + ModuleVersion = '1.700.2' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 5d9917d249..1699abaebf 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.700.1" +version = "1.700.2" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index cffc69e164..251f481322 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.700.1", + "version": "1.700.2", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index caad8ab8ad..53005f6120 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.700.1", + "version": "1.700.2", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index e52350d307..cfabbb8494 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.700.1 +1.700.2 From a305a74e732c52d39e54c27899de165b9073ded7 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Tue, 12 May 2026 15:44:00 +0200 Subject: [PATCH 10/40] refactor: require ai evals proxy backend (#9119) --- ai_evals/README.md | 18 ++- .../adapters/frontend/backendPreview.test.ts | 2 - ai_evals/adapters/frontend/backendPreview.ts | 9 +- ai_evals/adapters/frontend/benchmarkRunner.ts | 18 ++- .../frontend/core/app/appEvalRunner.ts | 17 ++- .../adapters/frontend/core/app/fileHelpers.ts | 37 ++++++- .../frontend/core/flow/flowEvalRunner.ts | 13 +-- .../frontend/core/script/scriptEvalRunner.ts | 9 +- .../frontend/core/shared/baseEvalRunner.ts | 76 ++++++------- .../core/shared/providerConfig.test.ts | 26 ----- .../frontend/core/shared/providerConfig.ts | 73 ++---------- .../adapters/frontend/core/shared/types.ts | 8 +- ai_evals/adapters/frontend/runtime.ts | 5 - .../adapters/frontend/windmillBackend.test.ts | 104 ++++++++++++++++++ ai_evals/adapters/frontend/windmillBackend.ts | 31 ++++-- ai_evals/cli/index.ts | 25 +---- ai_evals/core/backendValidation.ts | 2 - ai_evals/core/frontendTransport.test.ts | 64 ----------- ai_evals/core/frontendTransport.ts | 49 --------- ai_evals/core/results.ts | 6 - ai_evals/core/types.ts | 2 - ai_evals/core/windmillBackendSettings.test.ts | 63 +++++++++++ ai_evals/core/windmillBackendSettings.ts | 22 ---- ai_evals/modes/app.ts | 14 +-- ai_evals/modes/flow.ts | 16 +-- ai_evals/modes/frontendCommon.ts | 10 +- ai_evals/modes/script.ts | 16 +-- 27 files changed, 328 insertions(+), 407 deletions(-) create mode 100644 ai_evals/adapters/frontend/windmillBackend.test.ts delete mode 100644 ai_evals/core/frontendTransport.test.ts delete mode 100644 ai_evals/core/frontendTransport.ts create mode 100644 ai_evals/core/windmillBackendSettings.test.ts diff --git a/ai_evals/README.md b/ai_evals/README.md index 267451aabf..2e1f3210f8 100644 --- a/ai_evals/README.md +++ b/ai_evals/README.md @@ -55,7 +55,7 @@ bun run cli -- run flow flow-test4-order-processing-loop --model opus bun run cli -- run flow flow-test0-sum-two-numbers --models haiku,opus,4o bun run cli -- run flow flow-test0-sum-two-numbers --runs 3 --verbose bun run cli -- run flow --record -GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro --transport proxy +GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000 bun run cli -- run flow --backend-validation preview bun run cli -- run cli bun-hello-script ``` @@ -72,7 +72,6 @@ Public CLI surface: - `--output `: custom result JSON path - `--model `: choose the model under test - `--models `: run the same cases sequentially against several model aliases -- `--transport `: frontend request transport (`direct` by default, `proxy` to exercise `/api/w/{workspace}/ai/proxy`) - `--verbose`: stream assistant output for frontend runs - `--record`: append a compact tracked summary line to `ai_evals/history/.jsonl` for full-suite runs only - `--backend-validation `: optional backend smoke validation (`off` or `preview`) for `script` and `flow` evals @@ -145,26 +144,23 @@ If `--backend-validation preview` is enabled: - `script` evals run a real backend script preview in an isolated temp workspace - `flow` evals run a real backend flow preview only for cases that define `runtime.backendPreview` - `flow` cases with `initial.workspace` fixtures seed those scripts and flows into the preview workspace before preview -- when `WMILL_AI_EVAL_BACKEND_WORKSPACE` is set, `ai_evals` treats that workspace as a dedicated test workspace, clears managed eval assets under `f/evals/*` before each preview run, and then reseeds the current case fixtures +- when `WMILL_AI_EVAL_BACKEND_WORKSPACE` is set, `ai_evals` creates or reuses that workspace as a dedicated test workspace, clears managed eval assets under `f/evals/*` before each preview run, and then reseeds the current case fixtures -Supported backend validation env vars: +Supported backend env vars: - `WMILL_AI_EVAL_BACKEND_VALIDATION=preview` - `WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000` - `WMILL_AI_EVAL_BACKEND_EMAIL=admin@windmill.dev` - `WMILL_AI_EVAL_BACKEND_PASSWORD=changeme` - `WMILL_AI_EVAL_BACKEND_WORKSPACE=integration-tests` to reuse an existing workspace on CE installs with low workspace limits -- `WMILL_AI_EVAL_KEEP_WORKSPACES=1` -- `WMILL_AI_EVAL_WORKSPACE_PREFIX=ai-evals` -Frontend proxy transport uses the same backend auth/workspace env vars. +Frontend modes require a reachable Windmill backend and send model requests through the workspace AI proxy at `/api/w/{workspace}/ai/proxy`. At startup, `ai_evals` checks the resolved backend URL and fails early with setup guidance if the backend cannot be reached or login fails. -When `--transport proxy` is set: +For frontend modes: -- `ai_evals` creates or reuses a backend workspace +- `ai_evals` creates a temporary backend workspace, or creates/reuses `WMILL_AI_EVAL_BACKEND_WORKSPACE` when it is set - it upserts a provider resource under `f/evals/ai/` - frontend requests go through `/api/w/{workspace}/ai/proxy` -- result JSON and history records include `transport` so direct vs proxy runs stay distinguishable ## Results And Artifacts @@ -182,7 +178,7 @@ If `--record` is used, the CLI also appends one compact JSON line to: Each recorded line contains: -- run metadata (`createdAt`, `gitSha`, `mode`, `runModel`, `transport`, `judgeModel`) +- run metadata (`createdAt`, `gitSha`, `mode`, `runModel`, `judgeModel`) - suite totals (`caseCount`, `attemptCount`, `passedAttempts`, `passRate`, `averageDurationMs`, `averageJudgeScore`) - average token usage (`averageTokenUsagePerAttempt`) - per-case metrics under `cases[]` (`averageDurationMs`, `averageJudgeScore`, `averageTokenUsagePerAttempt`, pass rate) diff --git a/ai_evals/adapters/frontend/backendPreview.test.ts b/ai_evals/adapters/frontend/backendPreview.test.ts index 2f12c9a896..d4de361333 100644 --- a/ai_evals/adapters/frontend/backendPreview.test.ts +++ b/ai_evals/adapters/frontend/backendPreview.test.ts @@ -210,8 +210,6 @@ function buildSettings( baseUrl: 'http://backend.test/default', email: 'admin@windmill.dev', password: 'changeme', - keepWorkspaces: true, - workspacePrefix: 'ai-evals', pollIntervalMs: 1, maxWaitMs: 50, ...overrides diff --git a/ai_evals/adapters/frontend/backendPreview.ts b/ai_evals/adapters/frontend/backendPreview.ts index e1be934564..57e1cfdf2a 100644 --- a/ai_evals/adapters/frontend/backendPreview.ts +++ b/ai_evals/adapters/frontend/backendPreview.ts @@ -24,6 +24,7 @@ export interface CompletedPreviewJob { const tokenCache = new Map>() const sharedWorkspaceQueue = new Map>() const managedSharedWorkspacePrefixes = ['f/evals/'] +const DEFAULT_WORKSPACE_PREFIX = 'ai-evals' export class BackendPreviewClient { constructor(private readonly settings: BackendValidationSettings) {} @@ -35,7 +36,7 @@ export class BackendPreviewClient { ): Promise { const workspaceId = this.settings.workspaceOverride ?? - buildWorkspaceId(this.settings.workspacePrefix, caseId, attempt) + buildWorkspaceId(caseId, attempt) const run = async () => { await this.ensureWorkspace(workspaceId) @@ -46,7 +47,7 @@ export class BackendPreviewClient { try { return await body(workspaceId) } finally { - if (!this.settings.keepWorkspaces && !this.settings.workspaceOverride) { + if (!this.settings.workspaceOverride) { await this.deleteWorkspace(workspaceId).catch(() => undefined) } } @@ -440,14 +441,14 @@ async function withSharedWorkspaceLock(workspaceId: string, body: () => Promi } } -function buildWorkspaceId(prefix: string, caseId: string, attempt: number): string { +function buildWorkspaceId(caseId: string, attempt: number): string { const caseSlug = caseId .toLowerCase() .replace(/[^a-z0-9-]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 30) const suffix = randomUUID().slice(0, 8) - return `${prefix}-${caseSlug || 'case'}-a${attempt}-${suffix}` + return `${DEFAULT_WORKSPACE_PREFIX}-${caseSlug || 'case'}-a${attempt}-${suffix}` } function extractFolderName(path: string): string | null { diff --git a/ai_evals/adapters/frontend/benchmarkRunner.ts b/ai_evals/adapters/frontend/benchmarkRunner.ts index 474d434803..50a0d10c3c 100644 --- a/ai_evals/adapters/frontend/benchmarkRunner.ts +++ b/ai_evals/adapters/frontend/benchmarkRunner.ts @@ -1,6 +1,5 @@ import { loadSelectedCases } from "../../core/cases"; import { resolveBackendValidationSettings } from "../../core/backendValidation"; -import { resolveFrontendEvalTransportSettings } from "../../core/frontendTransport"; import { formatRunModelLabel, getFrontendEvalModel, @@ -9,6 +8,7 @@ import { import { buildRunResult } from "../../core/results"; import { runSuite } from "../../core/runSuite"; import type { BenchmarkRunResult, ModeRunner } from "../../core/types"; +import { resolveWindmillBackendSettings } from "../../core/windmillBackendSettings"; import { emitFrontendBenchmarkProgress } from "./progress"; import { createAppModeRunner } from "../../modes/app"; import { createFlowModeRunner } from "../../modes/flow"; @@ -36,17 +36,14 @@ export async function runFrontendBenchmarkFromEnv(): Promise evalMode: mode, requestedMode: process.env.WMILL_FRONTEND_AI_EVAL_BACKEND_VALIDATION, }); - const transportSettings = resolveFrontendEvalTransportSettings({ - evalMode: mode, - requestedTransport: process.env.WMILL_FRONTEND_AI_EVAL_TRANSPORT, - }); + const backendSettings = resolveWindmillBackendSettings(); const selectedCases = await loadSelectedCases(mode, caseIds); const modeRunner = getModeRunner( mode, getFrontendEvalModel(model), backendValidation, - transportSettings, + backendSettings, ); const runModel = formatRunModelLabel(mode, model); const caseResults = await runSuite({ @@ -66,7 +63,6 @@ export async function runFrontendBenchmarkFromEnv(): Promise mode, runs, runModel, - transport: transportSettings.transport, judgeModel: DEFAULT_JUDGE_MODEL, caseResults, }); @@ -76,18 +72,18 @@ function getModeRunner( mode: FrontendBenchmarkMode, model: ReturnType, backendValidation: ReturnType, - transportSettings: ReturnType, + backendSettings: ReturnType, ): ModeRunner { switch (mode) { case "flow": - return createFlowModeRunner(model, backendValidation, transportSettings); + return createFlowModeRunner(model, backendValidation, backendSettings); case "app": - return createAppModeRunner(model, transportSettings); + return createAppModeRunner(model, backendSettings); case "script": return createScriptModeRunner( model, backendValidation, - transportSettings, + backendSettings, ); } } diff --git a/ai_evals/adapters/frontend/core/app/appEvalRunner.ts b/ai_evals/adapters/frontend/core/app/appEvalRunner.ts index 55d1e6ab9a..16543b28de 100644 --- a/ai_evals/adapters/frontend/core/app/appEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/app/appEvalRunner.ts @@ -12,7 +12,7 @@ import { prepareAppUserMessage, } from "../../../../../frontend/src/lib/components/copilot/chat/app/core"; import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared"; -import { createAppFileHelpers } from "./fileHelpers"; +import { createAppFileHelpers, type AppEvalChatHelpers } from "./fileHelpers"; import { runEval } from "../shared"; import type { AIProvider } from "$lib/gen/types.gen"; import type { @@ -22,7 +22,6 @@ import type { } from "../../../../core/types"; import type { TokenUsage } from "../shared/types"; import type { AppFilesState } from "../../../../core/validators"; -import type { FrontendEvalTransport } from "../../../../core/frontendTransport"; import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings"; import { createAppBackendRunnableContextElement, @@ -49,8 +48,7 @@ export interface AppEvalOptions { model?: string; maxIterations?: number; provider?: AIProvider; - transport?: FrontendEvalTransport; - backend?: WindmillBackendSettings; + backend: WindmillBackendSettings; workspaceRoot?: string; runContext?: ModeRunContext; } @@ -58,7 +56,7 @@ export interface AppEvalOptions { export async function runAppEval( userPrompt: string, apiKey: string, - options?: AppEvalOptions, + options: AppEvalOptions, ): Promise { const workspaceRoot = options?.workspaceRoot ?? @@ -101,10 +99,9 @@ export async function runAppEval( model, workspace: workspaceRoot, provider: options?.provider, - transport: options?.transport, - backend: options?.backend, - proxyCaseId: options?.runContext?.caseId, - proxyAttempt: options?.runContext?.attempt, + backend: options.backend, + caseId: options?.runContext?.caseId, + attempt: options?.runContext?.attempt, }, }); @@ -124,7 +121,7 @@ export async function runAppEval( async function buildAdditionalContext( appContext: EvalCaseRuntimeAppContextSpec | undefined, - helpers: AppAIChatHelpers, + helpers: AppEvalChatHelpers, ): Promise { const entries = appContext?.additional ?? []; if (entries.length === 0) { diff --git a/ai_evals/adapters/frontend/core/app/fileHelpers.ts b/ai_evals/adapters/frontend/core/app/fileHelpers.ts index e82ddf4672..15721a49ed 100644 --- a/ai_evals/adapters/frontend/core/app/fileHelpers.ts +++ b/ai_evals/adapters/frontend/core/app/fileHelpers.ts @@ -2,6 +2,7 @@ import { mkdir, rm, writeFile } from 'fs/promises' import { dirname, join } from 'path' import type { AppAIChatHelpers, + AppDatatableMetadata, AppFiles, BackendRunnable, DataTableSchema, @@ -10,6 +11,10 @@ import type { } from '../../../../../frontend/src/lib/components/copilot/chat/app/core' import { buildAppWmillTypes, collectAppDiagnostics } from '../../../../core/appDiagnostics' +export interface AppEvalChatHelpers extends AppAIChatHelpers { + getDatatables: () => Promise +} + async function writeFrontendFile( workspaceRoot: string | undefined, path: string, @@ -92,7 +97,7 @@ export async function createAppFileHelpers( initialDatatables: DataTableSchema[] = [], workspaceRoot?: string ): Promise<{ - helpers: AppAIChatHelpers + helpers: AppEvalChatHelpers getFiles: () => AppFiles getEvalState: () => { frontend: Record @@ -137,7 +142,7 @@ export async function createAppFileHelpers( } await persistDatatables(workspaceRoot, datatables) - const helpers: AppAIChatHelpers = { + const helpers: AppEvalChatHelpers = { listFrontendFiles: () => [ ...Object.keys(frontend).filter((path) => path !== '/wmill.d.ts'), '/wmill.d.ts' @@ -211,6 +216,34 @@ export async function createAppFileHelpers( }, lint, getDatatables: async () => structuredClone(datatables), + listDatatableTables: async () => + datatables.map( + (datatable): AppDatatableMetadata => { + const schemas = Object.fromEntries( + Object.entries(datatable.schemas).map(([schemaName, tables]) => [ + schemaName, + Object.keys(tables) + ]) + ) + return { + datatable_name: datatable.datatable_name, + schemas, + tableCount: Object.values(schemas).reduce( + (sum, tableNames) => sum + tableNames.length, + 0 + ), + error: datatable.error + } + } + ), + getDatatableTableSchema: async ( + datatableName: string, + schemaName: string, + tableName: string + ) => { + const datatable = datatables.find((entry) => entry.datatable_name === datatableName) + return structuredClone(datatable?.schemas?.[schemaName]?.[tableName] ?? {}) + }, getAvailableDatatableNames: () => datatables.map((datatable) => datatable.datatable_name), execDatatableSql: async ( datatableName: string, diff --git a/ai_evals/adapters/frontend/core/flow/flowEvalRunner.ts b/ai_evals/adapters/frontend/core/flow/flowEvalRunner.ts index 1b448bdea4..0cd25f5787 100644 --- a/ai_evals/adapters/frontend/core/flow/flowEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/flow/flowEvalRunner.ts @@ -18,7 +18,6 @@ import { import { runEval } from "../shared"; import type { ModeRunContext } from "../../../../core/types"; import type { TokenUsage, ToolCallDetail } from "../shared/types"; -import type { FrontendEvalTransport } from "../../../../core/frontendTransport"; import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings"; export interface FlowFixture { @@ -48,8 +47,7 @@ export interface FlowEvalOptions { model?: string; maxIterations?: number; provider?: AIProvider; - transport?: FrontendEvalTransport; - backend?: WindmillBackendSettings; + backend: WindmillBackendSettings; workspaceRoot?: string; runContext?: ModeRunContext; } @@ -57,7 +55,7 @@ export interface FlowEvalOptions { export async function runFlowEval( userPrompt: string, apiKey: string, - options?: FlowEvalOptions, + options: FlowEvalOptions, ): Promise { const workspaceRoot = options?.workspaceRoot ?? @@ -100,10 +98,9 @@ export async function runFlowEval( model, workspace: workspaceRoot, provider: options?.provider, - transport: options?.transport, - backend: options?.backend, - proxyCaseId: options?.runContext?.caseId, - proxyAttempt: options?.runContext?.attempt, + backend: options.backend, + caseId: options?.runContext?.caseId, + attempt: options?.runContext?.attempt, }, }); diff --git a/ai_evals/adapters/frontend/core/script/scriptEvalRunner.ts b/ai_evals/adapters/frontend/core/script/scriptEvalRunner.ts index 30aa2c81e7..95ce6555e6 100644 --- a/ai_evals/adapters/frontend/core/script/scriptEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/script/scriptEvalRunner.ts @@ -14,7 +14,6 @@ import { createScriptFileHelpers, type ScriptEvalState } from "./fileHelpers"; import { runEval } from "../shared"; import type { ModeRunContext } from "../../../../core/types"; import type { TokenUsage, ToolCallDetail } from "../shared/types"; -import type { FrontendEvalTransport } from "../../../../core/frontendTransport"; import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings"; export interface ScriptEvalResult { @@ -33,8 +32,7 @@ export interface ScriptEvalOptions { model?: string; maxIterations?: number; provider?: AIProvider; - transport?: FrontendEvalTransport; - backend?: WindmillBackendSettings; + backend: WindmillBackendSettings; workspaceRoot?: string; runContext?: ModeRunContext; } @@ -98,10 +96,9 @@ export async function runScriptEval( model, workspace: workspaceRoot, provider: modelProvider.provider, - transport: options.transport, backend: options.backend, - proxyCaseId: options.runContext?.caseId, - proxyAttempt: options.runContext?.attempt, + caseId: options.runContext?.caseId, + attempt: options.runContext?.attempt, }, }); diff --git a/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts b/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts index 5b1f2e948e..7fd43ccb87 100644 --- a/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts @@ -40,8 +40,8 @@ export interface RunEvalParams { apiKey: string; /** Function to get the current output state */ getOutput: () => TOutput; - /** Optional configuration */ - options?: EvalRunnerOptions; + /** Model and Windmill backend configuration */ + options: EvalRunnerOptions; onAssistantMessageStart?: () => void; onAssistantToken?: (token: string) => void; onAssistantMessageEnd?: () => void; @@ -70,10 +70,10 @@ export async function runEval( } = params; let shouldEmitMessageStart = true; - const model = options?.model ?? "gpt-4o"; - const maxIterations = options?.maxIterations ?? 20; - const workspace = options?.workspace ?? "test-workspace"; - const provider = toFrontendEvalProvider(options?.provider); + const model = options.model ?? "gpt-4o"; + const maxIterations = options.maxIterations ?? 20; + const workspace = options.workspace ?? "test-workspace"; + const provider = toFrontendEvalProvider(options.provider); const modelProvider = resolveEvalModelProvider(model, provider); @@ -203,45 +203,31 @@ export async function runEval( } }; - if (options?.transport === "proxy") { - const backendSettings = options.backend; - if (!backendSettings) { - throw new Error("Missing backend settings for proxy transport"); - } - - const backendClient = new WindmillBackendClient(backendSettings); - return await backendClient.withWorkspace( - options.proxyCaseId ?? "eval", - options.proxyAttempt ?? 1, - async (proxyWorkspaceId) => { - const resourcePath = buildProxyResourcePath(modelProvider.provider); - await backendClient.upsertResource({ - workspaceId: proxyWorkspaceId, - path: resourcePath, - resourceType: modelProvider.provider, - value: { api_key: apiKey }, - }); - const token = await backendClient.getToken(); - const clients = createEvalClients({ - provider: modelProvider.provider, - apiKey, - transport: "proxy", - proxy: { - baseURL: `${backendSettings.baseUrl}/api/w/${encodeURIComponent(proxyWorkspaceId)}/ai/proxy`, - bearerToken: token, - resourcePath, - }, - }) as unknown as ChatClients; - return await executeChatLoop(clients); - }, - ); - } - - const clients = createEvalClients({ - provider: modelProvider.provider, - apiKey, - }) as unknown as ChatClients; - return await executeChatLoop(clients); + const backendSettings = options.backend; + const backendClient = new WindmillBackendClient(backendSettings); + return await backendClient.withWorkspace( + options.caseId ?? "eval", + options.attempt ?? 1, + async (proxyWorkspaceId) => { + const resourcePath = buildProxyResourcePath(modelProvider.provider); + await backendClient.upsertResource({ + workspaceId: proxyWorkspaceId, + path: resourcePath, + resourceType: modelProvider.provider, + value: { api_key: apiKey }, + }); + const token = await backendClient.getToken(); + const clients = createEvalClients({ + provider: modelProvider.provider, + proxy: { + baseURL: `${backendSettings.baseUrl}/api/w/${encodeURIComponent(proxyWorkspaceId)}/ai/proxy`, + bearerToken: token, + resourcePath, + }, + }) as unknown as ChatClients; + return await executeChatLoop(clients); + }, + ); } function toFrontendEvalProvider( diff --git a/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts b/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts index e504ced376..77d9154da3 100644 --- a/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts +++ b/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts @@ -2,35 +2,9 @@ import { describe, expect, it } from "bun:test"; import { buildProxyHeaders, buildProxyResourcePath, - buildOpenAICompatibleClientOptions, resolveEvalModelProvider, } from "./providerConfig"; -describe("buildOpenAICompatibleClientOptions", () => { - it("adds Gemini's OpenAI-compatible base URL and client header", () => { - const options = buildOpenAICompatibleClientOptions( - "googleai", - "gemini-test-key", - ); - - expect(options).toMatchObject({ - apiKey: "gemini-test-key", - baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/", - defaultHeaders: { - "x-goog-api-client": "windmill-ai-evals/1.0", - }, - }); - }); - - it("keeps the default OpenAI-compatible config for OpenAI", () => { - expect( - buildOpenAICompatibleClientOptions("openai", "openai-test-key"), - ).toEqual({ - apiKey: "openai-test-key", - }); - }); -}); - describe("proxy helpers", () => { it("builds provider-scoped proxy resource paths", () => { expect(buildProxyResourcePath("googleai")).toBe("f/evals/ai/googleai"); diff --git a/ai_evals/adapters/frontend/core/shared/providerConfig.ts b/ai_evals/adapters/frontend/core/shared/providerConfig.ts index 62ba221a40..15372049fe 100644 --- a/ai_evals/adapters/frontend/core/shared/providerConfig.ts +++ b/ai_evals/adapters/frontend/core/shared/providerConfig.ts @@ -1,7 +1,6 @@ import Anthropic from "@anthropic-ai/sdk"; import OpenAI from "openai"; import type { FrontendEvalModelConfig } from "../../../../core/models"; -import type { FrontendEvalTransport } from "../../../../core/frontendTransport"; export type FrontendEvalProvider = FrontendEvalModelConfig["provider"]; @@ -15,15 +14,12 @@ export interface ResolvedEvalModelProvider { model: string; } -export interface EvalProxyClientConfig { +export interface WindmillAiProxyClientConfig { baseURL: string; bearerToken: string; resourcePath: string; } -const GEMINI_OPENAI_BASE_URL = - "https://generativelanguage.googleapis.com/v1beta/openai/"; -const GEMINI_GOOG_API_CLIENT = "windmill-ai-evals/1.0"; const EVAL_PROXY_RESOURCE_PREFIX = "f/evals/ai"; export function buildProxyHeaders( @@ -40,25 +36,8 @@ export function buildProxyResourcePath(provider: FrontendEvalProvider): string { return `${EVAL_PROXY_RESOURCE_PREFIX}/${provider}`; } -export function buildOpenAICompatibleClientOptions( - provider: Exclude, - apiKey: string, -): ConstructorParameters[0] { - if (provider === "googleai") { - return { - apiKey, - baseURL: GEMINI_OPENAI_BASE_URL, - defaultHeaders: { - "x-goog-api-client": GEMINI_GOOG_API_CLIENT, - }, - }; - } - - return { apiKey }; -} - function buildProxyOpenAIClientOptions( - proxy: EvalProxyClientConfig, + proxy: WindmillAiProxyClientConfig, ): ConstructorParameters[0] { return { apiKey: "unused", @@ -69,52 +48,24 @@ function buildProxyOpenAIClientOptions( export function createEvalClients(input: { provider: FrontendEvalProvider; - apiKey: string; - transport?: FrontendEvalTransport; - proxy?: EvalProxyClientConfig; + proxy: WindmillAiProxyClientConfig; }): EvalClients { - const transport = input.transport ?? "direct"; - if (input.provider === "anthropic") { - if (transport === "proxy") { - if (!input.proxy) { - throw new Error( - "Missing proxy client configuration for proxy transport", - ); - } - return { - openai: new OpenAI({ apiKey: "unused" }), - anthropic: new Anthropic({ - apiKey: "unused", - baseURL: input.proxy.baseURL, - defaultHeaders: buildProxyHeaders( - input.proxy.bearerToken, - input.proxy.resourcePath, - ), - }), - }; - } - return { openai: new OpenAI({ apiKey: "unused" }), - anthropic: new Anthropic({ apiKey: input.apiKey }), - }; - } - - if (transport === "proxy") { - if (!input.proxy) { - throw new Error("Missing proxy client configuration for proxy transport"); - } - return { - openai: new OpenAI(buildProxyOpenAIClientOptions(input.proxy)), - anthropic: new Anthropic({ apiKey: "unused" }), + anthropic: new Anthropic({ + apiKey: "unused", + baseURL: input.proxy.baseURL, + defaultHeaders: buildProxyHeaders( + input.proxy.bearerToken, + input.proxy.resourcePath, + ), + }), }; } return { - openai: new OpenAI( - buildOpenAICompatibleClientOptions(input.provider, input.apiKey), - ), + openai: new OpenAI(buildProxyOpenAIClientOptions(input.proxy)), anthropic: new Anthropic({ apiKey: "unused" }), }; } diff --git a/ai_evals/adapters/frontend/core/shared/types.ts b/ai_evals/adapters/frontend/core/shared/types.ts index f2a3f04794..f081fe398a 100644 --- a/ai_evals/adapters/frontend/core/shared/types.ts +++ b/ai_evals/adapters/frontend/core/shared/types.ts @@ -1,6 +1,5 @@ import type { ChatCompletionMessageParam } from "openai/resources/chat/completions.mjs"; import type { AIProvider } from "$lib/gen/types.gen"; -import type { FrontendEvalTransport } from "../../../../core/frontendTransport"; import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings"; export interface TokenUsage { @@ -15,14 +14,13 @@ export interface ToolCallDetail { } export interface EvalRunnerOptions { + backend: WindmillBackendSettings; maxIterations?: number; model?: string; workspace?: string; provider?: AIProvider; - transport?: FrontendEvalTransport; - backend?: WindmillBackendSettings; - proxyCaseId?: string; - proxyAttempt?: number; + caseId?: string; + attempt?: number; } export interface RawEvalResult { diff --git a/ai_evals/adapters/frontend/runtime.ts b/ai_evals/adapters/frontend/runtime.ts index 6a76fceeb0..180c7a2993 100644 --- a/ai_evals/adapters/frontend/runtime.ts +++ b/ai_evals/adapters/frontend/runtime.ts @@ -23,7 +23,6 @@ export async function runFrontendBenchmarkAdapter(input: { caseIds: string[]; runs: number; model?: string; - transport?: string; verbose?: boolean; backendValidation?: string; }): Promise { @@ -44,10 +43,6 @@ export async function runFrontendBenchmarkAdapter(input: { WMILL_FRONTEND_AI_EVAL_BACKEND_VALIDATION: input.backendValidation ?? "", }; - if (input.transport) { - env.WMILL_FRONTEND_AI_EVAL_TRANSPORT = input.transport; - } - try { await runVitestBenchmark( path.join(FRONTEND_DIR, "node_modules", ".bin", "vitest"), diff --git a/ai_evals/adapters/frontend/windmillBackend.test.ts b/ai_evals/adapters/frontend/windmillBackend.test.ts new file mode 100644 index 0000000000..302502c2d9 --- /dev/null +++ b/ai_evals/adapters/frontend/windmillBackend.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import type { WindmillBackendSettings } from "../../core/windmillBackendSettings"; +import { + WindmillBackendClient, + assertWindmillBackendReachable, +} from "./windmillBackend"; + +const ORIGINAL_FETCH = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = ORIGINAL_FETCH; +}); + +describe("assertWindmillBackendReachable", () => { + it("logs in to verify backend reachability", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + globalThis.fetch = mockFetch(requests, textResponse(200, "token")); + + await expect( + assertWindmillBackendReachable( + buildSettings({ baseUrl: "http://backend.test/reachable" }), + ), + ).resolves.toBeUndefined(); + + expect(requests.map((entry) => entry.url)).toEqual([ + "http://backend.test/reachable/api/auth/login", + ]); + }); + + it("adds setup guidance when the backend cannot be initialized", async () => { + globalThis.fetch = mockFetch( + [], + textResponse(401, "invalid password"), + ); + + await expect( + assertWindmillBackendReachable( + buildSettings({ baseUrl: "http://backend.test/auth-failure" }), + ), + ).rejects.toThrow( + "Start a Windmill backend at that URL, or set WMILL_AI_EVAL_BACKEND_URL=.", + ); + }); +}); + +describe("WindmillBackendClient", () => { + it("creates or reuses the specified backend workspace without deleting it", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + globalThis.fetch = mockFetch( + requests, + textResponse(200, "token"), + textResponse(200, "false"), + textResponse(200, ""), + ); + + const client = new WindmillBackendClient( + buildSettings({ + baseUrl: "http://backend.test/shared-workspace", + workspaceOverride: "shared-evals", + }), + ); + + await expect( + client.withWorkspace("case-a", 1, async (workspaceId) => workspaceId), + ).resolves.toBe("shared-evals"); + + expect(requests.map((entry) => entry.url)).toEqual([ + "http://backend.test/shared-workspace/api/auth/login", + "http://backend.test/shared-workspace/api/workspaces/exists", + "http://backend.test/shared-workspace/api/workspaces/create", + ]); + }); +}); + +function buildSettings( + overrides: Partial = {}, +): WindmillBackendSettings { + return { + baseUrl: "http://backend.test/default", + email: "admin@windmill.dev", + password: "changeme", + ...overrides, + }; +} + +function mockFetch( + requests: Array<{ url: string; init?: RequestInit }>, + ...responses: Response[] +): typeof fetch { + const queue = [...responses]; + return async (input, init) => { + const url = String(input); + requests.push({ url, init }); + const next = queue.shift(); + if (!next) { + throw new Error(`Unexpected fetch: ${url}`); + } + return next; + }; +} + +function textResponse(status: number, body: string): Response { + return new Response(body, { status }); +} diff --git a/ai_evals/adapters/frontend/windmillBackend.ts b/ai_evals/adapters/frontend/windmillBackend.ts index c8d9537779..2247d8e5d5 100644 --- a/ai_evals/adapters/frontend/windmillBackend.ts +++ b/ai_evals/adapters/frontend/windmillBackend.ts @@ -3,6 +3,7 @@ import type { WindmillBackendSettings } from "../../core/windmillBackendSettings const tokenCache = new Map>(); const sharedWorkspaceQueue = new Map>(); +const DEFAULT_WORKSPACE_PREFIX = "ai-evals"; export class WindmillBackendClient { constructor(private readonly settings: WindmillBackendSettings) {} @@ -14,7 +15,7 @@ export class WindmillBackendClient { ): Promise { const workspaceId = this.settings.workspaceOverride ?? - buildWorkspaceId(this.settings.workspacePrefix, caseId, attempt); + buildWorkspaceId(caseId, attempt); const run = async () => { await this.ensureWorkspace(workspaceId); @@ -22,7 +23,7 @@ export class WindmillBackendClient { try { return await body(workspaceId); } finally { - if (!this.settings.keepWorkspaces && !this.settings.workspaceOverride) { + if (!this.settings.workspaceOverride) { await this.deleteWorkspace(workspaceId).catch(() => undefined); } } @@ -136,6 +137,24 @@ export class WindmillBackendClient { } } +export async function assertWindmillBackendReachable( + settings: WindmillBackendSettings, +): Promise { + try { + await new WindmillBackendClient(settings).getToken(); + } catch (error) { + const details = error instanceof Error ? error.message : String(error); + throw new Error( + [ + `Could not initialize the Windmill backend for AI eval proxy at ${settings.baseUrl}.`, + "Start a Windmill backend at that URL, or set WMILL_AI_EVAL_BACKEND_URL=.", + `Using login ${settings.email}; if authentication failed, set WMILL_AI_EVAL_BACKEND_EMAIL and WMILL_AI_EVAL_BACKEND_PASSWORD.`, + `Details: ${details}`, + ].join("\n"), + ); + } +} + async function withSharedWorkspaceLock( workspaceId: string, body: () => Promise, @@ -160,18 +179,14 @@ async function withSharedWorkspaceLock( } } -function buildWorkspaceId( - prefix: string, - caseId: string, - attempt: number, -): string { +function buildWorkspaceId(caseId: string, attempt: number): string { const caseSlug = caseId .toLowerCase() .replace(/[^a-z0-9-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 30); const suffix = randomUUID().slice(0, 8); - return `${prefix}-${caseSlug || "case"}-a${attempt}-${suffix}`; + return `${DEFAULT_WORKSPACE_PREFIX}-${caseSlug || "case"}-a${attempt}-${suffix}`; } async function expectOk(response: Response, context: string): Promise { diff --git a/ai_evals/cli/index.ts b/ai_evals/cli/index.ts index 202d826078..259b055ff5 100644 --- a/ai_evals/cli/index.ts +++ b/ai_evals/cli/index.ts @@ -27,11 +27,8 @@ import { EVAL_MODES, type EvalMode } from "../core/types"; import { DEFAULT_JUDGE_MODEL } from "../core/judge"; import { createCliModeRunner } from "../modes/cli"; import { runFrontendBenchmarkAdapter } from "../adapters/frontend/runtime"; -import { - FRONTEND_EVAL_TRANSPORTS, - type FrontendEvalTransport, - parseFrontendEvalTransport, -} from "../core/frontendTransport"; +import { resolveWindmillBackendSettings } from "../core/windmillBackendSettings"; +import { assertWindmillBackendReachable } from "../adapters/frontend/windmillBackend"; async function main() { const program = new Command() @@ -98,10 +95,6 @@ async function main() { "--models ", "comma-separated model aliases to run sequentially", ) - .option( - "--transport ", - `frontend transport (${FRONTEND_EVAL_TRANSPORTS.join(", ")})`, - ) .option("--verbose", "stream assistant output during frontend runs") .option( "--record", @@ -120,7 +113,6 @@ async function main() { output?: string; model?: string; models?: string; - transport?: string; verbose?: boolean; record?: boolean; backendValidation?: string; @@ -133,9 +125,6 @@ async function main() { outputPath: options.output, model: options.model, models: options.models, - transport: options.transport - ? parseFrontendEvalTransport(options.transport) - : undefined, verbose: options.verbose ?? false, record: options.record ?? false, backendValidation: options.backendValidation, @@ -184,7 +173,6 @@ async function handleRun(input: { outputPath?: string; model?: string; models?: string; - transport?: FrontendEvalTransport; verbose: boolean; record: boolean; backendValidation?: string; @@ -197,11 +185,6 @@ async function handleRun(input: { if (input.model && input.models) { throw new Error("Use either --model or --models, not both"); } - if (input.mode === "cli" && input.transport === "proxy") { - throw new Error( - "--transport proxy is only supported for flow, script, and app modes", - ); - } const selectedCases = await loadSelectedCases(input.mode, input.caseIds); const models = resolveRequestedModels(input.mode, input.model, input.models); @@ -220,6 +203,9 @@ async function handleRun(input: { "--backend-validation currently supports only flow and script modes", ); } + if (input.mode !== "cli") { + await assertWindmillBackendReachable(resolveWindmillBackendSettings()); + } const summaries: Array<{ label: string; @@ -249,7 +235,6 @@ async function handleRun(input: { caseIds: input.caseIds, runs: input.runs, model: model.id, - transport: input.transport, verbose: input.verbose, backendValidation, }); diff --git a/ai_evals/core/backendValidation.ts b/ai_evals/core/backendValidation.ts index 87094fd23f..1484aee2d4 100644 --- a/ai_evals/core/backendValidation.ts +++ b/ai_evals/core/backendValidation.ts @@ -13,9 +13,7 @@ export interface BackendValidationSettings { baseUrl: string; email: string; password: string; - keepWorkspaces: boolean; workspaceOverride?: string; - workspacePrefix: string; pollIntervalMs: number; maxWaitMs: number; } diff --git a/ai_evals/core/frontendTransport.test.ts b/ai_evals/core/frontendTransport.test.ts deleted file mode 100644 index 09ebd1b3a7..0000000000 --- a/ai_evals/core/frontendTransport.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { afterEach, describe, expect, it } from "bun:test"; -import { - parseFrontendEvalTransport, - resolveFrontendEvalTransportSettings, -} from "./frontendTransport"; - -const ORIGINAL_ENV = { - WMILL_AI_EVAL_BACKEND_URL: process.env.WMILL_AI_EVAL_BACKEND_URL, -}; - -afterEach(() => { - if (ORIGINAL_ENV.WMILL_AI_EVAL_BACKEND_URL === undefined) { - delete process.env.WMILL_AI_EVAL_BACKEND_URL; - } else { - process.env.WMILL_AI_EVAL_BACKEND_URL = - ORIGINAL_ENV.WMILL_AI_EVAL_BACKEND_URL; - } -}); - -describe("parseFrontendEvalTransport", () => { - it("defaults to direct when unset", () => { - expect(parseFrontendEvalTransport(undefined)).toBe("direct"); - }); - - it("accepts proxy explicitly", () => { - expect(parseFrontendEvalTransport("proxy")).toBe("proxy"); - }); - - it("rejects unsupported values", () => { - expect(() => parseFrontendEvalTransport("worker")).toThrow( - "Unsupported frontend eval transport: worker", - ); - }); -}); - -describe("resolveFrontendEvalTransportSettings", () => { - it("includes backend settings for proxy transport", () => { - process.env.WMILL_AI_EVAL_BACKEND_URL = "http://127.0.0.1:8000/"; - - expect( - resolveFrontendEvalTransportSettings({ - evalMode: "app", - requestedTransport: "proxy", - }), - ).toMatchObject({ - transport: "proxy", - backend: { - baseUrl: "http://127.0.0.1:8000", - }, - }); - }); - - it("keeps direct transport for cli runs", () => { - expect( - resolveFrontendEvalTransportSettings({ - evalMode: "cli", - requestedTransport: "direct", - }), - ).toEqual({ - transport: "direct", - backend: undefined, - }); - }); -}); diff --git a/ai_evals/core/frontendTransport.ts b/ai_evals/core/frontendTransport.ts deleted file mode 100644 index fa78505113..0000000000 --- a/ai_evals/core/frontendTransport.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { EvalMode } from "./types"; -import type { WindmillBackendSettings } from "./windmillBackendSettings"; -import { resolveWindmillBackendSettings } from "./windmillBackendSettings"; - -export const FRONTEND_EVAL_TRANSPORTS = ["direct", "proxy"] as const; - -export type FrontendEvalTransport = (typeof FRONTEND_EVAL_TRANSPORTS)[number]; - -export interface FrontendEvalTransportSettings { - transport: FrontendEvalTransport; - backend?: WindmillBackendSettings; -} - -export function parseFrontendEvalTransport( - value?: string | null, -): FrontendEvalTransport { - const normalized = value?.trim().toLowerCase(); - - if (!normalized || normalized === "direct") { - return "direct"; - } - - if (normalized === "proxy") { - return "proxy"; - } - - throw new Error( - `Unsupported frontend eval transport: ${value}. Use one of: ${FRONTEND_EVAL_TRANSPORTS.join(", ")}`, - ); -} - -export function resolveFrontendEvalTransportSettings(input: { - evalMode: EvalMode; - requestedTransport?: string | null; -}): FrontendEvalTransportSettings { - const transport = parseFrontendEvalTransport(input.requestedTransport); - - if (transport === "proxy" && input.evalMode === "cli") { - throw new Error( - 'Frontend eval transport "proxy" is only supported for flow, script, and app evals', - ); - } - - return { - transport, - backend: - transport === "proxy" ? resolveWindmillBackendSettings() : undefined, - }; -} diff --git a/ai_evals/core/results.ts b/ai_evals/core/results.ts index c5f6b8749b..e58840f911 100644 --- a/ai_evals/core/results.ts +++ b/ai_evals/core/results.ts @@ -74,7 +74,6 @@ export function buildRunResult(input: { mode: EvalMode; runs: number; runModel: string | null; - transport?: BenchmarkRunResult["transport"]; judgeModel: string | null; caseResults: BenchmarkCaseResult[]; }): BenchmarkRunResult { @@ -116,7 +115,6 @@ export function buildRunResult(input: { gitSha: getGitSha(), runs: input.runs, runModel: input.runModel, - transport: input.transport ?? null, judgeModel: input.judgeModel, caseCount: input.caseResults.length, attemptCount, @@ -142,9 +140,6 @@ export function formatRunSummary(result: BenchmarkRunResult): string { `Pass rate: ${formatPercent(result.passRate)} (${result.passedAttempts}/${result.attemptCount})`, `Average duration: ${Math.round(result.averageDurationMs)}ms`, ]; - if (result.transport) { - lines.splice(1, 0, `Transport: ${result.transport}`); - } const failures = collectFailures(result); if (failures.length > 0) { @@ -251,7 +246,6 @@ function toHistoryRecord(result: BenchmarkRunResult) { mode: result.mode, runs: result.runs, runModel: result.runModel, - transport: result.transport, judgeModel: result.judgeModel, caseCount: result.caseCount, attemptCount: result.attemptCount, diff --git a/ai_evals/core/types.ts b/ai_evals/core/types.ts index 2d612be21a..dfc6882f84 100644 --- a/ai_evals/core/types.ts +++ b/ai_evals/core/types.ts @@ -1,7 +1,6 @@ export const EVAL_MODES = ["cli", "flow", "script", "app"] as const; export type EvalMode = (typeof EVAL_MODES)[number]; -export type FrontendEvalTransport = "direct" | "proxy"; export interface EvalCaseRuntimeBackendPreview { args?: Record; @@ -297,7 +296,6 @@ export interface BenchmarkRunResult { gitSha: string | null; runs: number; runModel: string | null; - transport: FrontendEvalTransport | null; judgeModel: string | null; caseCount: number; attemptCount: number; diff --git a/ai_evals/core/windmillBackendSettings.test.ts b/ai_evals/core/windmillBackendSettings.test.ts new file mode 100644 index 0000000000..200bc4aa7a --- /dev/null +++ b/ai_evals/core/windmillBackendSettings.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { resolveWindmillBackendSettings } from "./windmillBackendSettings"; + +const ENV_KEYS = [ + "WMILL_AI_EVAL_BACKEND_URL", + "WINDMILL_URL", + "WINDMILL_BASE_URL", + "REMOTE", + "WMILL_AI_EVAL_BACKEND_EMAIL", + "WMILL_AI_EVAL_BACKEND_PASSWORD", + "WMILL_AI_EVAL_BACKEND_WORKSPACE", +] as const; + +const ORIGINAL_ENV = Object.fromEntries( + ENV_KEYS.map((key) => [key, process.env[key]]), +) as Record<(typeof ENV_KEYS)[number], string | undefined>; + +afterEach(() => { + for (const key of ENV_KEYS) { + const value = ORIGINAL_ENV[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); + +describe("resolveWindmillBackendSettings", () => { + it("uses backend URL/auth defaults and the optional explicit workspace", () => { + delete process.env.WMILL_AI_EVAL_BACKEND_URL; + delete process.env.WINDMILL_URL; + delete process.env.WINDMILL_BASE_URL; + delete process.env.REMOTE; + process.env.WMILL_AI_EVAL_BACKEND_WORKSPACE = "shared-evals"; + + expect(resolveWindmillBackendSettings()).toEqual({ + baseUrl: "http://127.0.0.1:8000", + email: "admin@windmill.dev", + password: "changeme", + workspaceOverride: "shared-evals", + }); + }); + + it("does not expose workspace retention knobs", () => { + process.env.WMILL_AI_EVAL_BACKEND_URL = "http://backend.test/"; + + const settings = resolveWindmillBackendSettings(); + + expect(settings).toEqual({ + baseUrl: "http://backend.test", + email: "admin@windmill.dev", + password: "changeme", + workspaceOverride: undefined, + }); + expect(Object.keys(settings).sort()).toEqual([ + "baseUrl", + "email", + "password", + "workspaceOverride", + ]); + }); +}); diff --git a/ai_evals/core/windmillBackendSettings.ts b/ai_evals/core/windmillBackendSettings.ts index c3a0a52d47..2388c32b4d 100644 --- a/ai_evals/core/windmillBackendSettings.ts +++ b/ai_evals/core/windmillBackendSettings.ts @@ -2,9 +2,7 @@ export interface WindmillBackendSettings { baseUrl: string; email: string; password: string; - keepWorkspaces: boolean; workspaceOverride?: string; - workspacePrefix: string; } export function resolveWindmillBackendSettings(): WindmillBackendSettings { @@ -18,13 +16,9 @@ export function resolveWindmillBackendSettings(): WindmillBackendSettings { ), email: process.env.WMILL_AI_EVAL_BACKEND_EMAIL ?? "admin@windmill.dev", password: process.env.WMILL_AI_EVAL_BACKEND_PASSWORD ?? "changeme", - keepWorkspaces: isTruthy(process.env.WMILL_AI_EVAL_KEEP_WORKSPACES), workspaceOverride: sanitizeOptionalWorkspaceId( process.env.WMILL_AI_EVAL_BACKEND_WORKSPACE, ), - workspacePrefix: sanitizeWorkspacePrefix( - process.env.WMILL_AI_EVAL_WORKSPACE_PREFIX ?? "ai-evals", - ), }; } @@ -43,25 +37,9 @@ function normalizeBaseUrl(value: string): string { return value.replace(/\/+$/, ""); } -function sanitizeWorkspacePrefix(value: string): string { - const sanitized = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9-]+/g, "-") - .replace(/^-+|-+$/g, ""); - return sanitized.length > 0 ? sanitized : "ai-evals"; -} - function sanitizeOptionalWorkspaceId( value: string | undefined, ): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; } - -function isTruthy(value: string | undefined): boolean { - if (!value) { - return false; - } - return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase()); -} diff --git a/ai_evals/modes/app.ts b/ai_evals/modes/app.ts index 5bca0ad878..af9d7c667b 100644 --- a/ai_evals/modes/app.ts +++ b/ai_evals/modes/app.ts @@ -5,15 +5,12 @@ import type { FrontendEvalModelConfig } from "../core/models"; import { validateAppState, type AppFilesState } from "../core/validators"; import type { BenchmarkArtifactFile, ModeRunner } from "../core/types"; import { runAppEval } from "../adapters/frontend/core/app/appEvalRunner"; -import { - DEFAULT_FRONTEND_EVAL_MODEL, - getFrontendApiKey, -} from "./frontendCommon"; -import type { FrontendEvalTransportSettings } from "../core/frontendTransport"; +import { getFrontendApiKey } from "./frontendCommon"; +import type { WindmillBackendSettings } from "../core/windmillBackendSettings"; export function createAppModeRunner( - modelConfig: FrontendEvalModelConfig = DEFAULT_FRONTEND_EVAL_MODEL, - transportSettings?: FrontendEvalTransportSettings, + modelConfig: FrontendEvalModelConfig, + backendSettings: WindmillBackendSettings, ): ModeRunner { return { mode: "app", @@ -37,8 +34,7 @@ export function createAppModeRunner( appContext: context.evalCase?.runtime?.appContext, provider: modelConfig.provider, model: modelConfig.model, - transport: transportSettings?.transport, - backend: transportSettings?.backend, + backend: backendSettings, runContext: context, }, ); diff --git a/ai_evals/modes/flow.ts b/ai_evals/modes/flow.ts index 4e4f6451b9..e40a495573 100644 --- a/ai_evals/modes/flow.ts +++ b/ai_evals/modes/flow.ts @@ -7,11 +7,8 @@ import type { BenchmarkArtifactFile, ModeRunner } from "../core/types"; import { runFlowEval } from "../adapters/frontend/core/flow/flowEvalRunner"; import type { FlowWorkspaceFixtures } from "../adapters/frontend/core/flow/fileHelpers"; import { BackendPreviewClient } from "../adapters/frontend/backendPreview"; -import { - DEFAULT_FRONTEND_EVAL_MODEL, - getFrontendApiKey, -} from "./frontendCommon"; -import type { FrontendEvalTransportSettings } from "../core/frontendTransport"; +import { getFrontendApiKey } from "./frontendCommon"; +import type { WindmillBackendSettings } from "../core/windmillBackendSettings"; import { normalizeFlowInitialFixture, normalizeFlowStateFixture, @@ -19,9 +16,9 @@ import { } from "./flowFixtures"; export function createFlowModeRunner( - modelConfig: FrontendEvalModelConfig = DEFAULT_FRONTEND_EVAL_MODEL, - backendValidation?: BackendValidationSettings, - transportSettings?: FrontendEvalTransportSettings, + modelConfig: FrontendEvalModelConfig, + backendValidation: BackendValidationSettings | undefined, + backendSettings: WindmillBackendSettings, ): ModeRunner { return { mode: "flow", @@ -49,8 +46,7 @@ export function createFlowModeRunner( maxIterations: context.evalCase?.runtime?.maxTurns, provider: modelConfig.provider, model: modelConfig.model, - transport: transportSettings?.transport, - backend: transportSettings?.backend, + backend: backendSettings, runContext: context, }, ); diff --git a/ai_evals/modes/frontendCommon.ts b/ai_evals/modes/frontendCommon.ts index 2619d21821..f121551d86 100644 --- a/ai_evals/modes/frontendCommon.ts +++ b/ai_evals/modes/frontendCommon.ts @@ -1,12 +1,4 @@ -import { - getFrontendEvalModel, - resolveEvalModel, - type FrontendEvalModelConfig, -} from "../core/models"; - -export const DEFAULT_FRONTEND_EVAL_MODEL: FrontendEvalModelConfig = getFrontendEvalModel( - resolveEvalModel("flow") -); +import type { FrontendEvalModelConfig } from "../core/models"; export function getFrontendApiKey(provider: FrontendEvalModelConfig["provider"]): string { const envName = diff --git a/ai_evals/modes/script.ts b/ai_evals/modes/script.ts index 7671e8220a..0c49b05d7d 100644 --- a/ai_evals/modes/script.ts +++ b/ai_evals/modes/script.ts @@ -6,16 +6,13 @@ import type { BenchmarkArtifactFile, ModeRunner } from "../core/types"; import { BackendPreviewClient } from "../adapters/frontend/backendPreview"; import { runScriptEval } from "../adapters/frontend/core/script/scriptEvalRunner"; import type { ScriptEvalState } from "../adapters/frontend/core/script/fileHelpers"; -import { - DEFAULT_FRONTEND_EVAL_MODEL, - getFrontendApiKey, -} from "./frontendCommon"; -import type { FrontendEvalTransportSettings } from "../core/frontendTransport"; +import { getFrontendApiKey } from "./frontendCommon"; +import type { WindmillBackendSettings } from "../core/windmillBackendSettings"; export function createScriptModeRunner( - modelConfig: FrontendEvalModelConfig = DEFAULT_FRONTEND_EVAL_MODEL, - backendValidation?: BackendValidationSettings, - transportSettings?: FrontendEvalTransportSettings, + modelConfig: FrontendEvalModelConfig, + backendValidation: BackendValidationSettings | undefined, + backendSettings: WindmillBackendSettings, ): ModeRunner { return { mode: "script", @@ -40,8 +37,7 @@ export function createScriptModeRunner( maxIterations: context.evalCase?.runtime?.maxTurns, provider: modelConfig.provider, model: modelConfig.model, - transport: transportSettings?.transport, - backend: transportSettings?.backend, + backend: backendSettings, runContext: context, }, ); From 17cf538a2d8724bbc289d9c1c13e4076a4cdfef7 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Tue, 12 May 2026 16:09:31 +0200 Subject: [PATCH 11/40] refactor: move ai providers to windmill-ai (#9120) --- backend/Cargo.lock | 4 --- backend/windmill-ai/src/lib.rs | 1 + .../src}/providers/anthropic.rs | 8 ++--- .../src}/providers/bedrock.rs | 12 +++---- .../src}/providers/google_ai.rs | 4 +-- backend/windmill-ai/src/providers/mod.rs | 31 ++++++++++++++++++ .../src}/providers/openai.rs | 8 ++--- .../src}/providers/openrouter.rs | 10 +++--- .../ai => windmill-ai/src}/providers/other.rs | 8 ++--- backend/windmill-worker/Cargo.toml | 6 +--- backend/windmill-worker/src/ai/mod.rs | 1 - .../windmill-worker/src/ai/providers/mod.rs | 7 ---- .../windmill-worker/src/ai/query_builder.rs | 32 ++----------------- backend/windmill-worker/src/ai_executor.rs | 5 +-- docs/windmill-ai-refactor-plan.md | 4 +-- 15 files changed, 65 insertions(+), 76 deletions(-) rename backend/{windmill-worker/src/ai => windmill-ai/src}/providers/anthropic.rs (99%) rename backend/{windmill-worker/src/ai => windmill-ai/src}/providers/bedrock.rs (97%) rename backend/{windmill-worker/src/ai => windmill-ai/src}/providers/google_ai.rs (99%) create mode 100644 backend/windmill-ai/src/providers/mod.rs rename backend/{windmill-worker/src/ai => windmill-ai/src}/providers/openai.rs (99%) rename backend/{windmill-worker/src/ai => windmill-ai/src}/providers/openrouter.rs (98%) rename backend/{windmill-worker/src/ai => windmill-ai/src}/providers/other.rs (99%) delete mode 100644 backend/windmill-worker/src/ai/providers/mod.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 7a6433e5af..00e8959f72 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -15618,10 +15618,6 @@ dependencies = [ "async-recursion", "async-stream", "async-trait", - "aws-config", - "aws-credential-types", - "aws-sdk-bedrockruntime", - "aws-smithy-types", "axum 0.8.9", "backon", "base64 0.22.1", diff --git a/backend/windmill-ai/src/lib.rs b/backend/windmill-ai/src/lib.rs index 945091613b..6b174bf2e6 100644 --- a/backend/windmill-ai/src/lib.rs +++ b/backend/windmill-ai/src/lib.rs @@ -5,6 +5,7 @@ pub mod ai_google; pub mod ai_providers; pub mod ai_types; pub mod image_handler; +pub mod providers; pub mod query_builder; pub mod sse; pub mod types; diff --git a/backend/windmill-worker/src/ai/providers/anthropic.rs b/backend/windmill-ai/src/providers/anthropic.rs similarity index 99% rename from backend/windmill-worker/src/ai/providers/anthropic.rs rename to backend/windmill-ai/src/providers/anthropic.rs index 0a3c5df6e4..43cb503d96 100644 --- a/backend/windmill-worker/src/ai/providers/anthropic.rs +++ b/backend/windmill-ai/src/providers/anthropic.rs @@ -1,7 +1,4 @@ -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use serde_json::value::RawValue; -use windmill_ai::{ +use crate::{ ai_google::parse_data_url, ai_providers::AIProvider, image_handler::prepare_messages_for_api, @@ -10,6 +7,9 @@ use windmill_ai::{ types::*, utils::{extract_text_content, should_use_structured_output_tool}, }; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; use windmill_common::{client::AuthedClient, error::Error}; /// Anthropic API version for standard API diff --git a/backend/windmill-worker/src/ai/providers/bedrock.rs b/backend/windmill-ai/src/providers/bedrock.rs similarity index 97% rename from backend/windmill-worker/src/ai/providers/bedrock.rs rename to backend/windmill-ai/src/providers/bedrock.rs index 8c37433cab..005327c15b 100644 --- a/backend/windmill-worker/src/ai/providers/bedrock.rs +++ b/backend/windmill-ai/src/providers/bedrock.rs @@ -1,4 +1,4 @@ -//! AWS Bedrock provider for the AI agent. +//! AWS Bedrock provider for AI requests. //! //! Uses shared SDK code from windmill_ai::ai_bedrock for: //! - BedrockClient (SDK wrapper with auth) @@ -6,16 +6,16 @@ //! - Stream event parsing //! - Helper utilities -use std::collections::HashMap; -use windmill_ai::{ +use crate::{ image_handler::prepare_messages_for_api, query_builder::{ParsedResponse, StreamEventSink}, types::{OpenAIMessage, StreamingEvent, TokenUsage, ToolDef}, }; +use std::collections::HashMap; use windmill_common::{client::AuthedClient, error::Error}; -// Import shared Bedrock helpers for worker-specific orchestration. -use windmill_ai::ai_bedrock::{ +// Import shared Bedrock helpers for provider orchestration. +use crate::ai_bedrock::{ bedrock_model_supports_prompt_caching, bedrock_stream_event_is_block_stop, bedrock_stream_event_to_text, bedrock_stream_event_to_tool_delta, bedrock_stream_event_to_tool_start, build_tool_config, create_inference_config, @@ -24,7 +24,7 @@ use windmill_ai::ai_bedrock::{ }; // ============================================================================ -// Query Builder (Worker-specific orchestration) +// Query Builder // ============================================================================ #[derive(Default)] diff --git a/backend/windmill-worker/src/ai/providers/google_ai.rs b/backend/windmill-ai/src/providers/google_ai.rs similarity index 99% rename from backend/windmill-worker/src/ai/providers/google_ai.rs rename to backend/windmill-ai/src/providers/google_ai.rs index 19c50d06de..81f34e7acb 100644 --- a/backend/windmill-worker/src/ai/providers/google_ai.rs +++ b/backend/windmill-ai/src/providers/google_ai.rs @@ -1,5 +1,4 @@ -use async_trait::async_trait; -use windmill_ai::{ +use crate::{ ai_google::{ openai_messages_to_gemini, openai_tools_to_gemini, GeminiGenerationConfig, GeminiImageContent, GeminiImageRequest, GeminiImageResponse, GeminiInlineData, GeminiPart, @@ -10,6 +9,7 @@ use windmill_ai::{ sse::{GeminiSSEParser, SSEParser}, types::*, }; +use async_trait::async_trait; use windmill_common::{client::AuthedClient, error::Error}; // ============================================================================ diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs new file mode 100644 index 0000000000..8f9e2382a8 --- /dev/null +++ b/backend/windmill-ai/src/providers/mod.rs @@ -0,0 +1,31 @@ +pub mod anthropic; +#[cfg(feature = "bedrock")] +pub mod bedrock; +pub mod google_ai; +pub mod openai; +pub mod openrouter; +pub mod other; + +use crate::{ai_providers::AIProvider, query_builder::QueryBuilder, types::ProviderWithResource}; + +use self::{ + anthropic::AnthropicQueryBuilder, google_ai::GoogleAIQueryBuilder, openai::OpenAIQueryBuilder, + openrouter::OpenRouterQueryBuilder, other::OtherQueryBuilder, +}; + +/// Factory function to create the appropriate query builder for a provider. +pub fn create_query_builder(provider: &ProviderWithResource) -> Box { + match provider.kind { + AIProvider::GoogleAI => { + Box::new(GoogleAIQueryBuilder::new(provider.get_platform().clone())) + } + AIProvider::OpenAI => Box::new(OpenAIQueryBuilder::new(provider.kind.clone())), + AIProvider::Anthropic => Box::new(AnthropicQueryBuilder::new( + provider.kind.clone(), + provider.get_platform().clone(), + provider.get_enable_1m_context(), + )), + AIProvider::OpenRouter => Box::new(OpenRouterQueryBuilder::new()), + _ => Box::new(OtherQueryBuilder::new(provider.kind.clone())), + } +} diff --git a/backend/windmill-worker/src/ai/providers/openai.rs b/backend/windmill-ai/src/providers/openai.rs similarity index 99% rename from backend/windmill-worker/src/ai/providers/openai.rs rename to backend/windmill-ai/src/providers/openai.rs index e0767d5dda..f8928ca940 100644 --- a/backend/windmill-worker/src/ai/providers/openai.rs +++ b/backend/windmill-ai/src/providers/openai.rs @@ -1,7 +1,4 @@ -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use serde_json::value::RawValue; -use windmill_ai::{ +use crate::{ ai_providers::AIProvider, ai_types::OpenAIToolCall, image_handler::{prepare_messages_for_api, s3_object_to_content_part}, @@ -10,6 +7,9 @@ use windmill_ai::{ types::*, utils::extract_text_content, }; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; use windmill_common::{client::AuthedClient, error::Error}; // Responses API structures diff --git a/backend/windmill-worker/src/ai/providers/openrouter.rs b/backend/windmill-ai/src/providers/openrouter.rs similarity index 98% rename from backend/windmill-worker/src/ai/providers/openrouter.rs rename to backend/windmill-ai/src/providers/openrouter.rs index ede541cd13..aaefbb08ca 100644 --- a/backend/windmill-worker/src/ai/providers/openrouter.rs +++ b/backend/windmill-ai/src/providers/openrouter.rs @@ -1,15 +1,15 @@ -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use serde_json; -use windmill_ai::{ +use crate::{ ai_providers::AIProvider, image_handler::prepare_messages_for_api, query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink}, types::*, }; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json; use windmill_common::{client::AuthedClient, error::Error}; -use crate::ai::providers::other::OtherQueryBuilder; +use crate::providers::other::OtherQueryBuilder; // OpenRouter-specific types #[derive(Serialize)] diff --git a/backend/windmill-worker/src/ai/providers/other.rs b/backend/windmill-ai/src/providers/other.rs similarity index 99% rename from backend/windmill-worker/src/ai/providers/other.rs rename to backend/windmill-ai/src/providers/other.rs index a840517133..08f922a1de 100644 --- a/backend/windmill-worker/src/ai/providers/other.rs +++ b/backend/windmill-ai/src/providers/other.rs @@ -1,7 +1,4 @@ -use async_trait::async_trait; -use serde::Serialize; -use serde_json; -use windmill_ai::{ +use crate::{ ai_providers::AIProvider, image_handler::prepare_messages_for_api, query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink}, @@ -9,6 +6,9 @@ use windmill_ai::{ types::*, utils::should_use_structured_output_tool, }; +use async_trait::async_trait; +use serde::Serialize; +use serde_json; use windmill_common::{client::AuthedClient, error::Error}; #[derive(Serialize, Debug, Clone)] diff --git a/backend/windmill-worker/Cargo.toml b/backend/windmill-worker/Cargo.toml index f1c7fa6c99..5ebcb0b370 100644 --- a/backend/windmill-worker/Cargo.toml +++ b/backend/windmill-worker/Cargo.toml @@ -39,7 +39,7 @@ ruby = ["dep:windmill-parser-ruby"] rlang = ["dep:windmill-parser-r"] duckdb = ["dep:libloading"] quickjs = ["windmill-jseval/quickjs"] -bedrock = ["windmill-ai/bedrock", "dep:aws-sdk-bedrockruntime", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-types"] +bedrock = ["windmill-ai/bedrock"] [dependencies] windmill-ai = { workspace = true, default-features = false } @@ -71,10 +71,6 @@ windmill-parser-sql.workspace = true windmill-parser-graphql.workspace = true windmill-parser-php = { workspace = true, optional = true } windmill-git-sync.workspace = true -aws-sdk-bedrockruntime = { workspace = true, optional = true } -aws-config = { workspace = true, optional = true } -aws-credential-types = { workspace = true, optional = true } -aws-smithy-types = { workspace = true, optional = true } flume.workspace = true sqlx.workspace = true uuid.workspace = true diff --git a/backend/windmill-worker/src/ai/mod.rs b/backend/windmill-worker/src/ai/mod.rs index b004b0ec20..24e877ab13 100644 --- a/backend/windmill-worker/src/ai/mod.rs +++ b/backend/windmill-worker/src/ai/mod.rs @@ -1,7 +1,6 @@ // AI executor module structure // This module will contain all AI-related execution logic -pub mod providers; pub mod query_builder; pub mod tools; pub mod utils; diff --git a/backend/windmill-worker/src/ai/providers/mod.rs b/backend/windmill-worker/src/ai/providers/mod.rs deleted file mode 100644 index f558c28289..0000000000 --- a/backend/windmill-worker/src/ai/providers/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod anthropic; -#[cfg(feature = "bedrock")] -pub mod bedrock; -pub mod google_ai; -pub mod openai; -pub mod openrouter; -pub mod other; diff --git a/backend/windmill-worker/src/ai/query_builder.rs b/backend/windmill-worker/src/ai/query_builder.rs index 606aa8f9db..fe8a3b9b00 100644 --- a/backend/windmill-worker/src/ai/query_builder.rs +++ b/backend/windmill-worker/src/ai/query_builder.rs @@ -1,37 +1,9 @@ use async_trait::async_trait; -use windmill_ai::{ - query_builder::{QueryBuilder, StreamEventSink}, - types::*, -}; +use windmill_ai::{query_builder::StreamEventSink, types::*}; use windmill_common::{error::Error, worker::Connection}; use windmill_queue::MiniPulledJob; -use crate::{ - ai::providers::{ - anthropic::AnthropicQueryBuilder, google_ai::GoogleAIQueryBuilder, - openai::OpenAIQueryBuilder, openrouter::OpenRouterQueryBuilder, other::OtherQueryBuilder, - }, - job_logger::append_result_stream, -}; - -/// Factory function to create the appropriate query builder for a provider -pub fn create_query_builder(provider: &ProviderWithResource) -> Box { - use windmill_ai::ai_providers::AIProvider; - - match provider.kind { - AIProvider::GoogleAI => { - Box::new(GoogleAIQueryBuilder::new(provider.get_platform().clone())) - } - AIProvider::OpenAI => Box::new(OpenAIQueryBuilder::new(provider.kind.clone())), - AIProvider::Anthropic => Box::new(AnthropicQueryBuilder::new( - provider.kind.clone(), - provider.get_platform().clone(), - provider.get_enable_1m_context(), - )), - AIProvider::OpenRouter => Box::new(OpenRouterQueryBuilder::new()), - _ => Box::new(OtherQueryBuilder::new(provider.kind.clone())), - } -} +use crate::job_logger::append_result_stream; /// Processes streaming events by persisting them to the database. /// Implements StreamEventSink so it can be passed to QueryBuilder methods. diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index cdf2995f9c..dae4f277f2 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -23,6 +23,7 @@ use crate::ai::tools::McpClientStub as McpClient; use windmill_ai::{ ai_providers::AIProvider, image_handler::upload_image_to_s3, + providers::create_query_builder, query_builder::{BuildRequestArgs, ParsedResponse}, types::*, utils::{should_use_structured_output_tool, AI_HTTP_HEADERS}, @@ -44,7 +45,7 @@ use windmill_common::{ use windmill_queue::{cancel_single_job, CanceledBy, MiniPulledJob}; use crate::{ - ai::query_builder::{create_query_builder, StreamEventProcessor}, + ai::query_builder::StreamEventProcessor, common::{build_args_map, resolve_job_timeout, OccupancyMetrics, StreamNotifier}, handle_child::{run_future_with_polling_update_job_poller_graceful, GracefulPollOutcome}, }; @@ -866,7 +867,7 @@ pub async fn run_agent( .get_region() .unwrap_or(windmill_ai::ai_providers::USE_ENV_REGION); // Use Bedrock SDK via dedicated query builder - crate::ai::providers::bedrock::BedrockQueryBuilder::default() + windmill_ai::providers::bedrock::BedrockQueryBuilder::default() .execute_request( &messages, tool_defs.as_deref(), diff --git a/docs/windmill-ai-refactor-plan.md b/docs/windmill-ai-refactor-plan.md index 78b82ddcc6..0e8449d897 100644 --- a/docs/windmill-ai-refactor-plan.md +++ b/docs/windmill-ai-refactor-plan.md @@ -142,7 +142,7 @@ Move from `windmill-worker/src/ai/sse.rs` to `windmill-ai/src/sse.rs`: --- -### Step 5: Move provider implementations to windmill-ai +### Step 5: Move provider implementations to windmill-ai ✅ Move from `windmill-worker/src/ai/providers/` to `windmill-ai/src/providers/`: - `anthropic.rs` — `AnthropicQueryBuilder` @@ -159,7 +159,7 @@ Move utility functions providers depend on: --- -### Step 6: Move image_handler to windmill-ai +### Step 6: Move image_handler to windmill-ai ✅ Move from `windmill-worker/src/ai/image_handler.rs` to `windmill-ai/src/image_handler.rs`: - `download_and_encode_s3_image` — no signature change needed From d0f23cc5238b025208c61e983701894de28536d5 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 12 May 2026 16:33:21 +0200 Subject: [PATCH 12/40] feat(frontend): unified EditorHeader with file picker for flow/script/app editors (#9047) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add EditableInput component * feat: add EditorHeader for flow editor with file picker entry point * feat: WorkspaceItemPicker for editor header navigation * feat: clickable breadcrumb in EditorHeader, scoped picker keyboard nav * fix: reload flow on URL change and reset highlight in search mode * feat: editor header layout polish and trigger removal * feat: apply EditorHeader to script, app, and raw app editors * fix: show generated initial path for new apps in EditorHeader * fix: align EditorHeader new-app path with draft drawer's Path component * fix: read page.params.path in loadApp to reload on URL change * fix: remount AppEditor when navigating between apps * fix: clear app/files on URL change so editor remounts with fresh data * fix: route picker selections to /apps/edit or /apps_raw/edit based on raw_app * fix: drop Save button from path popover; defer rename to deploy * refactor: drop pathPopoverContent snippet, bind newEditedPath directly * refactor: dedupe editor header plumbing (editPathFor, userPathPrefix, breadcrumb snippet) * fix: freeze breadcrumb during path edit so popover doesn't drift * fix: drop spinner from path dependency-check; render nothing when no usages * fix: swallow 404 in checkFlowOnBehalfOf so renaming a flow doesn't toast * Revert "fix: swallow 404 in checkFlowOnBehalfOf so renaming a flow doesn't toast" This reverts commit 82dec462aefe5f166862ee7642c028ce0e58e3e9. * refactor: drop moveRenameManager dep from EditorHeader; pass onBehalfOfEmail as prop * refactor: replace breadcrumb-snapshot effect with open/close setter * refactor: drop unused dirtyPath state from EditorHeader * fix: surface Path validation error in pen popover * fix: decouple Path validation error from hideFullPath toggle * refactor: use InputError for path validation message (slide transition) * fix: re-derive Path meta from external path changes (sibling sync) * docs: note Path's meta could be replaced with function-form bindings * fix: 'Exit & see details' uses deployed path, not live store * fix: undo/redo shortcut uses shiftKey instead of fragile case-match * fix: type errors and keep edit pen visible while popover is open * chore: remove unused meltComponents/Accordion wrapper * fix: focus search input on picker open via popover openFocus selector * fix: refocus picker search input on every popover open * fix: pre-seed picker loaded state from cache so accordion opens at the right place * fix: breadcrumb and picker track savedPath, not draft-renamed live path * fix: inject current draft item into picker so breadcrumb scope isn't empty * docs: add component-level and prop-level docs to EditableInput * feat: warn that a deploy is needed when path is edited on a saved item * feat: show same path-change-needs-deploy message in flow/script settings * fix(flows): persist draft-renamed path through reload and dirty check * fix: include path in unsaved-changes diff so renames trigger the modal * feat: nested folders in picker tree and breadcrumb * fix: per-segment popover state so switching breadcrumbs closes the previous one * refactor: replace accordion picker with drill-through picker * refactor(picker): review fixes, drill polish, and breadcrumb collapse * fix(picker): review fixes — banned bindable, drop sibling-sync, load races, mouse highlight * fix(picker): smooth-scroll highlighted row into view on open * fix(picker): second-pass review fixes — load races, cache invalidation, breadcrumb/picker a11y, raw_app routing * fix(RowIcon): apply size prop to resource_type and fallback divs * feat(picker): add cross-kind 'All' root; deeper breadcrumb segments open there * fix(picker,editor): third-pass review — search loading state, pen autofocus, allow empty summary, a11y * fix(picker,editor): fourth-pass review — own check, flowbuilder arrows, editableinput double-save, customui.path gate, invalidate races * fix(editor,picker): codex review — fresh URL state per load, granular whitelabel topBar gates * fix(flows): clear localStorage in auto-reload to break URL-state loop --- .../lib/components/BreadcrumbSegment.svelte | 76 ++ .../src/lib/components/EditorHeader.svelte | 247 +++++++ .../src/lib/components/FlowBuilder.svelte | 84 +-- frontend/src/lib/components/InputError.svelte | 2 +- .../src/lib/components/LabelsInput.svelte | 14 +- frontend/src/lib/components/Path.svelte | 10 +- .../src/lib/components/ScriptBuilder.svelte | 116 +-- .../WorkspaceItemDrillPicker.svelte | 699 ++++++++++++++++++ .../components/apps/editor/AppEditor.svelte | 4 +- .../apps/editor/AppEditorHeader.svelte | 99 ++- .../components/common/EditableInput.svelte | 143 ++++ .../components/common/button/Button.svelte | 12 +- .../UnsavedConfirmationModal.svelte | 7 +- .../components/common/table/RowIcon.svelte | 43 +- frontend/src/lib/components/flow_builder.ts | 82 +- .../flows/content/FlowSettings.svelte | 8 + .../lib/components/icons/BarsStaggered.svelte | 12 +- .../components/meltComponents/Popover.svelte | 5 + .../components/raw_apps/RawAppEditor.svelte | 2 +- .../raw_apps/RawAppEditorHeader.svelte | 97 ++- frontend/src/lib/components/script_builder.ts | 2 + .../src/lib/components/workspacePicker.ts | 151 ++++ frontend/src/lib/utils.ts | 10 + .../(logged)/apps/edit/[...path]/+page.svelte | 17 +- .../apps_raw/edit/[...path]/+page.svelte | 21 +- .../(root)/(logged)/flows/add/+page.svelte | 6 +- .../flows/edit/[...path]/+page.svelte | 71 +- .../(root)/(logged)/scripts/add/+page.svelte | 23 +- .../scripts/edit/[...path]/+page.svelte | 42 +- 29 files changed, 1760 insertions(+), 345 deletions(-) create mode 100644 frontend/src/lib/components/BreadcrumbSegment.svelte create mode 100644 frontend/src/lib/components/EditorHeader.svelte create mode 100644 frontend/src/lib/components/WorkspaceItemDrillPicker.svelte create mode 100644 frontend/src/lib/components/common/EditableInput.svelte create mode 100644 frontend/src/lib/components/workspacePicker.ts diff --git a/frontend/src/lib/components/BreadcrumbSegment.svelte b/frontend/src/lib/components/BreadcrumbSegment.svelte new file mode 100644 index 0000000000..8b18503f75 --- /dev/null +++ b/frontend/src/lib/components/BreadcrumbSegment.svelte @@ -0,0 +1,76 @@ + + + + + {#snippet trigger()} + {#if withChevron}{label}{:else}{label}{/if} + {/snippet} + {#snippet content()} + + {/snippet} + diff --git a/frontend/src/lib/components/EditorHeader.svelte b/frontend/src/lib/components/EditorHeader.svelte new file mode 100644 index 0000000000..e52210bb06 --- /dev/null +++ b/frontend/src/lib/components/EditorHeader.svelte @@ -0,0 +1,247 @@ + + +
+ +
+ + + + {#if pathEditable} + pathPopoverOpen, setPathPopoverOpen} + > + {#snippet trigger()} +
+ + +
+ +
+
diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 6c135a70b5..f2e37bbeda 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -20,6 +20,7 @@ readFieldsRecursively, replaceFalseWithUndefined, isMac, + userPathPrefix, type Item, type StateStore, type Value @@ -46,7 +47,6 @@ import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte' import { cleanFlow } from './flows/utils.svelte' import { - Calendar, Save, DiffIcon, HistoryIcon, @@ -71,7 +71,7 @@ import { resetAllTodos, skipAllTodos } from '$lib/tutorialUtils' import { tutorialsToDo } from '$lib/stores' import { getTutorialIndex } from '$lib/tutorials/config' - import SummaryPathDisplay from './SummaryPathDisplay.svelte' + import EditorHeader from './EditorHeader.svelte' import type { FlowBuilderWhitelabelCustomUi } from './custom_ui' import FlowYamlEditor from './flows/header/FlowYamlEditor.svelte' import { type TriggerContext, type ScheduleTrigger } from './triggers' @@ -130,7 +130,8 @@ onDetails, onSaveDraftError, onSaveDraftOnlyAtNewPath, - onHistoryRestore + onHistoryRestore, + onNavigate }: FlowBuilderProps = $props() let initialPathStore = writable(initialPath) @@ -147,13 +148,7 @@ }) // used for new flows for captures - let fakeInitialPath = - 'u/' + - ($userStore?.username?.includes('@') - ? $userStore!.username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '') - : $userStore?.username) + - '/' + - generateRandomString(12) + let fakeInitialPath = userPathPrefix($userStore?.username) + generateRandomString(12) // Used by multiplayer deploy collision warning let deployedValue: Value | undefined = $state(undefined) // Value to diff against @@ -203,6 +198,11 @@ savedValue: savedFlow, modifiedValue: { ...flowStore.val, + // `$pathStore` is the live-edited path (the pen popover binds it). + // `flowStore.val.path` doesn't track those edits, so without this the + // rename wouldn't show up in the diff and the unsaved-changes warning + // wouldn't fire when leaving with a pending rename. + path: $pathStore, draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot()) } } @@ -760,16 +760,13 @@ return } - switch (event.key) { - case 'Z': - if (event.ctrlKey || event.metaKey) { - handleRedo() - event.preventDefault() - } - break + // Only lowercase single-char keys — named keys like `ArrowDown` must + // stay PascalCase to match their switch cases. + switch (event.key.length === 1 ? event.key.toLowerCase() : event.key) { case 'z': if (event.ctrlKey || event.metaKey) { - handleUndo() + if (event.shiftKey) handleRedo() + else handleUndo() event.preventDefault() } break @@ -822,7 +819,10 @@ if (savedFlow?.draft_only === false || savedFlow?.draft_only === undefined) { dropdownItems.push({ label: 'Exit & see details', - onClick: () => onDetails?.({ path: $pathStore }) + // Use the deployed path, not the live `$pathStore` — the latter + // reflects local rename edits that haven't been deployed yet, + // which would land the user on a 404 details page. + onClick: () => onDetails?.({ path: initialPath }) }) } @@ -1025,9 +1025,20 @@ untrack(() => saveSessionDraft()) } }) + // Sync `$pathStore` from `flowStore.val.path` (which `initFlow` populates + // from the loaded flow — including the draft's rename, when there is one). + // This effect only tracks `flowStore.val.path`, so popover edits that go + // straight to `$pathStore` don't trigger it and aren't overwritten. + // Replaces the previous `$pathStore = initialPath` push (added in #2536 for + // the VSCode extension), which silently dropped any draft-renamed path + // because `initialPath` is the URL, not the loaded path. $effect.pre(() => { - initialPath && ($pathStore = initialPath) + // `flowStore.val` is typed `OpenFlow` here but `initFlow` actually puts a + // `Flow` (with `path`) in it. + const p = (flowStore.val as Flow | undefined)?.path + if (p) untrack(() => ($pathStore = p)) }) + $effect.pre(() => { selectedId && untrack(() => select(selectedId)) }) @@ -1150,38 +1161,15 @@
-
- + onNavigate?.(item)} />
- -
{#if $enterpriseLicense && !newFlow} diff --git a/frontend/src/lib/components/InputError.svelte b/frontend/src/lib/components/InputError.svelte index f075521740..a431140afa 100644 --- a/frontend/src/lib/components/InputError.svelte +++ b/frontend/src/lib/components/InputError.svelte @@ -2,7 +2,7 @@ import { slide } from 'svelte/transition' interface Props { - error: string + error?: string | undefined } let { error }: Props = $props() diff --git a/frontend/src/lib/components/LabelsInput.svelte b/frontend/src/lib/components/LabelsInput.svelte index e357f62895..933fe38ea1 100644 --- a/frontend/src/lib/components/LabelsInput.svelte +++ b/frontend/src/lib/components/LabelsInput.svelte @@ -1,5 +1,6 @@ + + (x.summary ? `${x.summary} (${x.path})` : x.path)} + opts={{}} +/> + +{#snippet leafRow(it: Item, secondary: string, baseClass: string)} + {@const key = leafKey(it)} + {@const isHl = key === highlightedKey} + {@const isCur = isCurrent(it)} + +{/snippet} + + +
(mouseActive = true)} +> +
+ +
+ + {#if scope} + {@const s = scope} + + {/if} + +
+ {#if isSearching} + {@const total = (searchedItems ?? []).length} + {@const anyKindLoading = kinds.some((k) => loadingKind[k])} + {#if !searchedItems || anyKindLoading} + +
+ Searching… +
+ {:else if total === 0} +
No matches
+ {:else} + {#each kinds as k (k)} + {@const results = searchResultsByKind[k]} + {#if results.length > 0} +
+ {KIND_LABEL[k]} +
+
    + {#each results as it (leafKey(it))} +
  • {@render leafRow(it, it.path, 'py-1.5')}
  • + {/each} +
+ {/if} + {/each} + {/if} + {:else if scopeLoading && entries.length === 0} +
+ Loading… +
+ {:else if entries.length === 0} +
Empty
+ {:else} +
+ {#each entries as entry (entry.key)} + {@const isHl = entry.key === highlightedKey} + {#if entry.type === 'leaf'} + {@render leafRow( + entry.item, + scope?.dir ? entry.item.path.slice(scope.dir.length + 1) : entry.item.path, + 'py-1.5' + )} + {:else} + + {/if} + {/each} +
+ {/if} +
+
+ + diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index e919f7d484..158e4be8df 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -877,7 +877,7 @@ {/snippet} {#if $mode === 'preview'} - +
{/if} - +
- import { Drawer, DrawerContent, UndoRedo } from '$lib/components/common' + import { Drawer, DrawerContent } from '$lib/components/common' import Button from '$lib/components/common/button/Button.svelte' import Toggle from '$lib/components/Toggle.svelte' import { AppService, DraftService, type Policy } from '$lib/gen' import { redo, undo } from '$lib/history.svelte' import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores' - import type { Item } from '$lib/utils' + import { isMac, type Item, userPathPrefix } from '$lib/utils' + import { random_adj } from '$lib/components/random_positive_adjetive' import { AlignHorizontalSpaceAround, BellOff, @@ -24,6 +25,8 @@ Sun, Moon, SunMoon, + Undo, + Redo, Zap, Globe } from 'lucide-svelte' @@ -52,7 +55,9 @@ import AppReportsDrawer from './AppReportsDrawer.svelte' import DebugPanel from './contextPanel/DebugPanel.svelte' - import Summary from '$lib/components/Summary.svelte' + import EditorHeader from '$lib/components/EditorHeader.svelte' + import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker' + import { goto } from '$app/navigation' import HideButton from './settingsPanel/HideButton.svelte' import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte' @@ -119,7 +124,20 @@ onHideBottomPanel }: Props = $props() - let newEditedPath = $state('') + /** Mirror of the path the user is editing in the pen popover. Initialized + * once from `newPath` (or a synthesized path for new apps) and only + * updated by user input from then on — we deliberately do NOT sync from + * `newPath` afterwards so the user's in-flight rename isn't clobbered by + * a parent reload that re-supplies the saved path. The fallback chain at + * read sites (`newEditedPath || savedApp?.draft?.path || savedApp?.path`) + * handles the case where `newEditedPath` is briefly empty before the + * synthesized initialization runs — falls through to the saved path so + * rename detection still works. */ + let newEditedPath = $state( + untrack(() => + newApp ? userPathPrefix($userStore?.username) + random_adj() + '_app' : (newPath ?? '') + ) + ) let deployedValue: Value | undefined = $state(undefined) // Value to diff against let deployedBy: string | undefined = $state(undefined) // Author let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning @@ -283,6 +301,7 @@ preserve_on_behalf_of: preserveOnBehalfOf || undefined } }) + invalidatePicker($workspaceStore!, 'app') savedApp = { summary: $summary, value: structuredClone($state.snapshot($app)), @@ -511,19 +530,12 @@ lock = true - switch (event.key) { - case 'Z': - if (event.ctrlKey || event.metaKey) { - const napp = redo(history) - for (const key in napp) { - $app[key] = napp[key] - } - event.preventDefault() - } - break + // Only lowercase single-char keys — named keys (`ArrowDown`, etc.) must + // stay PascalCase to match their switch cases. + switch (event.key.length === 1 ? event.key.toLowerCase() : event.key) { case 'z': if (event.ctrlKey || event.metaKey) { - const napp = undo(history, $app) + const napp = event.shiftKey ? redo(history) : undo(history, $app) for (const key in napp) { $app[key] = napp[key] } @@ -558,7 +570,37 @@ lock = false } + const mod = isMac() ? '⌘' : 'Ctrl+' + + function handleUndo() { + const napp = undo(history, $app) + for (const key in napp) { + $app[key] = napp[key] + } + } + function handleRedo() { + const napp = redo(history) + for (const key in napp) { + $app[key] = napp[key] + } + } + let moreItems = $derived([ + { + displayName: 'Undo', + icon: Undo, + action: () => handleUndo(), + disabled: $history?.index === 0, + shortcut: `${mod}Z` + }, + { + displayName: 'Redo', + icon: Redo, + action: () => handleRedo(), + disabled: $history && $history?.index === $history.history.length - 1, + shortcut: `${mod}⇧Z`, + separatorBottom: true + }, { displayName: 'Deployment history', icon: History, @@ -890,28 +932,17 @@
- + goto(editPathFor(item))} + />
- { - const napp = undo(history, $app) - for (const key in napp) { - $app[key] = napp[key] - } - }} - on:redo={() => { - const napp = redo(history) - for (const key in napp) { - $app[key] = napp[key] - } - }} - /> - {#if $app} (summary = v)} + textClass="text-xs font-semibold text-emphasis" +/> +``` + +The current value isn't bound — `onSave` is fired with the trimmed draft +whenever it differs from the prior `value`, including with `''` when the +user clears the field. Callers that want to reject empty commits should +guard inside their `onSave` handler. The parent owns the canonical state; +this component just proposes new values. +--> + + +{#if editing} + + + +{:else} + +{/if} + + diff --git a/frontend/src/lib/components/common/button/Button.svelte b/frontend/src/lib/components/common/button/Button.svelte index 077994d842..b739076cee 100644 --- a/frontend/src/lib/components/common/button/Button.svelte +++ b/frontend/src/lib/components/common/button/Button.svelte @@ -385,7 +385,11 @@ {@render children?.()} {/if} {#if endIcon?.icon} - + {/if} {#if shortCut && !shortCut.hide}
+ {/if} {#if shortCut && !shortCut.hide} {@const Icon = shortCut.Icon} diff --git a/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte b/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte index 20ebe04c85..1305d611c1 100644 --- a/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte +++ b/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte @@ -58,11 +58,8 @@ open = true } if (savedValue && modifiedValue) { - const draftOrDeployed = cleanValueProperties({ - ...((savedValue.draft || savedValue) ?? {}), - path: undefined - }) - const current = cleanValueProperties({ ...(modifiedValue ?? {}), path: undefined }) + const draftOrDeployed = cleanValueProperties((savedValue.draft || savedValue) ?? {}) + const current = cleanValueProperties(modifiedValue ?? {}) if ( orderedJsonStringify(replaceFalseWithUndefined(draftOrDeployed)) === diff --git a/frontend/src/lib/components/common/table/RowIcon.svelte b/frontend/src/lib/components/common/table/RowIcon.svelte index 7906b01d33..67a707ff1e 100644 --- a/frontend/src/lib/components/common/table/RowIcon.svelte +++ b/frontend/src/lib/components/common/table/RowIcon.svelte @@ -53,9 +53,10 @@ | 'email_trigger' /** For 'trigger' kind, specifies the specific trigger type (routes, schedules, etc.) */ triggerKind?: string | undefined + size?: number } - let { kind, triggerKind = undefined }: Props = $props() + let { kind, triggerKind = undefined, size = 16 }: Props = $props() // Map per-kind backend names (e.g. `kafka_trigger`) to the legacy short // names the icon switch already handles, so we don't have to duplicate cases. @@ -79,44 +80,44 @@
{#if effectiveKind === 'flow'} - + {:else if effectiveKind === 'app' || effectiveKind === 'raw_app'} - + {:else if effectiveKind === 'script'} - + {:else if effectiveKind === 'variable'} - + {:else if effectiveKind === 'resource'} - + {:else if effectiveKind === 'resource_type'} -
+
{:else if effectiveKind === 'folder'} - + {:else if effectiveKind === 'schedule' || effectiveKind === 'schedules'} - + {:else if effectiveKind === 'routes'} - + {:else if effectiveKind === 'websockets'} - + {:else if effectiveKind === 'postgres'} - + {:else if effectiveKind === 'kafka'} - + {:else if effectiveKind === 'nats'} - + {:else if effectiveKind === 'mqtt'} - + {:else if effectiveKind === 'sqs'} - + {:else if effectiveKind === 'gcp'} - + {:else if effectiveKind === 'azure'} - + {:else if effectiveKind === 'emails'} - + {:else if effectiveKind === 'trigger'} - + {:else} -
+
{/if}
diff --git a/frontend/src/lib/components/flow_builder.ts b/frontend/src/lib/components/flow_builder.ts index d847664fa7..427fb9a9fd 100644 --- a/frontend/src/lib/components/flow_builder.ts +++ b/frontend/src/lib/components/flow_builder.ts @@ -6,46 +6,48 @@ import type { DiffDrawerI } from './diff_drawer' import type { FlowBuilderWhitelabelCustomUi } from './custom_ui' import type { ScheduleTrigger } from './triggers' import type { stepState } from './stepHistoryLoader.svelte' +import type { WorkspaceItem } from './workspacePicker' export type FlowBuilderProps = { - initialPath?: string - pathStoreInit?: string | undefined - newFlow: boolean - selectedId: string | undefined - initialArgs?: Record - loading?: boolean - flowStore: StateStore - flowStateStore: StateStore - savedFlow?: FlowWithDraftAndDraftTriggers | undefined - diffDrawer?: DiffDrawerI | undefined - customUi?: FlowBuilderWhitelabelCustomUi - disableAi?: boolean - disabledFlowInputs?: boolean - savedPrimarySchedule?: ScheduleTrigger | undefined // used to set the primary schedule in the legacy primaryScheduleStore - version?: number | undefined - setSavedraftCb?: ((cb: () => void) => void) | undefined - draftTriggersFromUrl?: Trigger[] | undefined - selectedTriggerIndexFromUrl?: number | undefined - children?: import('svelte').Snippet - loadedFromHistoryFromUrl?: { - flowJobInitial: boolean | undefined - stepsState: Record - } - noInitial?: boolean - onSaveInitial?: ({ path, id }: { path: string; id: string }) => void - onSaveDraft?: ({ - path, - savedAtNewPath, - newFlow - }: { - path: string - savedAtNewPath: boolean - newFlow: boolean - }) => void - onSaveDraftError?: ({ error }: { error: any }) => void - onSaveDraftOnlyAtNewPath?: ({ path, selectedId }: { path: string; selectedId: string }) => void - onDeploy?: ({ path }: { path: string }) => void - onDeployError?: ({ error }: { error: any }) => void - onDetails?: ({ path }: { path: string }) => void - onHistoryRestore?: () => void + initialPath?: string + pathStoreInit?: string | undefined + newFlow: boolean + selectedId: string | undefined + initialArgs?: Record + loading?: boolean + flowStore: StateStore + flowStateStore: StateStore + savedFlow?: FlowWithDraftAndDraftTriggers | undefined + diffDrawer?: DiffDrawerI | undefined + customUi?: FlowBuilderWhitelabelCustomUi + disableAi?: boolean + disabledFlowInputs?: boolean + savedPrimarySchedule?: ScheduleTrigger | undefined // used to set the primary schedule in the legacy primaryScheduleStore + version?: number | undefined + setSavedraftCb?: ((cb: () => void) => void) | undefined + draftTriggersFromUrl?: Trigger[] | undefined + selectedTriggerIndexFromUrl?: number | undefined + children?: import('svelte').Snippet + loadedFromHistoryFromUrl?: { + flowJobInitial: boolean | undefined + stepsState: Record + } + noInitial?: boolean + onSaveInitial?: ({ path, id }: { path: string; id: string }) => void + onSaveDraft?: ({ + path, + savedAtNewPath, + newFlow + }: { + path: string + savedAtNewPath: boolean + newFlow: boolean + }) => void + onSaveDraftError?: ({ error }: { error: any }) => void + onSaveDraftOnlyAtNewPath?: ({ path, selectedId }: { path: string; selectedId: string }) => void + onDeploy?: ({ path }: { path: string }) => void + onDeployError?: ({ error }: { error: any }) => void + onDetails?: ({ path }: { path: string }) => void + onHistoryRestore?: () => void + onNavigate?: (item: WorkspaceItem) => void } diff --git a/frontend/src/lib/components/flows/content/FlowSettings.svelte b/frontend/src/lib/components/flows/content/FlowSettings.svelte index 353c9ba563..3c6b268e00 100644 --- a/frontend/src/lib/components/flows/content/FlowSettings.svelte +++ b/frontend/src/lib/components/flows/content/FlowSettings.svelte @@ -139,6 +139,14 @@ namePlaceholder="flow" kind="flow" /> + {#if $initialPathStore && $pathStore && $pathStore !== $initialPathStore} + + {/if} {/if} diff --git a/frontend/src/lib/components/icons/BarsStaggered.svelte b/frontend/src/lib/components/icons/BarsStaggered.svelte index dbfebdeb1d..eedc72de8a 100644 --- a/frontend/src/lib/components/icons/BarsStaggered.svelte +++ b/frontend/src/lib/components/icons/BarsStaggered.svelte @@ -1,18 +1,18 @@ diff --git a/frontend/src/lib/components/meltComponents/Popover.svelte b/frontend/src/lib/components/meltComponents/Popover.svelte index a88d90c7ff..87d5ff3f83 100644 --- a/frontend/src/lib/components/meltComponents/Popover.svelte +++ b/frontend/src/lib/components/meltComponents/Popover.svelte @@ -52,6 +52,9 @@ closeOnOtherPopoverOpen?: boolean allowFullScreen?: boolean extraProps?: Record + /** Attributes spread onto the trigger button. Use for `aria-label`, + * `aria-current`, etc. — overrides the default `aria-label="Popup button"`. */ + triggerAttrs?: Record disabled?: boolean documentationLink?: string | undefined disableFocusTrap?: boolean @@ -90,6 +93,7 @@ closeOnOtherPopoverOpen = false, allowFullScreen = false, extraProps = {}, + triggerAttrs = {}, disabled = false, documentationLink = undefined, disableFocusTrap = false, @@ -219,6 +223,7 @@ class={className} use:melt={$_trigger} aria-label="Popup button" + {...triggerAttrs} disabled={disablePopup || disabled} onmouseenter={() => { if (openOnHover) { diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 8d9fa070af..247a0486af 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -943,7 +943,7 @@ onApply={handleYamlApply} /> - + - import { Badge, Drawer, DrawerContent } from '$lib/components/common' + import { Drawer, DrawerContent } from '$lib/components/common' import Button from '$lib/components/common/button/Button.svelte' - import UndoRedo from '$lib/components/common/button/UndoRedo.svelte' + import { isMac, userPathPrefix } from '$lib/utils' + import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker' import { AppService, DraftService, type Policy } from '$lib/gen' import { rawAppToHubUrl } from '$lib/hub' @@ -15,18 +16,19 @@ FileJson, Globe, History, - Pen, + Redo, Save, + Undo, WandSparkles } from 'lucide-svelte' - import { createEventDispatcher } from 'svelte' + import { createEventDispatcher, untrack } from 'svelte' import { cleanValueProperties, orderedJsonStringify, type Value, - replaceFalseWithUndefined, - defaultIfEmptyString + replaceFalseWithUndefined } from '../../utils' + import { random_adj } from '$lib/components/random_positive_adjetive' // import { allItems, toStatic } from '../apps/editor/settingsPanel/utils' import AppExportButton from '../apps/editor/AppExportButton.svelte' @@ -37,7 +39,8 @@ import Awareness from '$lib/components/Awareness.svelte' import type DiffDrawer from '$lib/components/DiffDrawer.svelte' - import Summary from '$lib/components/Summary.svelte' + import EditorHeader from '$lib/components/EditorHeader.svelte' + import { goto } from '$app/navigation' import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte' import AppJobsDrawer from '../apps/editor/AppJobsDrawer.svelte' @@ -130,7 +133,13 @@ onOpenYamlEditor = undefined }: Props = $props() - let newEditedPath = $state('') + let newEditedPath = $state( + untrack(() => + newApp + ? userPathPrefix($userStore?.username) + random_adj() + '_app' + : newPath || appPath || '' + ) + ) let deployedValue: Value | undefined = $state(undefined) // Value to diff against let deployedBy: string | undefined = $state(undefined) // Author @@ -332,6 +341,7 @@ css } }) + invalidatePicker($workspaceStore!, 'app') savedApp = { summary: summary, value: structuredClone(stateSnapshot(app)), @@ -562,7 +572,24 @@ } } - let moreItems = [ + const mod = isMac() ? '⌘' : 'Ctrl+' + + let moreItems = $derived([ + { + displayName: 'Undo', + icon: Undo, + action: () => onUndo?.(), + disabled: !canUndo, + shortcut: `${mod}Z` + }, + { + displayName: 'Redo', + icon: Redo, + action: () => onRedo?.(), + disabled: !canRedo, + shortcut: `${mod}⇧Z`, + separatorBottom: true + }, { displayName: 'Deployment history', icon: History, @@ -617,7 +644,7 @@ }, disabled: !savedApp } - ] + ]) const dispatch = createEventDispatcher() @@ -824,52 +851,20 @@ />
- -
- onUndo?.()} - on:redo={() => onRedo?.()} + goto(editPathFor(item))} /> +
-
- {#if newPath || newEditedPath} -
-
- -
- { - currentTarget.select() - }} - /> -
- {/if} -
{#if $enterpriseLicense && appPath != ''} {/if} diff --git a/frontend/src/lib/components/script_builder.ts b/frontend/src/lib/components/script_builder.ts index 24d8e69d32..378e562e98 100644 --- a/frontend/src/lib/components/script_builder.ts +++ b/frontend/src/lib/components/script_builder.ts @@ -5,6 +5,7 @@ import type { DiffDrawerI } from './diff_drawer' import type { ScriptBuilderFunctionExports } from './scriptBuilder' import type { ScheduleTrigger } from './triggers' import type { NewScriptWithDraftAndDraftTriggers, Trigger } from './triggers/utils' +import type { WorkspaceItem } from './workspacePicker' export interface ScriptBuilderProps { script: NewScript & { @@ -44,4 +45,5 @@ export interface ScriptBuilderProps { onSaveDraft?: (e: { path: string; savedAtNewPath: boolean; script: NewScript }) => void onSeeDetails?: (e: { path: string }) => void onSaveDraftError?: (e: { path: string; error: any }) => void + onNavigate?: (item: WorkspaceItem) => void } diff --git a/frontend/src/lib/components/workspacePicker.ts b/frontend/src/lib/components/workspacePicker.ts new file mode 100644 index 0000000000..ba2985ecbb --- /dev/null +++ b/frontend/src/lib/components/workspacePicker.ts @@ -0,0 +1,151 @@ +import type { Flow, ListableApp, Script } from '$lib/gen' + +export type WorkspaceItemKind = 'flow' | 'script' | 'app' + +export type WorkspaceItem = { + path: string + summary: string + kind: WorkspaceItemKind + raw_app?: boolean +} + +/** Display label for kinds — used in the picker rows and search section headers. */ +export const KIND_LABEL: Record = { + flow: 'Flows', + script: 'Scripts', + app: 'Apps' +} + +/** Lowercase variant — matches the editor breadcrumb style. */ +export const KIND_LABEL_LOWER: Record = { + flow: 'flows', + script: 'scripts', + app: 'apps' +} + +/** Composite keys used for keyboard nav and `initialHighlight`. The picker and + * its callers must agree on these — keep them all here. `'all'` is a virtual + * kind for the cross-kind root view; only valid for `kindKey` / `dirKey` + * (leaves always belong to a real kind). */ +export const kindKey = (k: WorkspaceItemKind | 'all') => `kind:${k}` +export const dirKey = (k: WorkspaceItemKind | 'all', fullPath: string) => `dir:${k}:${fullPath}` +export const leafKeyFor = (k: WorkspaceItemKind, path: string) => `leaf:${k}:${path}` + +export function editPathFor(item: WorkspaceItem): string { + if (item.kind === 'flow') return `/flows/edit/${item.path}` + if (item.kind === 'script') return `/scripts/edit/${item.path}` + return item.raw_app ? `/apps_raw/edit/${item.path}` : `/apps/edit/${item.path}` +} + +type WorkspaceCache = { + flow?: WorkspaceItem[] + script?: WorkspaceItem[] + app?: WorkspaceItem[] +} + +/** Module-level session cache. Persists across picker mounts within a single + * page session. NOT invalidated automatically — call `invalidate()` after + * creating/deleting an item if the picker may be opened again before a full + * reload. */ +const cache = new Map() +const inflight = new Map>() +/** Bumped by `invalidate()`. Each in-flight `loadKind` captures the version + * at start and only writes back to the cache if it still matches — so a + * deploy mid-fetch can't have its stale predecessor repopulate the cache. */ +const cacheVersion = new Map() + +const cacheKey = (workspace: string, kind: WorkspaceItemKind) => `${workspace}:${kind}` + +const KINDS: WorkspaceItemKind[] = ['flow', 'script', 'app'] + +function bumpVersion(workspace: string, kind: WorkspaceItemKind) { + const k = cacheKey(workspace, kind) + cacheVersion.set(k, (cacheVersion.get(k) ?? 0) + 1) +} + +export function getCachedItems( + workspace: string, + kind: WorkspaceItemKind +): WorkspaceItem[] | undefined { + return cache.get(workspace)?.[kind] +} + +/** Drop a workspace+kind (or a whole workspace) from the cache so the next + * picker open re-fetches. Use after creating/deleting items. Also bumps the + * version so any in-flight `loadKind` started before the invalidate won't + * write its (now-stale) result back to the cache. */ +export function invalidate(workspace: string, kind?: WorkspaceItemKind) { + if (!kind) { + cache.delete(workspace) + for (const k of KINDS) bumpVersion(workspace, k) + return + } + const bucket = cache.get(workspace) + if (bucket) delete bucket[kind] + bumpVersion(workspace, kind) +} + +export async function loadKind( + workspace: string, + kind: WorkspaceItemKind +): Promise { + const existing = cache.get(workspace)?.[kind] + if (existing) return existing + const key = cacheKey(workspace, kind) + const flying = inflight.get(key) + if (flying) return flying + + const startVersion = cacheVersion.get(key) ?? 0 + const promise = (async () => { + const { ScriptService, FlowService, AppService } = await import('$lib/gen') + let items: WorkspaceItem[] + if (kind === 'flow') { + const flows = await FlowService.listFlows({ + workspace, + includeDraftOnly: true, + withoutDescription: true + }) + items = flows.map((f: Flow) => ({ + path: f.path, + summary: f.summary ?? '', + kind: 'flow' as const + })) + } else if (kind === 'script') { + const scripts = await ScriptService.listScripts({ + workspace, + includeDraftOnly: true, + withoutDescription: true + }) + items = scripts.map((s: Script) => ({ + path: s.path, + summary: s.summary ?? '', + kind: 'script' as const + })) + } else { + const apps = await AppService.listApps({ + workspace, + includeDraftOnly: true + }) + items = apps.map((a: ListableApp) => ({ + path: a.path, + summary: a.summary ?? '', + kind: 'app' as const, + raw_app: a.raw_app ?? false + })) + } + // Only commit if the cache version hasn't changed since we started — + // otherwise we'd overwrite a deliberate `invalidate()` with stale data. + if ((cacheVersion.get(key) ?? 0) === startVersion) { + const bucket = cache.get(workspace) ?? {} + bucket[kind] = items + cache.set(workspace, bucket) + } + return items + })() + inflight.set(key, promise) + try { + return await promise + } finally { + inflight.delete(key) + } +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 0911fe44e1..20d1e496ff 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1022,6 +1022,16 @@ export function generateRandomString(len: number = 24): string { return result } +/** `u//` — the default scope prefix for items owned by the + * current user. The username is normalized so emails and special characters + * don't leak into paths. */ +export function userPathPrefix(username: string | undefined): string { + const u = username?.includes('@') + ? username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '') + : (username ?? '') + return `u/${u}/` +} + export function deepMergeWithPriority(target: T, source: T): T { if (typeof target !== 'object' || typeof source !== 'object') { return source diff --git a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte index 471a3c1643..d22875f9b6 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -33,7 +33,6 @@ } | undefined = $state(undefined) let redraw = $state(0) - let path = page.params.path ?? '' let nodraft = page.url.searchParams.get('nodraft') @@ -48,11 +47,17 @@ let stateLoadedFromLocalStorage = initialState != undefined ? decodeState(initialState) : undefined + /** Increments per `loadApp` call. Stale loads (e.g. when picker + * navigation races a draft-discard reload) bail at the next checkpoint + * after their captured token no longer matches. */ + let loadAppToken = 0 async function loadApp(): Promise { + const tok = ++loadAppToken const app_w_draft = await AppService.getAppByPathWithDraft({ - path, + path: page.params.path ?? '', workspace: $workspaceStore! }) + if (tok !== loadAppToken) return const app_w_draft_: AppWithLastVersionWDraft = structuredClone(stateSnapshot(app_w_draft)) savedApp = { summary: app_w_draft_.summary, @@ -160,8 +165,16 @@ } $effect(() => { + // Re-run on workspace OR path change so navigating from one app editor + // to another (e.g. via the workspace picker) reloads the new app. + const newPath = page.params.path if ($workspaceStore) { untrack(() => { + // Clear the app so AppEditor unmounts; it will remount once loadApp + // completes with fresh data, re-initializing its internal stores. + app = undefined + const s = nodraft ? undefined : localStorage.getItem(`app-${newPath}`) + stateLoadedFromLocalStorage = s != undefined ? decodeState(s) : undefined loadApp() }) } diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index b22d1884be..3f9d08b224 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -37,7 +37,6 @@ } | undefined = $state(undefined) let redraw = $state(0) - let path = page.params.path ?? '' let nodraft = page.url.searchParams.get('nodraft') @@ -78,11 +77,17 @@ let stateLoadedFromLocalStorage = initialState != undefined ? decodeState(initialState) : undefined + /** Increments per `loadApp` call. Stale loads (e.g. when picker + * navigation races a draft-discard reload) bail at the next checkpoint + * after their captured token no longer matches. */ + let loadAppToken = 0 async function loadApp(): Promise { + const tok = ++loadAppToken const app_w_draft = await AppService.getAppByPathWithDraft({ - path, + path: page.params.path ?? '', workspace: $workspaceStore! }) + if (tok !== loadAppToken) return const app_w_draft_ = structuredClone(stateSnapshot(app_w_draft)) savedApp = { summary: app_w_draft_.summary, @@ -128,9 +133,7 @@ } sendUserToast('App restored from browser storage', false, actions) app_w_draft.value = stateLoadedFromLocalStorage - const rawValue = app_w_draft.value as any - files = rawValue.files as any - runnables = rawValue.runnables as any + extractRawApp(app_w_draft) redraw += 1 } else if (app_w_draft.draft) { extractRawApp(app_w_draft.draft) @@ -170,7 +173,15 @@ } run(() => { + // Re-run on workspace OR path change so navigating from one raw app editor + // to another (e.g. via the workspace picker) reloads the new app. + const newPath = page.params.path if ($workspaceStore) { + // Clear files so RawAppEditor unmounts; it will remount when loadApp + // completes with fresh data, re-initializing its internal stores. + files = undefined + const s = nodraft ? undefined : localStorage.getItem(`rawapp-${newPath}`) + stateLoadedFromLocalStorage = s != undefined ? decodeState(s) : undefined loadApp() } }) diff --git a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte index 1b8503163e..0fc9c0219d 100644 --- a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte @@ -4,6 +4,7 @@ import { page } from '$app/state' import FlowBuilder from '$lib/components/FlowBuilder.svelte' + import { editPathFor, invalidate } from '$lib/components/workspacePicker' import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' import { importFlowStore, initFlow } from '$lib/components/flows/flowStore.svelte' import { FlowService, type Flow } from '$lib/gen' @@ -179,7 +180,7 @@ await tick() let attempts = 0 while (attempts < 20 && !document.querySelector('#flow-editor-virtual-Input')) { - await new Promise(resolve => setTimeout(resolve, 100)) + await new Promise((resolve) => setTimeout(resolve, 100)) attempts++ } flowBuilder?.triggerTutorial() @@ -193,14 +194,17 @@ { + if ($workspaceStore) invalidate($workspaceStore, 'flow') goto(`/flows/edit/${e.path}?selected=${e.id}`) }} onDeploy={(e) => { + if ($workspaceStore) invalidate($workspaceStore, 'flow') goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) }} onDetails={(e) => { goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) }} + onNavigate={(item) => goto(editPathFor(item))} {initialPath} {pathStoreInit} bind:this={flowBuilder} diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index 7bc082b29d..a0b21703f4 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -2,6 +2,7 @@ import { FlowService, type Flow, DraftService } from '$lib/gen' import FlowBuilder from '$lib/components/FlowBuilder.svelte' + import { editPathFor, invalidate } from '$lib/components/workspacePicker' import { initialArgsStore, workspaceStore } from '$lib/stores' import { cleanValueProperties, @@ -24,10 +25,11 @@ import { page } from '$app/state' let version: undefined | number = $state(undefined) - let nodraft = page.url.searchParams.get('nodraft') - const initialState = nodraft ? undefined : localStorage.getItem(`flow-${page.params.path}`) - let stateLoadedFromUrl = initialState != undefined ? decodeState(initialState) : undefined + // `initialArgs` is captured once at mount — it's the session's initial + // argument set. Per-flow autosave (`stateLoadedFromUrl`) and the + // `nodraft` flag are re-read inside `loadFlow` / the navigation hook so + // picker navigation doesn't reuse the original path's state. const urlArgs = page.url.searchParams.get('initial_args') let initialArgs = $state({}) @@ -45,7 +47,7 @@ | undefined = $state(undefined) afterNavigate(() => { - if (nodraft) { + if (page.url.searchParams.get('nodraft')) { let url = new URL(page.url.href) url.search = '' replaceState(url.toString(), page.state) @@ -72,9 +74,15 @@ let nobackenddraft = false - let savedPrimarySchedule: ScheduleTrigger | undefined = $state( - stateLoadedFromUrl?.primarySchedule - ) + // One-shot read of mount-time autosave, used only to seed the initial + // `savedPrimarySchedule` before `loadFlow` runs. `loadFlow` itself + // re-reads localStorage on every invocation (see comment there). + const initialAutosave = (() => { + if (page.url.searchParams.get('nodraft')) return undefined + const raw = localStorage.getItem(`flow-${page.params.path}`) + return raw != undefined ? decodeState(raw) : undefined + })() + let savedPrimarySchedule: ScheduleTrigger | undefined = $state(initialAutosave?.primarySchedule) let draftTriggersFromUrl: Trigger[] | undefined = $state(undefined) let selectedTriggerIndexFromUrl: number | undefined = $state(undefined) @@ -84,20 +92,36 @@ let flowBuilder: FlowBuilder | undefined = $state(undefined) let notFound = $state(false) + /** Increments per `loadFlow` call. Each in-flight load checks its captured + * token against this before writing shared state — if a newer load started + * (e.g. picker navigation while a draft-discard reload is in flight), + * the older promise no-ops at the next checkpoint. */ + let loadFlowToken = 0 async function loadFlow(): Promise { - console.log('loadFlow') + const tok = ++loadFlowToken loading = true + + // Re-read autosave per load. The component doesn't remount when the + // picker navigates between flows, so capturing at module init would + // keep reusing the original flow's state. + const stored = page.url.searchParams.get('nodraft') + ? undefined + : localStorage.getItem(`flow-${page.params.path}`) + const stateLoadedFromUrl = stored != undefined ? decodeState(stored) : undefined + let flow: Flow let statePath = stateLoadedFromUrl?.path if (stateLoadedFromUrl != undefined && statePath == page.params.path) { // Currently there is no way to get version of flow with flow. // So we have to request it here - version = ( + const v = ( await FlowService.getFlowLatestVersion({ workspace: $workspaceStore!, path: statePath }) )?.id + if (tok !== loadFlowToken) return + version = v if (version == undefined) { notFound = true @@ -105,10 +129,12 @@ return } - savedFlow = await FlowService.getFlowByPathWithDraft({ + const sf = await FlowService.getFlowByPathWithDraft({ workspace: $workspaceStore!, path: statePath }) + if (tok !== loadFlowToken) return + savedFlow = sf const draftOrDeployed = cleanValueProperties(savedFlow?.draft || savedFlow) const urlScript = cleanValueProperties( @@ -126,7 +152,16 @@ flowBuilder?.setLoadedFromHistory(loadedFromHistoryFromUrl) const selectedId = stateLoadedFromUrl?.selectedId ?? 'settings-metadata' const reloadAction = () => { - stateLoadedFromUrl = undefined + // Discard the localStorage autosave so the next `loadFlow` + // (re-)read sees an empty slot and falls through to the + // fetch branch — otherwise we'd re-enter this branch and + // loop. Scripts dodge this because their state lives in + // the URL fragment, which `goto` clears for us. + try { + localStorage.removeItem(`flow-${statePath}`) + } catch (e) { + console.error('error interacting with local storage', e) + } goto(`/flows/edit/${statePath}?selected=${selectedId}`) loadFlow() } @@ -156,17 +191,20 @@ } else { // Currently there is no way to get version of flow with flow. // So we have to request it here - version = ( + const v = ( await FlowService.getFlowLatestVersion({ workspace: $workspaceStore!, path: page.params.path ?? '' }) ).id + if (tok !== loadFlowToken) return + version = v const flowWithDraft = await FlowService.getFlowByPathWithDraft({ workspace: $workspaceStore!, path: page.params.path ?? '' }) + if (tok !== loadFlowToken) return savedFlow = { ...structuredClone($state.snapshot(flowWithDraft)), draft: flowWithDraft.draft @@ -190,7 +228,6 @@ const deployed = cleanValueProperties(flowWithDraft) const draft = cleanValueProperties(flow) const reloadAction = async () => { - stateLoadedFromUrl = undefined await DraftService.deleteDraft({ workspace: $workspaceStore!, kind: 'flow', @@ -226,12 +263,16 @@ } await initFlow(flow, flowStore, flowStateStore) + if (tok !== loadFlowToken) return loading = false selectedId = stateLoadedFromUrl?.selectedId ?? page.url.searchParams.get('selected') flowBuilder?.loadFlowState() } $effect(() => { + // Re-run on workspace OR path change so navigating from one flow editor + // to another (e.g. via the workspace picker) reloads the new flow. + page.params.path if ($workspaceStore) { untrack(() => loadFlow()) } @@ -245,7 +286,6 @@ return } diffDrawer?.closeDrawer() - stateLoadedFromUrl = undefined goto(`/flows/edit/${savedFlow.draft.path}`) loadFlow() } @@ -263,7 +303,6 @@ path: savedFlow.path }) } - stateLoadedFromUrl = undefined goto(`/flows/edit/${savedFlow.path}`) loadFlow() } @@ -280,6 +319,7 @@ {:else} { + if ($workspaceStore) invalidate($workspaceStore, 'flow') goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) }} onDetails={(e) => { @@ -291,6 +331,7 @@ onHistoryRestore={() => { loadFlow() }} + onNavigate={(item) => goto(editPathFor(item))} {flowStore} {flowStateStore} initialPath={page.params.path ?? ''} diff --git a/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte index 8f43dde796..ad2452dce1 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte @@ -4,6 +4,7 @@ import { page } from '$app/state' import { defaultScripts, initialArgsStore, workspaceStore } from '$lib/stores' import ScriptBuilder from '$lib/components/ScriptBuilder.svelte' + import { editPathFor, invalidate } from '$lib/components/workspacePicker' import type { Schema } from '$lib/common' import { decodeState, emptySchema, emptyString, sendUserToast } from '$lib/utils' import { goto } from '$lib/navigation' @@ -63,9 +64,12 @@ schema: schema, is_template: false, extra_perms: {}, - language: (wacParam === 'python' ? 'python3' : wacParam === 'typescript' ? 'bun' : null) ?? collabLang ?? ($defaultScripts?.order?.filter( - (x) => $defaultScripts?.hidden == undefined || !$defaultScripts.hidden.includes(x) - )?.[0] ?? 'bun') as ScriptLang, + language: + (wacParam === 'python' ? 'python3' : wacParam === 'typescript' ? 'bun' : null) ?? + collabLang ?? + (($defaultScripts?.order?.filter( + (x) => $defaultScripts?.hidden == undefined || !$defaultScripts.hidden.includes(x) + )?.[0] ?? 'bun') as ScriptLang), kind: 'script' } } @@ -139,8 +143,7 @@ extra_perms: {} } if (isWac) { - importedWacTemplate = - imported.language === 'python3' ? 'wac_python' : 'wac_typescript' + importedWacTemplate = imported.language === 'python3' ? 'wac_python' : 'wac_typescript' sendUserToast('WAC script loaded from YAML/JSON') } else { sendUserToast('Script loaded from YAML/JSON') @@ -159,13 +162,21 @@ {initialArgs} bind:this={scriptBuilder} lockedLanguage={templatePath != null || hubPath != null} - template={importedWacTemplate ?? (wacParam === 'python' ? 'wac_python' : wacParam === 'typescript' ? 'wac_typescript' : 'script')} + template={importedWacTemplate ?? + (wacParam === 'python' + ? 'wac_python' + : wacParam === 'typescript' + ? 'wac_typescript' + : 'script')} onDeploy={(e) => { + if ($workspaceStore) invalidate($workspaceStore, 'script') goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`) }} onSaveInitial={(e) => { + if ($workspaceStore) invalidate($workspaceStore, 'script') goto(`/scripts/edit/${e.path}`) }} + onNavigate={(item) => goto(editPathFor(item))} searchParams={page.url.searchParams} bind:script {showMeta} diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index 8afaa2ce61..f3002daedc 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -3,6 +3,7 @@ import { initialArgsStore, workspaceStore } from '$lib/stores' import ScriptBuilder from '$lib/components/ScriptBuilder.svelte' + import { editPathFor, invalidate } from '$lib/components/workspacePicker' import { decodeState, cleanValueProperties, orderedJsonStringify } from '$lib/utils' import { goto } from '$lib/navigation' import { replaceState } from '$app/navigation' @@ -15,16 +16,13 @@ import { untrack } from 'svelte' import { page } from '$app/state' - let initialState = window.location.hash != '' ? window.location.hash.slice(1) : undefined + // `initialArgs` is intentionally captured once at mount — it's the + // session's initial argument set, not per-script. URL-derived state + // (`hash`, `topHash`, fragment autosave) is re-read inside `loadScript` + // because picker navigation reuses this component without remounting. let initialArgs = get(initialArgsStore) ?? {} if (get(initialArgsStore)) $initialArgsStore = undefined - let topHash = page.url.searchParams.get('topHash') ?? undefined - - let hash = page.url.searchParams.get('hash') ?? undefined - - let scriptLoadedFromUrl = initialState != undefined ? decodeState(initialState) : undefined - let script: (NewScript & { draft_triggers?: Trigger[] }) | undefined = $state(undefined) let initialPath: string = $state('') @@ -38,21 +36,36 @@ let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined) + /** Increments per `loadScript` call. Stale loads (e.g. when picker + * navigation races a draft-discard reload) bail at the next checkpoint + * after their captured token no longer matches. */ + let loadScriptToken = 0 async function loadScript(): Promise { + const tok = ++loadScriptToken fullyLoaded = false + + // Re-read URL-derived state on every load. The component doesn't + // remount when the picker navigates between scripts, so capturing + // these at module init would leave them stale. + const urlFragment = window.location.hash != '' ? window.location.hash.slice(1) : undefined + const scriptLoadedFromUrl = urlFragment != undefined ? decodeState(urlFragment) : undefined + const hash = page.url.searchParams.get('hash') ?? undefined + const topHash = page.url.searchParams.get('topHash') ?? undefined + if (scriptLoadedFromUrl != undefined && scriptLoadedFromUrl.path == page.params.path) { script = scriptLoadedFromUrl reloadAction = async () => { - scriptLoadedFromUrl = undefined goto(`/scripts/edit/${script!.path}`) loadScript() } async function compareAutosave() { - savedScript = await ScriptService.getScriptByPathWithDraft({ + const sf = await ScriptService.getScriptByPathWithDraft({ workspace: $workspaceStore!, path: script!.path }) + if (tok !== loadScriptToken) return + savedScript = sf const draftOrDeployed = cleanValueProperties(savedScript?.draft || savedScript) const urlScript = cleanValueProperties(scriptLoadedFromUrl) @@ -87,6 +100,7 @@ workspace: $workspaceStore!, hash }) + if (tok !== loadScriptToken) return savedScript = structuredClone($state.snapshot(scriptByHash)) as NewScriptWithDraft script = { ...scriptByHash, parent_hash: hash, lock: undefined } } else { @@ -94,6 +108,7 @@ workspace: $workspaceStore!, path: page.params.path ?? '' }) + if (tok !== loadScriptToken) return savedScript = structuredClone($state.snapshot(scriptWithDraft)) if (scriptWithDraft.draft != undefined) { script = scriptWithDraft.draft @@ -105,7 +120,6 @@ if (!scriptWithDraft.draft_only) { reloadAction = async () => { - scriptLoadedFromUrl = undefined await DraftService.deleteDraft({ workspace: $workspaceStore!, kind: 'script', @@ -164,6 +178,9 @@ } $effect(() => { + // Re-run on workspace OR path change so navigating from one script editor + // to another (e.g. via the workspace picker) reloads the new script. + page.params.path if ($workspaceStore) { untrack(() => loadScript()) } @@ -178,7 +195,6 @@ } diffDrawer?.closeDrawer() goto(`/scripts/edit/${savedScript.draft.path}`) - scriptLoadedFromUrl = undefined loadScript() } @@ -196,7 +212,6 @@ }) } goto(`/scripts/edit/${savedScript.path}`) - scriptLoadedFromUrl = undefined loadScript() } @@ -214,14 +229,17 @@ {savedPrimarySchedule} searchParams={page.url.searchParams} onDeploy={(e) => { + if ($workspaceStore) invalidate($workspaceStore, 'script') goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`) }} onSaveInitial={(e) => { + if ($workspaceStore) invalidate($workspaceStore, 'script') goto(`/scripts/edit/${e.path}`) }} onSeeDetails={(e) => { goto(`/scripts/get/${e.path}?workspace=${$workspaceStore}`) }} + onNavigate={(item) => goto(editPathFor(item))} replaceStateFn={(path) => { replaceState(path, page.state) }} From 7ebb08133cd4027bc00bacc4a0fc5865cd5709ec Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 13 May 2026 06:38:57 +0000 Subject: [PATCH 13/40] fix(operator): refresh IAM RDS / Entra ID tokens in operator process (#9141) * fix(operator): refresh IAM RDS / Entra ID tokens in operator process * fix: gate DEFAULT_MAX_CONNECTIONS_OPERATOR on operator feature --- backend/src/db_connect.rs | 135 +++++++++++++++++++++++--------------- backend/src/main.rs | 19 +++++- 2 files changed, 99 insertions(+), 55 deletions(-) diff --git a/backend/src/db_connect.rs b/backend/src/db_connect.rs index 3860c18909..deda032058 100644 --- a/backend/src/db_connect.rs +++ b/backend/src/db_connect.rs @@ -6,6 +6,8 @@ use windmill_common::{ pub const DEFAULT_MAX_CONNECTIONS_SERVER: u32 = 50; pub const DEFAULT_MAX_CONNECTIONS_WORKER: u32 = 5; pub const DEFAULT_MAX_CONNECTIONS_INDEXER: u32 = 5; +#[cfg(feature = "operator")] +pub const DEFAULT_MAX_CONNECTIONS_OPERATOR: u32 = 2; pub async fn initial_connection() -> Result, error::Error> { let connect_options = get_database_url().await?.connect_options().await?; @@ -16,12 +18,35 @@ pub async fn initial_connection() -> Result, error::E .map_err(|err| Error::ConnectingToDatabase(err.to_string())) } +/// Connect to the database for the Kubernetes operator process. +/// +/// Long-running operator pods need IAM RDS / Entra ID token refresh just like the server, +/// otherwise new pool connections start failing once the initial token expires (~15 min). +#[cfg(feature = "operator")] +pub async fn operator_connection( + #[cfg(all(feature = "enterprise", feature = "private"))] + killpill_rx: tokio::sync::broadcast::Receiver<()>, +) -> anyhow::Result> { + let database_url = get_database_url().await?; + let pool = connect( + database_url.clone(), + DEFAULT_MAX_CONNECTIONS_OPERATOR, + false, + ) + .await?; + + #[cfg(all(feature = "enterprise", feature = "private"))] + spawn_token_refresh_task(pool.clone(), database_url, killpill_rx); + + Ok(pool) +} + pub async fn connect_db( server_mode: bool, indexer_mode: bool, worker_mode: bool, num_workers: i32, - #[cfg(feature = "private")] mut killpill_rx: tokio::sync::broadcast::Receiver<()>, + #[cfg(feature = "private")] killpill_rx: tokio::sync::broadcast::Receiver<()>, ) -> anyhow::Result> { use anyhow::Context; @@ -43,70 +68,72 @@ pub async fn connect_db( let pool = connect(database_url.clone(), max_connections, worker_mode).await?; #[cfg(all(feature = "enterprise", feature = "private"))] - { - let needs_token_refresh = matches!( - database_url, - DatabaseUrl::IamRds(_) | DatabaseUrl::EntraId(_) - ); - let label = match &database_url { - DatabaseUrl::IamRds(_) => "IAM RDS", - DatabaseUrl::EntraId(_) => "Entra ID", - DatabaseUrl::Static(_) => "", - }; - if needs_token_refresh { - let pool2 = pool.clone(); - let database_url2 = database_url.clone(); - tokio::spawn(async move { - loop { - tokio::select! { - _ = killpill_rx.recv() => { - break; - } - _ = tokio::time::sleep(std::time::Duration::from_secs(10)) => { - if !database_url2.needs_refresh().await { - continue; - } - let new_url = tokio::time::timeout( - std::time::Duration::from_secs(10), - get_database_url(), - ) - .await; - match new_url { - Ok(Ok(new_url)) => { - match new_url.connect_options().await { - Ok(connect_options) => { - pool2.set_connect_options(connect_options); - tracing::info!("Refreshed {label} URL successfully"); - } - Err(e) => { - tracing::error!( - "Error getting {label} connect options, retrying in 10s: {e}" - ); - continue; - } - } - } - Ok(Err(e)) => { - tracing::error!( - "Error refreshing {label} URL, trying again in 10s: {e}" - ); - continue; + spawn_token_refresh_task(pool.clone(), database_url, killpill_rx); + + Ok(pool) +} + +/// Spawn a background task that refreshes IAM RDS / Entra ID tokens before they expire +/// and updates the pool's connect options so new connections use the fresh token. +/// No-op for static (password-based) database URLs. +#[cfg(all(feature = "enterprise", feature = "private"))] +pub fn spawn_token_refresh_task( + pool: sqlx::Pool, + database_url: DatabaseUrl, + mut killpill_rx: tokio::sync::broadcast::Receiver<()>, +) { + let label = match &database_url { + DatabaseUrl::IamRds(_) => "IAM RDS", + DatabaseUrl::EntraId(_) => "Entra ID", + DatabaseUrl::Static(_) => return, + }; + tokio::spawn(async move { + loop { + tokio::select! { + _ = killpill_rx.recv() => { + break; + } + _ = tokio::time::sleep(std::time::Duration::from_secs(10)) => { + if !database_url.needs_refresh().await { + continue; + } + let new_url = tokio::time::timeout( + std::time::Duration::from_secs(10), + get_database_url(), + ) + .await; + match new_url { + Ok(Ok(new_url)) => { + match new_url.connect_options().await { + Ok(connect_options) => { + pool.set_connect_options(connect_options); + tracing::info!("Refreshed {label} URL successfully"); } Err(e) => { tracing::error!( - "Timeout after 10s refreshing {label} URL, trying again in 10s: {e}" + "Error getting {label} connect options, retrying in 10s: {e}" ); continue; } } } + Ok(Err(e)) => { + tracing::error!( + "Error refreshing {label} URL, trying again in 10s: {e}" + ); + continue; + } + Err(e) => { + tracing::error!( + "Timeout after 10s refreshing {label} URL, trying again in 10s: {e}" + ); + continue; + } } } - }); + } } - } - - Ok(pool) + }); } pub async fn connect( diff --git a/backend/src/main.rs b/backend/src/main.rs index 9a2709e9a9..74e2c240ba 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -670,7 +670,24 @@ async fn windmill_main() -> anyhow::Result<()> { tracing_subscriber::fmt::init(); tracing::info!("Starting Windmill Kubernetes operator..."); tracing::info!("Connecting to database..."); - let db = crate::db_connect::initial_connection().await?; + + #[cfg(all(feature = "enterprise", feature = "private"))] + let (operator_killpill_tx, operator_killpill_rx) = + tokio::sync::broadcast::channel::<()>(2); + + let db = crate::db_connect::operator_connection( + #[cfg(all(feature = "enterprise", feature = "private"))] + operator_killpill_rx, + ) + .await?; + + #[cfg(all(feature = "enterprise", feature = "private"))] + tokio::spawn(async move { + if let Ok(()) = tokio::signal::ctrl_c().await { + let _ = operator_killpill_tx.send(()); + } + }); + tracing::info!("Database connected. Starting ConfigMap watcher..."); windmill_operator::run(db).await?; return Ok(()); From 79c5b7b8b7676b0a06fa6480dd04b7105d39d250 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 13 May 2026 07:09:45 +0000 Subject: [PATCH 14/40] fix(cli): prevent !inline-corruption in flow push/pull (#9142) * fix(cli): hard-fail flow push on missing inline files; guard extractor * fix(cli): gate !inline extractor guard with opt-in flag * test(cli): fix createFlowFixture !inline path to be relative to flow folder --- cli/src/commands/flow/flow.ts | 12 ++-- cli/src/commands/flow/flow_metadata.ts | 30 +++++++-- cli/src/commands/sync/sync.ts | 6 +- ..._scripts_failure_preprocessor_unit.test.ts | 65 +++++++++++++++++++ cli/test/sync_pull_push.test.ts | 8 ++- .../src/inline-scripts/extractor.ts | 47 +++++++++++--- 6 files changed, 145 insertions(+), 23 deletions(-) diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index efd05c376a..b76b890053 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -174,10 +174,14 @@ export async function pushFlow( await replaceInlineScripts([localFlow.value.preprocessor_module], fileReader, log, localPath, SEP, undefined, missingFiles); } if (missingFiles.length > 0) { - log.warn(colors.yellow( - `Warning: missing inline script file(s): ${missingFiles.join(", ")}. ` + - `The flow will be pushed with unresolved !inline references.` - )); + // Hard-fail rather than push the literal `!inline path` text as + // rawscript.content. That string would be persisted in flow_version.value + // and round-trip as the script body on the next pull, overwriting the + // user's local handler with the directive — see GIT-871 / #9140. + throw new Error( + `Cannot push flow ${remotePath}: missing inline script file(s): ${missingFiles.join(", ")}. ` + + `Either restore the file(s) or remove the !inline reference(s) from flow.yaml before pushing.` + ); } const hasOnBehalfOf = (localFlow as any).has_on_behalf_of ?? !!localFlow.on_behalf_of_email; diff --git a/cli/src/commands/flow/flow_metadata.ts b/cli/src/commands/flow/flow_metadata.ts index f33ef07bcc..5c3ca578c7 100644 --- a/cli/src/commands/flow/flow_metadata.ts +++ b/cli/src/commands/flow/flow_metadata.ts @@ -266,19 +266,32 @@ export async function generateFlowLockInternal( return tree.isStale(treePath); }) : changedScripts; + const missingFiles: string[] = []; await replaceInlineScripts( flowValue.value.modules, fileReader, log, folder + SEP!, SEP, - locksToRemove + locksToRemove, + missingFiles ); if (flowValue.value.failure_module) { - await replaceInlineScripts([flowValue.value.failure_module], fileReader, log, folder + SEP!, SEP, locksToRemove); + await replaceInlineScripts([flowValue.value.failure_module], fileReader, log, folder + SEP!, SEP, locksToRemove, missingFiles); } if (flowValue.value.preprocessor_module) { - await replaceInlineScripts([flowValue.value.preprocessor_module], fileReader, log, folder + SEP!, SEP, locksToRemove); + await replaceInlineScripts([flowValue.value.preprocessor_module], fileReader, log, folder + SEP!, SEP, locksToRemove, missingFiles); + } + if (missingFiles.length > 0) { + // Abort before updateFlow rather than push the literal `!inline path` + // string as rawscript.content (GIT-871 / #9140). Note: at this point + // replaceInlineScripts has already mutated `flowValue.value` in place + // for the modules that *did* resolve. All current callers re-throw on + // this error; do not catch and reuse `flowValue` without re-parsing. + throw new Error( + `Cannot regenerate lock for flow ${remote_path}: missing inline script file(s): ${missingFiles.join(", ")}. ` + + `Either restore the file(s) or remove the !inline reference(s) from flow.yaml before retrying.` + ); } //removeChangedLocks @@ -304,18 +317,23 @@ export async function generateFlowLockInternal( const lockAssigner = newPathAssigner(opts.defaultTs ?? "bun", { skipInlineScriptSuffix: getNonDottedPaths(), }); + // flowValue.value here is the backend's response from updateFlow, so a + // rawscript whose content is `!inline ...` is corruption (GIT-871) — fail + // fast rather than writing the literal directive back to a script file. + const extractOpts = { skipInlineScriptSuffix: getNonDottedPaths(), failOnInlineDirective: true }; const inlineScripts = extractInlineScriptsForFlows( flowValue.value.modules, currentMapping, SEP, opts.defaultTs, - lockAssigner + lockAssigner, + extractOpts ); if (flowValue.value.failure_module) { - inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.failure_module], currentMapping, SEP, opts.defaultTs, lockAssigner)); + inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.failure_module], currentMapping, SEP, opts.defaultTs, lockAssigner, extractOpts)); } if (flowValue.value.preprocessor_module) { - inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.preprocessor_module], currentMapping, SEP, opts.defaultTs, lockAssigner)); + inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.preprocessor_module], currentMapping, SEP, opts.defaultTs, lockAssigner, extractOpts)); } inlineScripts.forEach((s) => { writeIfChanged(process.cwd() + SEP + folder + SEP + s.path, s.content); diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 6cd3545c5d..215a0d3120 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -950,7 +950,7 @@ function ZipFSElement( SEP, defaultTs, assigner, - { skipInlineScriptSuffix: getNonDottedPaths() }, + { skipInlineScriptSuffix: getNonDottedPaths(), failOnInlineDirective: true }, ); if (flow.value.failure_module) { inlineScripts.push(...extractInlineScriptsForFlows( @@ -959,7 +959,7 @@ function ZipFSElement( SEP, defaultTs, assigner, - { skipInlineScriptSuffix: getNonDottedPaths() }, + { skipInlineScriptSuffix: getNonDottedPaths(), failOnInlineDirective: true }, )); } if (flow.value.preprocessor_module) { @@ -969,7 +969,7 @@ function ZipFSElement( SEP, defaultTs, assigner, - { skipInlineScriptSuffix: getNonDottedPaths() }, + { skipInlineScriptSuffix: getNonDottedPaths(), failOnInlineDirective: true }, )); } } catch (error) { diff --git a/cli/test/inline_scripts_failure_preprocessor_unit.test.ts b/cli/test/inline_scripts_failure_preprocessor_unit.test.ts index 78af37a02e..a5d1298f15 100644 --- a/cli/test/inline_scripts_failure_preprocessor_unit.test.ts +++ b/cli/test/inline_scripts_failure_preprocessor_unit.test.ts @@ -574,3 +574,68 @@ describe("extractInlineScripts with mapping preserves file paths", () => { expect(lockScript!.path).toBe("my.inline_script.lock"); }); }); + +// --------------------------------------------------------------------------- +// failOnInlineDirective option (GIT-871 / #9140) +// --------------------------------------------------------------------------- + +describe("failOnInlineDirective option", () => { + test("default behavior: yaml-parsed module with !inline content extracts without throwing", () => { + // Simulates flow_metadata / dev callers: yaml-parsed local flow whose + // rawscript.content is the literal `!inline foo.ts` directive (the + // legitimate on-disk shape after extraction). + const mod = makeRawscriptModule("a", "!inline a.inline_script.ts", "bun"); + expect(() => + extractInlineScripts([mod], {}, "/", "bun"), + ).not.toThrow(); + }); + + test("yaml-parsed !inline content round-trips as the script's body", () => { + const mod = makeRawscriptModule("a", "!inline a.inline_script.ts", "bun"); + const scripts = extractInlineScripts([mod], {}, "/", "bun"); + const script = scripts.find((s) => !s.is_lock); + expect(script).toBeDefined(); + expect(script!.content).toBe("!inline a.inline_script.ts"); + }); + + test("opt-in: failOnInlineDirective=true throws on !inline content", () => { + // Simulates the sync-pull call site: rawscript came from the backend's + // flow_version.value, so `!inline ...` content means the row is corrupt. + const mod = makeRawscriptModule("failure", "!inline Handle_error.ts", "bun"); + expect(() => + extractInlineScripts([mod], {}, "/", "bun", undefined, { + failOnInlineDirective: true, + }), + ).toThrow(/corrupted inline script/); + }); + + test("opt-in: real script content still extracts cleanly", () => { + const mod = makeRawscriptModule( + "failure", + 'export function main() { return 1; }', + "bun", + ); + expect(() => + extractInlineScripts([mod], {}, "/", "bun", undefined, { + failOnInlineDirective: true, + }), + ).not.toThrow(); + }); + + test("opt-in: throws for nested rawscript inside branchall", () => { + const inner = makeRawscriptModule("inner", "!inline poisoned.ts", "bun"); + const outer: FlowModule = { + id: "branch", + value: { + type: "branchall" as const, + branches: [{ summary: "b1", expr: "true", modules: [inner], skip_failure: false, parallel: false }], + parallel: false, + }, + }; + expect(() => + extractInlineScripts([outer], {}, "/", "bun", undefined, { + failOnInlineDirective: true, + }), + ).toThrow(/corrupted inline script/); + }); +}); diff --git a/cli/test/sync_pull_push.test.ts b/cli/test/sync_pull_push.test.ts index e31c4e1084..4b5b8e5389 100644 --- a/cli/test/sync_pull_push.test.ts +++ b/cli/test/sync_pull_push.test.ts @@ -111,6 +111,10 @@ kind: script function createFlowFixture(name: string): Record { const flowSuffix = getFolderSuffix("flow"); const metadataFile = getMetadataFileName("flow", "yaml"); + // !inline paths are resolved relative to the flow folder (see + // pushFlow's fileReader in cli/src/commands/flow/flow.ts), so the + // path inside the directive must NOT include the flow folder prefix. + const scriptFile = "a.ts"; return { metadata: { @@ -122,7 +126,7 @@ value: - id: a value: type: rawscript - content: "!inline ${name}${flowSuffix}/a.ts" + content: "!inline ${scriptFile}" language: bun input_transforms: {} schema: @@ -133,7 +137,7 @@ schema: `, }, inlineScript: { - path: `${name}${flowSuffix}/a.ts`, + path: `${name}${flowSuffix}/${scriptFile}`, content: `export async function main() {\n return "Hello from flow ${name}";\n}`, }, }; diff --git a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts index e472372a99..f556b32c57 100644 --- a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts +++ b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts @@ -20,13 +20,28 @@ function extractRawscriptInline( rawscript: RawScript, mapping: Record, separator: string, - assigner: PathAssigner + assigner: PathAssigner, + failOnInlineDirective: boolean ): InlineScript[] { const [basePath, ext] = assigner.assignPath(summary ?? id, rawscript.language); const mappedPath = mapping[id]; const path = mappedPath ?? basePath + ext; const language = rawscript.language; const content = rawscript.content; + // Opt-in defensive guard: when extracting from backend-shaped data (i.e. + // sync pull), a rawscript whose content is itself an `!inline ...` directive + // means the backend was poisoned by a prior push that sent the unresolved + // directive as the script body (GIT-871 / #9140). Refuse to write it back + // to disk. Off by default because callers that operate on YAML-parsed local + // flows (flow_metadata, dev) legitimately see `!inline foo.ts` as content. + if (failOnInlineDirective && typeof content === "string" && content.startsWith("!inline ")) { + throw new Error( + `Refusing to extract corrupted inline script for module '${id}': ` + + `rawscript.content is the literal string \`${content.split("\n")[0]}\` ` + + `instead of script source. The backend's flow_version.value is corrupt — ` + + `re-push from a known-good local copy to repair it.` + ); + } const r = [{ path: path, content: content, language, is_lock: false}]; rawscript.content = "!inline " + path.replaceAll(separator, "/"); const lock = rawscript.lock; @@ -50,6 +65,15 @@ function extractRawscriptInline( export interface ExtractInlineScriptsOptions { /** When true, skip the .inline_script. suffix in file names */ skipInlineScriptSuffix?: boolean; + /** + * When true, throw if a `rawscript.content` is itself an `!inline ...` + * directive. Set this only at the sync-pull call site, where the input + * comes from the backend's `flow_version.value` and `!inline ...` content + * means the row is corrupt (GIT-871 / #9140). Leave off for callers that + * pass YAML-parsed local flows — the directive is the legitimate on-disk + * shape there. + */ + failOnInlineDirective?: boolean; } /** @@ -74,6 +98,7 @@ export function extractInlineScripts( ): InlineScript[] { // Create pathAssigner only if not provided (top-level call), but reuse it for nested calls const assigner = pathAssigner ?? newPathAssigner(defaultTs ?? "bun", { skipInlineScriptSuffix: options?.skipInlineScriptSuffix }); + const failOnInlineDirective = options?.failOnInlineDirective ?? false; return modules.flatMap((m) => { if (m.value.type == "rawscript") { @@ -83,7 +108,8 @@ export function extractInlineScripts( m.value, mapping, separator, - assigner + assigner, + failOnInlineDirective ); } else if (m.value.type == "forloopflow") { return extractInlineScripts( @@ -91,11 +117,12 @@ export function extractInlineScripts( mapping, separator, defaultTs, - assigner + assigner, + options ); } else if (m.value.type == "branchall") { return m.value.branches.flatMap((b) => - extractInlineScripts(b.modules, mapping, separator, defaultTs, assigner) + extractInlineScripts(b.modules, mapping, separator, defaultTs, assigner, options) ); } else if (m.value.type == "whileloopflow") { return extractInlineScripts( @@ -103,7 +130,8 @@ export function extractInlineScripts( mapping, separator, defaultTs, - assigner + assigner, + options ); } else if (m.value.type == "branchone") { return [ @@ -113,7 +141,8 @@ export function extractInlineScripts( mapping, separator, defaultTs, - assigner + assigner, + options ) ), ...extractInlineScripts( @@ -121,7 +150,8 @@ export function extractInlineScripts( mapping, separator, defaultTs, - assigner + assigner, + options ), ]; } else if (m.value.type == "aiagent") { @@ -138,7 +168,8 @@ export function extractInlineScripts( toolValue, mapping, separator, - assigner + assigner, + failOnInlineDirective ); }); } else { From b348119ab9c76e3d80102d1e62d4037bdf901594 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 13 May 2026 07:11:24 +0000 Subject: [PATCH 15/40] publish CLI skills + AGENTS.md to windmill-cli-docs for context7 (#9143) * feat: publish CLI skills + AGENTS.md to windmill-cli-docs for context7 Auto-generates a public docs snapshot (AGENTS.md, full CLI reference, all rendered skills) and pushes it to windmill-labs/windmill-cli-docs on every release tag, so context7 can index Windmill CLI docs. - generate.py: new --context7-dir flag rendering fully-resolved skills + AGENTS.md (extracted from cli/src/guidance/core.ts to avoid drift) + cli-commands.md + README.md + manifest.json into a docs-repo checkout. Preserves .git, .github, LICENSE, context7.json across regenerations. - publish-cli-docs.yml: GitHub Action on v* tag and workflow_dispatch that regenerates the docs repo and pushes via the CLI_DOCS_DEPLOY_KEY SSH deploy key. * fix: skip tag mirror on workflow_dispatch from non-tag ref * docs: turn windmill-cli-docs README into a CLI quickstart * fix: address PR review (target safety, regex anchor, concurrency, tag mirror) - Refuse to wipe --context7-dir unless empty, has a context7 marker, or points at the windmill-cli-docs remote (P1, prevents typo blast). - Anchor AGENTS.md template regex on `generateAgentsMdContent` so adding other template-returning functions to core.ts can't silently retarget it. - Decode TS escapes in one pass to avoid order-sensitive mangling. - Include Windmill version (from version.txt) in manifest.json so each snapshot is self-describing. - Add concurrency group on the publish workflow. - Always mirror version tag on tag pushes, even when content is unchanged, so the docs repo has a tag for every Windmill release. - Expand preserve list with .gitignore, .gitattributes, CODEOWNERS. * fix: validate manifest.json content, not just presence, before wipe --- .github/workflows/publish-cli-docs.yml | 84 +++++ system_prompts/README.md | 15 + system_prompts/generate.py | 404 +++++++++++++++++++++++++ 3 files changed, 503 insertions(+) create mode 100644 .github/workflows/publish-cli-docs.yml diff --git a/.github/workflows/publish-cli-docs.yml b/.github/workflows/publish-cli-docs.yml new file mode 100644 index 0000000000..9e76117eb5 --- /dev/null +++ b/.github/workflows/publish-cli-docs.yml @@ -0,0 +1,84 @@ +name: Publish CLI docs repo + +# Regenerates the windmill-cli-docs repo (consumed by context7) from the +# canonical sources in this repo on every Windmill release. +# +# Required secret: +# CLI_DOCS_DEPLOY_KEY — ed25519 private key whose public half is registered +# as a write-access deploy key on +# windmill-labs/windmill-cli-docs. + +on: + push: + tags: + - "v*" + workflow_dispatch: + +# Serialize pushes to windmill-cli-docs so two release tags landing close +# together (e.g. a release-please bump + a hotfix) can't race to force-push +# the docs repo. +concurrency: + group: publish-cli-docs + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout windmill (source of truth) + uses: actions/checkout@v4 + with: + path: windmill + + - name: Checkout windmill-cli-docs (publish target) + uses: actions/checkout@v4 + with: + repository: windmill-labs/windmill-cli-docs + path: windmill-cli-docs + ssh-key: ${{ secrets.CLI_DOCS_DEPLOY_KEY }} + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install pyyaml + + - name: Regenerate docs + run: | + python3 windmill/system_prompts/generate.py \ + --context7-dir "$GITHUB_WORKSPACE/windmill-cli-docs" + + - name: Commit and push if changed + working-directory: windmill-cli-docs + env: + REF_NAME: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} + run: | + git config user.name "windmill-bot" + git config user.email "bot@windmill.dev" + git add -A + if git diff --cached --quiet; then + echo "No doc changes for ${REF_NAME}." + committed=false + else + committed=true + if [ "${REF_TYPE}" = "tag" ]; then + git commit -m "chore: sync from windmill ${REF_NAME}" + else + git commit -m "chore: sync from windmill (manual dispatch from ${REF_NAME})" + fi + git push origin HEAD + fi + # Always mirror the version tag on tag pushes, even when content + # didn't change — downstream consumers tie snapshots to releases by + # tag, and skipping it would leave the docs repo without a tag for + # the new Windmill release. + # workflow_dispatch from a non-tag ref skips this so we don't + # create a junk tag named after a branch. + if [ "${REF_TYPE}" = "tag" ]; then + git tag -f "${REF_NAME}" + git push origin "${REF_NAME}" --force + echo "Mirrored tag ${REF_NAME} to windmill-cli-docs (content changed: ${committed})." + fi diff --git a/system_prompts/README.md b/system_prompts/README.md index cd3a123b6a..cc9449f93e 100644 --- a/system_prompts/README.md +++ b/system_prompts/README.md @@ -38,6 +38,21 @@ python system_prompts/generate.py --plugin-dir ~/windmill-claude-plugin - a plugin root such as `plugins/windmill-code-plugin` - a direct `skills/` directory +To regenerate the public docs repo (consumed by context7): + +```bash +python system_prompts/generate.py --context7-dir ~/windmill-cli-docs +``` + +`--context7-dir` writes a fully-rendered snapshot (`AGENTS.md`, +`cli-commands.md`, `skills//SKILL.md`, `README.md`, `manifest.json` +with the Windmill `version`) with all template placeholders resolved — +suitable for ingestion by docs aggregators. In CI this runs from +`.github/workflows/publish-cli-docs.yml` on every release tag. The +generator refuses to wipe the target directory unless it's empty or has +a context7 marker (`context7.json`, `manifest.json`, or a +`windmill-cli-docs` git remote), so a typo can't delete unrelated files. + This will: 1. Parse TypeScript and Python SDK files to extract function signatures diff --git a/system_prompts/generate.py b/system_prompts/generate.py index 87f0b63576..92090d525e 100644 --- a/system_prompts/generate.py +++ b/system_prompts/generate.py @@ -12,6 +12,7 @@ This script: Usage: python generate.py python generate.py --plugin-dir /path/to/windmill-claude-plugin + python generate.py --context7-dir /path/to/windmill-cli-docs """ import argparse @@ -1778,6 +1779,395 @@ def generate_plugin_skills( return skills_dir +# ============================================================================= +# Context7 Docs Repo Generation +# ============================================================================= + +# Files in the context7 target directory that must survive a regeneration +# (everything else is wiped to keep the export deterministic). +CONTEXT7_PRESERVE = frozenset( + { + ".git", + ".github", + ".gitignore", + ".gitattributes", + "CODEOWNERS", + "LICENSE", + "LICENSE.md", + "context7.json", + } +) + +# Name written into manifest.json — also used to recognise the docs repo +# when re-generating into an existing checkout. +CONTEXT7_REPO_NAME = "windmill-cli-docs" + + +def extract_agents_md_template() -> str: + """Extract the AGENTS.md template string from cli/src/guidance/core.ts. + + Keeping a single source of truth in TypeScript avoids drift between what + `wmill init` writes locally and what we publish for context7 ingestion. + """ + core_ts_path = SCRIPT_DIR.parent / "cli" / "src" / "guidance" / "core.ts" + content = core_ts_path.read_text() + # Anchor on the function name so adding other template-literal-returning + # functions to core.ts can't silently re-target the regex. + match = re.search( + r"function\s+generateAgentsMdContent\b[\s\S]*?return\s+`([\s\S]*?)`;", + content, + ) + if not match: + raise RuntimeError( + f"Could not extract AGENTS.md template from {core_ts_path}" + ) + return _unescape_ts_template_literal(match.group(1)) + + +def _unescape_ts_template_literal(raw: str) -> str: + """Decode TS template-literal escapes in one pass. + + Multi-pass `.replace()` would mangle e.g. `\\\\` -> `\\` -> `` ` `` if the + template ever contained a literal backslash followed by a backtick. A + single-pass scan is order-independent. + """ + return re.sub( + r"\\(.)", + lambda m: {"`": "`", "$": "$", "\\": "\\"}.get(m.group(1), m.group(0)), + raw, + ) + + +def render_agents_md_for_docs( + skills: list[str], skill_desc_map: dict[str, str] +) -> str: + """Render AGENTS.md exactly as `wmill init` would, for the docs repo.""" + template = extract_agents_md_template() + skills_reference = "\n".join( + f"- `.claude/skills/{name}/SKILL.md` - {skill_desc_map[name]}" + for name in skills + if name in skill_desc_map + ) + return template.replace("${skillsReference}", skills_reference) + + +def build_skill_desc_map(skills: list[str]) -> dict[str, str]: + """Map each skill name to its user-facing description. + + Mirrors the logic in `generate_skills_ts_export`: language skills draw from + LANGUAGE_METADATA, everything else from SKILL_DEFINITIONS. + """ + desc_map = {s["name"]: s["description"] for s in SKILL_DEFINITIONS} + for skill in skills: + if skill.startswith("write-script-"): + lang_key = skill.replace("write-script-", "") + metadata = LANGUAGE_METADATA.get(lang_key) + if metadata: + desc_map[skill] = metadata["description"] + return desc_map + + +def _looks_like_windmill_manifest(path: Path) -> bool: + """Return True iff `path` is a JSON file whose top-level `name` is ours. + + Used to distinguish a previously-generated docs repo from an unrelated + project that happens to have a `manifest.json` (Chrome extensions, npm + packages, web app manifests, etc.). + """ + try: + data = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return False + return isinstance(data, dict) and data.get("name") == CONTEXT7_REPO_NAME + + +def _verify_context7_target(target_dir: Path) -> None: + """Refuse to wipe a non-empty dir that doesn't look like the docs repo. + + A typo such as `--context7-dir .`, `~`, or the wrong checkout could + otherwise nuke unrelated files. We accept the target if it's empty/new, + if it has our ownership file, if its `manifest.json` self-identifies as + the windmill-cli-docs repo, or if its git origin points at one. + """ + if not target_dir.exists() or not any(target_dir.iterdir()): + return + + if (target_dir / "context7.json").exists(): + return + + manifest_path = target_dir / "manifest.json" + if manifest_path.exists() and _looks_like_windmill_manifest(manifest_path): + return + + git_dir = target_dir / ".git" + if git_dir.exists(): + import subprocess + + try: + origin = subprocess.run( + ["git", "-C", str(target_dir), "config", "--get", "remote.origin.url"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + if CONTEXT7_REPO_NAME in origin: + return + except subprocess.CalledProcessError: + pass + + raise RuntimeError( + f"Refusing to overwrite {target_dir}: target does not look like the " + f"{CONTEXT7_REPO_NAME} docs repo.\n" + f"Expected one of:\n" + f" - a `context7.json` at the top level,\n" + f" - a `manifest.json` whose top-level `name` is {CONTEXT7_REPO_NAME!r},\n" + f" - a git remote `origin` containing '{CONTEXT7_REPO_NAME}'.\n" + f"If this is the right directory, add a `context7.json` and retry." + ) + + +def clear_context7_dir(target_dir: Path) -> None: + """Wipe the docs repo dir of previously generated content. + + Preserves a small allowlist (.git, .github, LICENSE, context7.json, etc.) + so this can run against a real checkout without nuking version control or + CI config. + """ + if not target_dir.exists(): + return + for entry in target_dir.iterdir(): + if entry.name in CONTEXT7_PRESERVE: + continue + if entry.is_dir(): + shutil.rmtree(entry) + else: + entry.unlink() + + +def _read_windmill_version() -> str | None: + """Return the Windmill release version (e.g. '1.700.2'), or None if absent. + + Sourced from `version.txt` at the repo root — the same file release-please + updates on every release. + """ + version_file = SCRIPT_DIR.parent / "version.txt" + if not version_file.exists(): + return None + return version_file.read_text().strip() or None + + +def generate_context7_repo( + target_dir: Path, + skills: list[str], + schema_yaml_content: dict[str, str], + cli_commands_md: str, +) -> Path: + """Generate a fully-rendered docs repo suitable for context7 ingestion. + + Layout written to `target_dir`: + AGENTS.md # the prompt agents see in their projects + README.md # stable intro for humans / context7 + manifest.json # version + skill list (for indexing) + cli-commands.md # full CLI flag reference + skills//SKILL.md # one rendered skill per file + """ + target_dir = target_dir.expanduser().resolve() + target_dir.mkdir(parents=True, exist_ok=True) + _verify_context7_target(target_dir) + clear_context7_dir(target_dir) + + skill_desc_map = build_skill_desc_map(skills) + + # AGENTS.md — the same content `wmill init` writes locally. + (target_dir / "AGENTS.md").write_text( + render_agents_md_for_docs(skills, skill_desc_map) + ) + + # Full CLI reference at top level. + (target_dir / "cli-commands.md").write_text(cli_commands_md) + + # One markdown per skill, with schemas inlined (no template placeholders). + skills_dir = target_dir / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + for skill_name in skills: + skill_dir = skills_dir / skill_name + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text( + render_plugin_skill_content(skill_name, schema_yaml_content) + ) + + # Stable README so the GitHub repo landing page tells readers (and + # context7's crawler) what they're looking at. + (target_dir / "README.md").write_text(_context7_readme(skills)) + + # Machine-readable index for context7 / downstream consumers. + # Note: the `name` field is also the marker `_verify_context7_target` + # uses to distinguish our `manifest.json` from generic ones. + manifest = { + "name": CONTEXT7_REPO_NAME, + "description": ( + "Auto-generated Windmill CLI docs: agent prompt, skills, and " + "full CLI reference. Source: github.com/windmill-labs/windmill." + ), + "skills": [ + {"name": name, "description": skill_desc_map.get(name, "")} + for name in skills + ], + } + version = _read_windmill_version() + if version: + manifest["version"] = version + (target_dir / "manifest.json").write_text( + json.dumps(manifest, indent=2) + "\n" + ) + + print(f"\nGenerated for context7 docs repo:") + print(f" - {target_dir} ({len(skills)} skills + AGENTS.md + cli-commands.md)") + return target_dir + + +def _context7_readme(skills: list[str]) -> str: + """Render the README that ships at the root of the docs repo. + + Doubles as a CLI quickstart for humans landing on the GitHub page and as + the top-level entry point context7 indexes first — keep it actionable. + """ + skill_lines = "\n".join(f"- `skills/{name}/SKILL.md`" for name in skills) + return f"""# Windmill CLI Quickstart + +[`wmill`](https://www.windmill.dev/docs/advanced/cli) is the official command +line interface for [Windmill](https://www.windmill.dev) — an open-source +platform for internal tools, workflows, API integrations, background jobs, and +UIs. Use it to authenticate against a workspace, scaffold local projects, +sync scripts/flows/apps between your filesystem and a workspace, and run or +debug jobs from your terminal. + +## Install + +```sh +npm install -g windmill-cli +wmill --version +``` + +Upgrade later with `wmill upgrade`. + +## Connect to a workspace + +```sh +wmill workspace add +``` + +This walks you through adding a workspace profile — a `(name, remote URL, +workspace id, token)` tuple stored under `~/.config/windmill`. You can have +multiple profiles and switch between them with `wmill workspace switch `. + +A workspace token is created from the Windmill UI under +`User Settings → Tokens`. For self-hosted instances, point the remote at your +own URL (e.g. `https://windmill.example.com`). + +## Initialize a project directory + +```sh +wmill init +``` + +`wmill init` creates: + +- `wmill.yaml` — sync configuration (which folders/types to track). +- `AGENTS.md` + `CLAUDE.md` — the agent prompt published in this repo. +- `.claude/skills/` and `.agents/skills/` — per-task guides used by AI coding + assistants (Claude Code, Codex, Pi). These are the same `SKILL.md` files + you'll find under `skills/` in this repo. + +It also offers to bind a workspace profile to the current git branch and to +import git-sync settings from the backend if any are configured. + +## Sync between local files and a workspace + +```sh +wmill sync pull # workspace → local (writes flows, scripts, apps, etc.) +wmill sync push # local → workspace +``` + +Sync is idempotent and diff-aware: `wmill sync push --dry-run` previews the +changes without applying them. Use `--yaml` (recommended) to keep specs as +YAML rather than JSON. + +For individual entities you can also use the type-specific commands: + +```sh +wmill script push path/to/script.ts +wmill flow push path/to/flow.yaml +wmill app push path/to/app.yaml +wmill resource push path/to/resource.yaml +``` + +## Run, inspect, and debug jobs + +```sh +wmill script run u/me/my_script --data '{{"foo": "bar"}}' +wmill flow run u/me/my_flow --data @inputs.json +wmill job list --failed --limit 20 +wmill job get +wmill job logs +``` + +Logs and flow steps stream as the job runs. For flow failures, `wmill job get` +shows the step tree with each sub-job's id so you can drill in with +`wmill job logs `. + +## Scaffold new entities + +```sh +wmill script new u/me/path --language bun +wmill flow new u/me/path --summary "..." +wmill app new u/me/path --summary "..." --framework svelte +``` + +These create the correct folder layout and a minimal spec file, then print +next-step hints. Prefer them over hand-creating the folders — they pick the +right naming conventions for your workspace. + +## Triggers and schedules + +Triggers (HTTP routes, WebSocket, Kafka, NATS, MQTT, SQS, GCP Pub/Sub, Azure +Event Hubs, Email, Postgres CDC) and cron schedules are tracked as YAML files +synced alongside your scripts and flows. See `skills/triggers/SKILL.md` and +`skills/schedules/SKILL.md` for the full schemas. + +## Completion + +```sh +source <(wmill completions bash) # bash, zsh: source <(wmill completions zsh) +source (wmill completions fish | psub) # fish +``` + +## Reference + +- `cli-commands.md` — every `wmill` command and flag, generated from the + source. +- `AGENTS.md` — the top-level prompt the CLI installs into each project (and + the same instructions AI coding assistants follow when working in a + Windmill repo). +- `skills//SKILL.md` — one self-contained guide per common task. + +### Skills index + +{skill_lines} + +## About this repo + +Auto-generated mirror of the Windmill CLI's bundled AI-agent guidance and +command reference, published for ingestion by docs aggregators such as +[context7](https://context7.com). + +**Do not edit by hand.** This repo is regenerated from +[windmill-labs/windmill](https://github.com/windmill-labs/windmill) on every +release. Open issues and PRs in the source repo, not here. The generator is +`system_prompts/generate.py --context7-dir`. +""" + + # ============================================================================= # Main Entry Point # ============================================================================= @@ -1799,6 +2189,15 @@ def parse_args() -> argparse.Namespace: "a plugin root, or a skills directory, and refreshes standalone skills there." ), ) + parser.add_argument( + "--context7-dir", + type=Path, + help=( + "Optional path to a docs-repo checkout (e.g. windmill-cli-docs). " + "Writes AGENTS.md, cli-commands.md, skills/, README.md, and manifest.json " + "with all placeholders resolved, suitable for context7 ingestion." + ), + ) return parser.parse_args() @@ -2117,6 +2516,11 @@ export declare function getWorkflowAsCodePrompt(language?: string): string; if args.plugin_dir: generate_plugin_skills(args.plugin_dir, skills, schema_yaml_content) + if args.context7_dir: + generate_context7_repo( + args.context7_dir, skills, schema_yaml_content, cli_commands + ) + print("\nDone!") From 7a7d246a6e27aef6bc15c2e88a4163874f474b86 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 13 May 2026 10:20:30 +0200 Subject: [PATCH 16/40] test: add global ai eval mode (#9129) * feat: add global ai eval mode * fix: improve global eval validation feedback --- ai_evals/AGENTS.md | 11 + ai_evals/README.md | 16 +- ai_evals/adapters/frontend/benchmarkRunner.ts | 7 +- .../frontend/core/global/globalEvalRunner.ts | 127 ++++++ ai_evals/adapters/frontend/progress.ts | 2 +- ai_evals/adapters/frontend/runtime.ts | 2 +- .../adapters/frontend/vitestAdapter.test.ts | 174 ++++++++ ai_evals/cases/global.yaml | 55 +++ ai_evals/cli/index.ts | 7 +- ai_evals/core/cases.test.ts | 20 + ai_evals/core/models.ts | 2 +- ai_evals/core/types.ts | 26 +- ai_evals/core/validators.test.ts | 225 +++++++++++ ai_evals/core/validators.ts | 376 ++++++++++++++++++ .../initial/format_greeting_script.json | 23 ++ ai_evals/modes/global.ts | 81 ++++ 16 files changed, 1142 insertions(+), 12 deletions(-) create mode 100644 ai_evals/adapters/frontend/core/global/globalEvalRunner.ts create mode 100644 ai_evals/cases/global.yaml create mode 100644 ai_evals/fixtures/frontend/global/initial/format_greeting_script.json create mode 100644 ai_evals/modes/global.ts diff --git a/ai_evals/AGENTS.md b/ai_evals/AGENTS.md index 096baf5b58..d26e6d60ea 100644 --- a/ai_evals/AGENTS.md +++ b/ai_evals/AGENTS.md @@ -6,6 +6,7 @@ This folder contains black-box benchmark cases for: - `app` - `script` - `cli` +- `global` The goal is to test the current production prompts and guidance with realistic user requests, not to test one exact implementation shape. @@ -75,6 +76,16 @@ Still, avoid benchmark phrasing. The prompt should read like a repo task, not a When relevant, ask the assistant to tell the user which `wmill` commands to run next. That is part of the benchmarked behavior. +## Global-specific rules + +Global prompts should exercise workspace-level drafting behavior: + +- inspecting existing scripts, flows, apps, schedules, triggers, resources, and variables when relevant +- writing AI drafts rather than saving or deploying by default +- producing coherent multi-artifact changes when the request crosses artifact boundaries + +Keep deterministic validation focused on the draft contract: required draft type/path, required content snippets, forbidden draft paths, and forbidden mutating tools such as deploy/delete unless the case explicitly asks for them. + ## Deterministic validation Use deterministic validation only for hard failures such as: diff --git a/ai_evals/README.md b/ai_evals/README.md index 2e1f3210f8..6982d70da9 100644 --- a/ai_evals/README.md +++ b/ai_evals/README.md @@ -1,11 +1,12 @@ # AI Evals -Small benchmark runner for the four Windmill AI generation modes: +Small benchmark runner for the Windmill AI generation modes: - `cli` - `flow` - `script` - `app` +- `global` The benchmark always tests the current production prompts, tools, and guidance in this checkout. @@ -57,6 +58,7 @@ bun run cli -- run flow flow-test0-sum-two-numbers --runs 3 --verbose bun run cli -- run flow --record GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000 bun run cli -- run flow --backend-validation preview +bun run cli -- run global global-test1-script-create bun run cli -- run cli bun-hello-script ``` @@ -94,7 +96,7 @@ Today: Notes: - the command also prints accepted alias spellings such as `gpt-4o`, `claude-opus-4.6`, and `claude-haiku-4.5` -- frontend modes (`flow`, `script`, `app`) can use Anthropic, OpenAI, and Gemini-backed aliases +- frontend modes (`flow`, `script`, `app`, `global`) can use Anthropic, OpenAI, and Gemini-backed aliases - `cli` mode always uses the Anthropic agent SDK, so only Anthropic aliases are valid there - the judge model is separate and currently defaults to `claude-sonnet-4-6` @@ -133,6 +135,13 @@ For `app` mode, `validate` can express narrow hard requirements such as: - minimum datatable / datatable-table counts - specific required datatable tables +For `global` mode, `validate` can express draft-level requirements such as: + +- required draft type/path/language +- required or forbidden snippets in draft values +- required or forbidden draft counts +- forbidden draft paths + App fixtures can also include an optional `datatables.json` file at the fixture root. For `flow` mode, an `initial` fixture can also include a benchmark workspace catalog of @@ -174,6 +183,7 @@ If `--record` is used, the CLI also appends one compact JSON line to: - `ai_evals/history/flow.jsonl` - `ai_evals/history/script.jsonl` - `ai_evals/history/app.jsonl` +- `ai_evals/history/global.jsonl` - `ai_evals/history/cli.jsonl` Each recorded line contains: @@ -194,6 +204,7 @@ Typical artifacts by mode: - `flow`: `flow.json` - `script`: `script.json` plus the generated script file - `app`: `app.json` plus frontend/backend files +- `global`: `global-drafts.json` - `cli`: `assistant-output.txt`, `trace.json`, `wmill-invocations.jsonl`, plus generated workspace files - backend-validated attempts also include `backend-preview.json` @@ -209,6 +220,7 @@ Typical artifacts by mode: ## Notes - Frontend modes reuse the production frontend chat code through the Vitest bridge. +- Global mode evaluates the production global AI tools and validates the resulting AI draft store. - CLI mode creates an isolated workspace, writes the current checkout guidance into it, and benchmarks the real skills / `AGENTS.md` flow. - CLI mode now also records a structured trace of invoked skills, tool calls, proposed `wmill` commands, and any attempted `wmill` executions. - Frontend progress streams live while the benchmark is running. diff --git a/ai_evals/adapters/frontend/benchmarkRunner.ts b/ai_evals/adapters/frontend/benchmarkRunner.ts index 50a0d10c3c..14be108a10 100644 --- a/ai_evals/adapters/frontend/benchmarkRunner.ts +++ b/ai_evals/adapters/frontend/benchmarkRunner.ts @@ -12,10 +12,11 @@ import { resolveWindmillBackendSettings } from "../../core/windmillBackendSettin import { emitFrontendBenchmarkProgress } from "./progress"; import { createAppModeRunner } from "../../modes/app"; import { createFlowModeRunner } from "../../modes/flow"; +import { createGlobalModeRunner } from "../../modes/global"; import { createScriptModeRunner } from "../../modes/script"; import { DEFAULT_JUDGE_MODEL } from "../../core/judge"; -export type FrontendBenchmarkMode = "flow" | "app" | "script"; +export type FrontendBenchmarkMode = "flow" | "app" | "script" | "global"; export async function runFrontendBenchmarkFromEnv(): Promise { const mode = parseMode(process.env.WMILL_FRONTEND_AI_EVAL_MODE); @@ -85,11 +86,13 @@ function getModeRunner( backendValidation, backendSettings, ); + case "global": + return createGlobalModeRunner(model, backendSettings); } } function parseMode(value: string | undefined): FrontendBenchmarkMode { - if (value === "flow" || value === "app" || value === "script") { + if (value === "flow" || value === "app" || value === "script" || value === "global") { return value; } throw new Error(`Unsupported frontend benchmark mode: ${String(value)}`); diff --git a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts new file mode 100644 index 0000000000..5e00dd6f34 --- /dev/null +++ b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts @@ -0,0 +1,127 @@ +import { mkdtemp, rm } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import type { AIProvider } from "$lib/gen/types.gen"; +import { + globalTools, + prepareGlobalSystemMessage, + prepareGlobalUserMessage, +} from "../../../../../frontend/src/lib/components/copilot/chat/global/core"; +import { globalDraftStore } from "../../../../../frontend/src/lib/components/copilot/chat/global/draftStore.svelte"; +import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared"; +import type { ModeRunContext } from "../../../../core/types"; +import type { GlobalDraftState } from "../../../../core/validators"; +import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings"; +import { + registerBenchmarkWorkspaceRunnables, + unregisterBenchmarkWorkspaceRunnables, + type BenchmarkWorkspaceRunnables, +} from "../../mockBackend"; +import { runEval } from "../shared"; +import type { TokenUsage, ToolCallDetail } from "../shared/types"; + +const MUTATING_GLOBAL_TOOLS = new Set([ + "deploy_workspace_item", + "delete_workspace_item", +]); + +export interface GlobalEvalResult { + success: boolean; + state: GlobalDraftState; + error?: string; + assistantMessageCount: number; + toolCallCount: number; + toolsUsed: string[]; + toolCallDetails: ToolCallDetail[]; + tokenUsage: TokenUsage; +} + +export interface GlobalEvalOptions { + workspaceFixtures?: BenchmarkWorkspaceRunnables; + model?: string; + maxIterations?: number; + provider?: AIProvider; + backend: WindmillBackendSettings; + workspaceRoot?: string; + runContext?: ModeRunContext; +} + +export async function runGlobalEval( + userPrompt: string, + apiKey: string, + options: GlobalEvalOptions, +): Promise { + const workspaceRoot = + options.workspaceRoot ?? + (await mkdtemp(join(tmpdir(), "wmill-frontend-global-benchmark-"))); + + globalDraftStore.clearDrafts(workspaceRoot); + registerBenchmarkWorkspaceRunnables(workspaceRoot, options.workspaceFixtures ?? {}); + + try { + const model = options.model ?? "claude-haiku-4-5-20251001"; + const rawResult = await runEval({ + userPrompt, + systemMessage: prepareGlobalSystemMessage(), + userMessage: prepareGlobalUserMessage(userPrompt), + tools: getGlobalEvalTools(), + helpers: {}, + apiKey, + getOutput: () => ({ drafts: globalDraftStore.listDrafts(workspaceRoot) }), + onAssistantMessageStart: options.runContext?.onAssistantMessageStart, + onAssistantToken: options.runContext?.onAssistantChunk, + onAssistantMessageEnd: options.runContext?.onAssistantMessageEnd, + onToolCall: options.runContext?.onToolCall, + options: { + maxIterations: options.maxIterations, + model, + workspace: workspaceRoot, + provider: options.provider, + backend: options.backend, + caseId: options.runContext?.caseId, + attempt: options.runContext?.attempt, + }, + }); + + return { + state: rawResult.output, + success: rawResult.success, + error: rawResult.error, + assistantMessageCount: rawResult.iterations, + toolCallCount: rawResult.toolCallsCount, + toolsUsed: rawResult.toolsCalled, + toolCallDetails: rawResult.toolCallDetails, + tokenUsage: rawResult.tokenUsage, + }; + } finally { + globalDraftStore.clearDrafts(workspaceRoot); + unregisterBenchmarkWorkspaceRunnables(workspaceRoot); + if (!options.workspaceRoot) { + await rm(workspaceRoot, { recursive: true, force: true }); + } + } +} + +function getGlobalEvalTools(): ProductionTool<{}>[] { + return (globalTools as ProductionTool<{}>[]).map((tool) => { + if (!MUTATING_GLOBAL_TOOLS.has(tool.def.function.name)) { + return tool; + } + + return { + ...tool, + requiresConfirmation: false, + validateBeforeConfirmation: undefined, + fn: async () => + JSON.stringify( + { + success: false, + error: + "This mutating workspace tool is disabled during ai_evals global mode.", + }, + null, + 2, + ), + }; + }); +} diff --git a/ai_evals/adapters/frontend/progress.ts b/ai_evals/adapters/frontend/progress.ts index b5b8f12c83..3a4810c4a3 100644 --- a/ai_evals/adapters/frontend/progress.ts +++ b/ai_evals/adapters/frontend/progress.ts @@ -1,4 +1,4 @@ -export type FrontendBenchmarkProgressSurface = 'flow' | 'app' | 'script' +export type FrontendBenchmarkProgressSurface = 'flow' | 'app' | 'script' | 'global' export type FrontendBenchmarkProgressEvent = | { diff --git a/ai_evals/adapters/frontend/runtime.ts b/ai_evals/adapters/frontend/runtime.ts index 180c7a2993..347e15191c 100644 --- a/ai_evals/adapters/frontend/runtime.ts +++ b/ai_evals/adapters/frontend/runtime.ts @@ -16,7 +16,7 @@ const FRONTEND_BENCHMARK_TEST = const FRONTEND_BENCHMARK_CONFIG = "../ai_evals/adapters/frontend/vitest.config.ts"; -export type FrontendMode = "flow" | "app" | "script"; +export type FrontendMode = "flow" | "app" | "script" | "global"; export async function runFrontendBenchmarkAdapter(input: { mode: FrontendMode; diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index 542feaf89b..1275acf0b4 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -65,6 +65,10 @@ vi.mock('$lib/gen', async () => { hasBenchmarkWorkspace(data.workspace) ? (listBenchmarkScripts(data.workspace) ?? []) : actual.ScriptService.listScripts(data), + existsScriptByPath: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) + ? Boolean(getBenchmarkScriptByPath(data.workspace, data.path)) + : actual.ScriptService.existsScriptByPath(data), getScriptByPath: async (data: { workspace: string; path: string }) => { if (hasBenchmarkWorkspace(data.workspace)) { const script = getBenchmarkScriptByPath(data.workspace, data.path) @@ -91,6 +95,10 @@ vi.mock('$lib/gen', async () => { hasBenchmarkWorkspace(data.workspace) ? (listBenchmarkFlows(data.workspace) ?? []) : actual.FlowService.listFlows(data), + existsFlowByPath: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) + ? Boolean(getBenchmarkFlowByPath(data.workspace, data.path)) + : actual.FlowService.existsFlowByPath(data), getFlowByPath: async (data: { workspace: string; path: string }) => { if (hasBenchmarkWorkspace(data.workspace)) { const flow = getBenchmarkFlowByPath(data.workspace, data.path) @@ -142,6 +150,16 @@ vi.mock('$lib/gen', async () => { } }), ScheduleService: wrapService(actual.ScheduleService, { + existsSchedule: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.ScheduleService.existsSchedule(data), + listSchedules: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.ScheduleService.listSchedules(data), + getSchedule: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`Schedule "${data.path}" not found in benchmark workspace`) + } + return actual.ScheduleService.getSchedule(data) + }, previewSchedule: async (data: { requestBody?: Record }) => previewBenchmarkSchedule(data), createSchedule: async (data: { workspace: string; requestBody: Record }) => @@ -149,11 +167,167 @@ vi.mock('$lib/gen', async () => { ? createBenchmarkSchedule(data) : actual.ScheduleService.createSchedule(data) }), + ResourceService: wrapService(actual.ResourceService, { + existsResource: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.ResourceService.existsResource(data), + listResource: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.listResource(data), + getResource: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`Resource "${data.path}" not found in benchmark workspace`) + } + return actual.ResourceService.getResource(data) + }, + queryResourceTypes: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.queryResourceTypes(data) + }), + VariableService: wrapService(actual.VariableService, { + existsVariable: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.VariableService.existsVariable(data), + listVariable: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.VariableService.listVariable(data), + getVariable: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`Variable "${data.path}" not found in benchmark workspace`) + } + return actual.VariableService.getVariable(data) + } + }), + AppService: wrapService(actual.AppService, { + existsApp: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.AppService.existsApp(data), + listApps: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.AppService.listApps(data), + getAppByPath: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`App "${data.path}" not found in benchmark workspace`) + } + return actual.AppService.getAppByPath(data) + } + }), HttpTriggerService: wrapService(actual.HttpTriggerService, { + existsHttpTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.HttpTriggerService.existsHttpTrigger(data), + listHttpTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.HttpTriggerService.listHttpTriggers(data), + getHttpTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`HTTP trigger "${data.path}" not found in benchmark workspace`) + } + return actual.HttpTriggerService.getHttpTrigger(data) + }, createHttpTrigger: async (data: { workspace: string; requestBody: Record }) => hasBenchmarkWorkspace(data.workspace) ? createBenchmarkHttpTrigger(data) : actual.HttpTriggerService.createHttpTrigger(data) + }), + WebsocketTriggerService: wrapService(actual.WebsocketTriggerService, { + existsWebsocketTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) + ? false + : actual.WebsocketTriggerService.existsWebsocketTrigger(data), + listWebsocketTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) + ? [] + : actual.WebsocketTriggerService.listWebsocketTriggers(data), + getWebsocketTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`Websocket trigger "${data.path}" not found in benchmark workspace`) + } + return actual.WebsocketTriggerService.getWebsocketTrigger(data) + } + }), + KafkaTriggerService: wrapService(actual.KafkaTriggerService, { + existsKafkaTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) + ? false + : actual.KafkaTriggerService.existsKafkaTrigger(data), + listKafkaTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.KafkaTriggerService.listKafkaTriggers(data), + getKafkaTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`Kafka trigger "${data.path}" not found in benchmark workspace`) + } + return actual.KafkaTriggerService.getKafkaTrigger(data) + } + }), + NatsTriggerService: wrapService(actual.NatsTriggerService, { + existsNatsTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.NatsTriggerService.existsNatsTrigger(data), + listNatsTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.NatsTriggerService.listNatsTriggers(data), + getNatsTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`NATS trigger "${data.path}" not found in benchmark workspace`) + } + return actual.NatsTriggerService.getNatsTrigger(data) + } + }), + PostgresTriggerService: wrapService(actual.PostgresTriggerService, { + existsPostgresTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) + ? false + : actual.PostgresTriggerService.existsPostgresTrigger(data), + listPostgresTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) + ? [] + : actual.PostgresTriggerService.listPostgresTriggers(data), + getPostgresTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`Postgres trigger "${data.path}" not found in benchmark workspace`) + } + return actual.PostgresTriggerService.getPostgresTrigger(data) + } + }), + MqttTriggerService: wrapService(actual.MqttTriggerService, { + existsMqttTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.MqttTriggerService.existsMqttTrigger(data), + listMqttTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.MqttTriggerService.listMqttTriggers(data), + getMqttTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`MQTT trigger "${data.path}" not found in benchmark workspace`) + } + return actual.MqttTriggerService.getMqttTrigger(data) + } + }), + SqsTriggerService: wrapService(actual.SqsTriggerService, { + existsSqsTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.SqsTriggerService.existsSqsTrigger(data), + listSqsTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.SqsTriggerService.listSqsTriggers(data), + getSqsTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`SQS trigger "${data.path}" not found in benchmark workspace`) + } + return actual.SqsTriggerService.getSqsTrigger(data) + } + }), + GcpTriggerService: wrapService(actual.GcpTriggerService, { + existsGcpTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.GcpTriggerService.existsGcpTrigger(data), + listGcpTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.GcpTriggerService.listGcpTriggers(data), + getGcpTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`GCP trigger "${data.path}" not found in benchmark workspace`) + } + return actual.GcpTriggerService.getGcpTrigger(data) + } + }), + AzureTriggerService: wrapService(actual.AzureTriggerService, { + existsAzureTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) + ? false + : actual.AzureTriggerService.existsAzureTrigger(data), + listAzureTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.AzureTriggerService.listAzureTriggers(data), + getAzureTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`Azure trigger "${data.path}" not found in benchmark workspace`) + } + return actual.AzureTriggerService.getAzureTrigger(data) + } }) } }) diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml new file mode 100644 index 0000000000..709081942b --- /dev/null +++ b/ai_evals/cases/global.yaml @@ -0,0 +1,55 @@ +- id: global-test1-script-create + prompt: |- + Create a draft Bun script at `f/evals/global/greet_user`. + It should take a string `name` input and return `Hello, ${name}!`. + Leave it as an AI draft only; do not deploy or save it. + runtime: + maxTurns: 8 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + path: f/evals/global/greet_user + language: bun + valueIncludes: + - name + - Hello + toolExpect: + requiredToolsUsed: + - write_script + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - creates a Bun script draft at f/evals/global/greet_user + - the script accepts a name input + - the script returns a greeting containing Hello, the provided name, and an exclamation mark + - the result stays as an AI draft and is not deployed or saved to the workspace + +- id: global-test2-script-edit-existing + prompt: |- + Update the existing workspace script at `f/evals/global/format_greeting`. + Keep it as a Bun script, but change the greeting so the provided name is uppercased and the returned message ends with an exclamation mark. + Leave the result as an AI draft only; do not deploy or save it. + initial: ai_evals/fixtures/frontend/global/initial/format_greeting_script.json + runtime: + maxTurns: 8 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + path: f/evals/global/format_greeting + language: bun + valueIncludes: + - toUpperCase + - "!" + toolExpect: + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - creates an AI draft for the existing f/evals/global/format_greeting script + - preserves the script as Bun + - uppercases the provided name in the greeting + - returns a message ending with an exclamation mark + - does not deploy or save the draft to the workspace diff --git a/ai_evals/cli/index.ts b/ai_evals/cli/index.ts index 259b055ff5..8ed61740c8 100644 --- a/ai_evals/cli/index.ts +++ b/ai_evals/cli/index.ts @@ -53,6 +53,7 @@ async function main() { " bun run cli -- run flow --record", " bun run cli -- run flow --backend-validation preview", " bun run cli -- run flow flow-test5-simple-modification --runs 3", + " bun run cli -- run global global-test1-script-create", " bun run cli -- run cli bun-hello-script", "", "Models:", @@ -70,7 +71,7 @@ async function main() { program .command("cases") .description("List available cases") - .argument("[mode]", "cli, flow, script, or app", parseOptionalMode) + .argument("[mode]", "cli, flow, script, app, or global", parseOptionalMode) .action(async (mode?: EvalMode) => { await handleCases(mode); }); @@ -78,7 +79,7 @@ async function main() { program .command("run") .description("Run one benchmark mode") - .argument("", "cli, flow, script, or app", parseMode) + .argument("", "cli, flow, script, app, or global", parseMode) .argument("[caseIds...]", "specific case ids to run") .option( "--runs ", @@ -152,7 +153,7 @@ function handleModels() { process.stdout.write("Available models\n"); for (const model of EVAL_MODELS) { const supports = [ - ...(model.frontend ? ["flow", "script", "app"] : []), + ...(model.frontend ? ["flow", "script", "app", "global"] : []), ...(model.cli ? ["cli"] : []), ]; const aliases = [ diff --git a/ai_evals/core/cases.test.ts b/ai_evals/core/cases.test.ts index 977bb71390..733d34ddd2 100644 --- a/ai_evals/core/cases.test.ts +++ b/ai_evals/core/cases.test.ts @@ -183,6 +183,26 @@ describe("loadCases", () => { }); }); + it("loads global draft validation and forbidden tool expectations", async () => { + const globalCases = await loadCases("global"); + const caseEntry = globalCases.find((entry) => entry.id === "global-test1-script-create"); + + expect(caseEntry?.validate).toMatchObject({ + draftCountExactly: 1, + requiredDrafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + }, + ], + }); + expect(caseEntry?.toolExpect).toMatchObject({ + requiredToolsUsed: ["write_script"], + forbiddenToolsUsed: ["deploy_workspace_item", "delete_workspace_item"], + }); + }); + it("loads tool expectations for workspace mutation cases", async () => { const scriptCases = await loadCases("script"); const caseEntry = scriptCases.find( diff --git a/ai_evals/core/models.ts b/ai_evals/core/models.ts index 9cc0ab0597..82f3b3f69b 100644 --- a/ai_evals/core/models.ts +++ b/ai_evals/core/models.ts @@ -145,7 +145,7 @@ export function resolveEvalModel(mode: EvalMode, alias?: string): EvalModelSpec export function getEvalModelHelpText(): string { return EVAL_MODELS.map((model) => { const modes = [ - ...(model.frontend ? ["flow", "script", "app"] : []), + ...(model.frontend ? ["flow", "script", "app", "global"] : []), ...(model.cli ? ["cli"] : []), ]; return ` ${model.id.padEnd(8)} ${model.label} (${modes.join(", ")})`; diff --git a/ai_evals/core/types.ts b/ai_evals/core/types.ts index dfc6882f84..2b42a0dfc5 100644 --- a/ai_evals/core/types.ts +++ b/ai_evals/core/types.ts @@ -1,4 +1,4 @@ -export const EVAL_MODES = ["cli", "flow", "script", "app"] as const; +export const EVAL_MODES = ["cli", "flow", "script", "app", "global"] as const; export type EvalMode = (typeof EVAL_MODES)[number]; @@ -108,6 +108,27 @@ export interface AppValidationSpec { forbiddenAppContent?: string[]; } +export interface GlobalDraftRequirement { + type: string; + path: string; + triggerKind?: string; + language?: string; + summaryIncludes?: string[]; + valueIncludes?: string[]; + valueExcludes?: string[]; +} + +export interface GlobalValidationSpec { + draftCountAtLeast?: number; + draftCountExactly?: number; + requiredDrafts?: GlobalDraftRequirement[]; + forbiddenDrafts?: Array<{ + type: string; + path: string; + triggerKind?: string; + }>; +} + export interface CliValidationSpec { requiredSkills?: string[]; forbiddenSkills?: string[]; @@ -136,10 +157,11 @@ export interface ToolCallArgumentRule { export interface ToolValidationSpec { requiredToolsUsed?: string[]; + forbiddenToolsUsed?: string[]; toolCallArgs?: ToolCallArgumentRule[]; } -export type EvalValidationSpec = FlowValidationSpec | AppValidationSpec; +export type EvalValidationSpec = FlowValidationSpec | AppValidationSpec | GlobalValidationSpec; export interface EvalCase { id: string; diff --git a/ai_evals/core/validators.test.ts b/ai_evals/core/validators.test.ts index 406172b955..d2a6e954bb 100644 --- a/ai_evals/core/validators.test.ts +++ b/ai_evals/core/validators.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import { validateAppState, validateCliWorkspace, + validateGlobalState, validateScriptState, validateToolExpectations, } from "./validators"; @@ -117,6 +118,230 @@ describe("validateToolExpectations", () => { details: 'rejected prefixes: schedules/; values: "schedules/greet_user_daily"', }); }); + + it("rejects forbidden tool usage", () => { + const checks = validateToolExpectations({ + run: { + success: true, + actual: {}, + assistantMessageCount: 1, + toolCallCount: 1, + toolsUsed: ["write_script", "deploy_workspace_item"], + skillsInvoked: [], + }, + toolExpect: { + forbiddenToolsUsed: ["deploy_workspace_item"], + }, + }); + + expect(checks).toContainEqual({ + name: "does not use deploy_workspace_item", + passed: false, + details: "tools used: write_script, deploy_workspace_item", + }); + }); +}); + +describe("validateGlobalState", () => { + it("accepts a required script draft", () => { + const checks = validateGlobalState({ + actual: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + value: + "export async function main(name: string) {\n return `Hello, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + validate: { + draftCountExactly: 1, + requiredDrafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + valueIncludes: ["Hello"], + }, + ], + }, + }); + + expect(checks.every((check) => check.passed)).toBe(true); + }); + + it("fails when a required draft is missing", () => { + const checks = validateGlobalState({ + actual: { + drafts: [], + }, + validate: { + requiredDrafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + }, + ], + }, + }); + + expect(checks).toContainEqual({ + name: "global includes script draft f/evals/global/greet_user", + passed: false, + details: "drafts: none", + }); + }); + + it("does not require a TypeScript entrypoint for non-TypeScript script drafts", () => { + const checks = validateGlobalState({ + actual: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_python", + language: "python3", + value: "def main(name: str):\n return f'Hello, {name}!'\n", + isDraft: true, + }, + ], + }, + }); + + expect(checks.some((check) => check.name.includes("exports entrypoint"))).toBe( + false + ); + expect(checks.every((check) => check.passed)).toBe(true); + }); + + it("allows read-only global cases without draft expectations", () => { + const checks = validateGlobalState({ + actual: { + drafts: [], + }, + }); + + expect( + checks.some( + (check) => check.name === "global produced at least one draft" + ) + ).toBe(false); + expect(checks.every((check) => check.passed)).toBe(true); + }); + + it("matches expected global draft fixtures", () => { + const checks = validateGlobalState({ + actual: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + value: + "export async function main(name: string) {\r\n return `Hello, ${name}!`\r\n}\r\n", + isDraft: true, + }, + ], + }, + expected: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + value: + "export async function main(name: string) {\n return `Hello, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + }); + + expect(checks).toContainEqual({ + name: "global drafts match expected", + passed: true, + }); + }); + + it("fails when expected global draft fixtures differ", () => { + const checks = validateGlobalState({ + actual: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + value: + "export async function main(name: string) {\n return `Hello, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + expected: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + value: + "export async function main(name: string) {\n return `Bonjour, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + }); + + const expectedMatchCheck = checks.find( + (check) => check.name === "global drafts match expected" + ); + expect(expectedMatchCheck?.passed).toBe(false); + expect(expectedMatchCheck?.details).toContain( + "script:f/evals/global/greet_user value differs" + ); + expect(expectedMatchCheck?.details).toContain("Hello"); + expect(expectedMatchCheck?.details).toContain("Bonjour"); + }); + + it("explains expected global draft metadata mismatches", () => { + const checks = validateGlobalState({ + actual: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + value: + "export async function main(name: string) {\n return `Hello, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + expected: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "python3", + value: + "export async function main(name: string) {\n return `Hello, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + }); + + const expectedMatchCheck = checks.find( + (check) => check.name === "global drafts match expected" + ); + expect(expectedMatchCheck?.passed).toBe(false); + expect(expectedMatchCheck?.details).toContain( + "script:f/evals/global/greet_user language differs" + ); + expect(expectedMatchCheck?.details).toContain('actual="bun"'); + expect(expectedMatchCheck?.details).toContain('expected="python3"'); + }); }); describe("validateAppState", () => { diff --git a/ai_evals/core/validators.ts b/ai_evals/core/validators.ts index e690b3d7eb..4f59368113 100644 --- a/ai_evals/core/validators.ts +++ b/ai_evals/core/validators.ts @@ -6,6 +6,7 @@ import type { CliTrace, CliValidationSpec, FlowValidationSpec, + GlobalValidationSpec, ModeRunOutput, ToolValidationSpec, } from "./types"; @@ -51,6 +52,20 @@ export interface AppDatatableState { error?: string; } +export interface GlobalDraftState { + drafts: GlobalDraft[]; +} + +export interface GlobalDraft { + type: string; + path: string; + triggerKind?: string; + summary?: string; + language?: string; + value?: unknown; + isDraft?: boolean; +} + const TS_LIKE_LANGUAGES = new Set(["bun", "deno", "nativets", "bunnative", "ts", "typescript"]); const CONTROL_FLOW_MODULE_TYPES = new Set(["branchone", "branchall", "forloopflow", "whileloopflow"]); @@ -154,6 +169,16 @@ export function validateToolExpectations(input: { ); } + for (const toolName of expect.forbiddenToolsUsed ?? []) { + checks.push( + check( + `does not use ${toolName}`, + !input.run.toolsUsed.includes(toolName), + `tools used: ${input.run.toolsUsed.join(", ") || "none"}` + ) + ); + } + for (const rule of expect.toolCallArgs ?? []) { const calls = toolCallDetails.filter((call) => call.name === rule.tool); checks.push( @@ -202,6 +227,161 @@ export function validateToolExpectations(input: { return checks; } +export function validateGlobalState(input: { + actual: GlobalDraftState; + expected?: GlobalDraftState; + validate?: GlobalValidationSpec; +}): BenchmarkCheck[] { + const drafts = input.actual.drafts ?? []; + const checks: BenchmarkCheck[] = []; + + // Read-only global cases are valid; only enforce draft production when the + // case explicitly asks for draft output. + if (globalValidationExpectsDrafts(input)) { + checks.push( + check( + "global produced at least one draft", + drafts.length > 0, + `drafts=${drafts.length}` + ) + ); + } + + checks.push( + check( + "all global outputs are drafts", + drafts.every((draft) => draft.isDraft === true), + summarizeGlobalDrafts(drafts) + ) + ); + + for (const draft of drafts) { + if (draft.type !== "script" || typeof draft.value !== "string") { + continue; + } + + const language = (draft.language ?? "bun").toLowerCase(); + const syntaxErrors = getScriptSyntaxErrors(draft.value, language); + if (TS_LIKE_LANGUAGES.has(language)) { + checks.push( + check( + `script draft ${draft.path} exports entrypoint`, + hasSupportedEntrypoint(draft.value) + ) + ); + } + checks.push( + check( + `script draft ${draft.path} has no syntax errors`, + syntaxErrors.length === 0, + summarizeProblems(syntaxErrors) + ) + ); + } + + if (input.expected) { + checks.push( + check( + "global drafts match expected", + globalDraftStatesEqual(input.actual, input.expected), + describeGlobalDraftStateMismatch(input.actual, input.expected) + ) + ); + } + + const validate = input.validate; + if (!validate) { + return checks; + } + + if (validate.draftCountAtLeast !== undefined) { + checks.push( + check( + `global includes at least ${validate.draftCountAtLeast} draft(s)`, + drafts.length >= validate.draftCountAtLeast, + `drafts=${drafts.length}` + ) + ); + } + + if (validate.draftCountExactly !== undefined) { + checks.push( + check( + `global includes exactly ${validate.draftCountExactly} draft(s)`, + drafts.length === validate.draftCountExactly, + `drafts=${drafts.length}` + ) + ); + } + + for (const required of validate.requiredDrafts ?? []) { + const draft = findGlobalDraft(drafts, required.type, required.path, required.triggerKind); + checks.push( + check( + `global includes ${required.type} draft ${required.path}`, + Boolean(draft), + summarizeGlobalDrafts(drafts) + ) + ); + if (!draft) { + continue; + } + + if (required.language !== undefined) { + checks.push( + check( + `${required.type} draft ${required.path} uses ${required.language}`, + draft.language === required.language, + `language=${draft.language ?? "(none)"}` + ) + ); + } + + for (const snippet of required.summaryIncludes ?? []) { + checks.push( + check( + `${required.type} draft ${required.path} summary includes '${snippet}'`, + normalizeText(draft.summary ?? "").includes(normalizeText(snippet)), + `summary=${draft.summary ?? ""}` + ) + ); + } + + const valueText = stringifyGlobalDraftValue(draft.value); + for (const snippet of required.valueIncludes ?? []) { + checks.push( + check( + `${required.type} draft ${required.path} value includes '${snippet}'`, + normalizeText(valueText).includes(normalizeText(snippet)), + truncateForDetails(valueText) + ) + ); + } + + for (const snippet of required.valueExcludes ?? []) { + checks.push( + check( + `${required.type} draft ${required.path} value excludes '${snippet}'`, + !normalizeText(valueText).includes(normalizeText(snippet)), + truncateForDetails(valueText) + ) + ); + } + } + + for (const forbidden of validate.forbiddenDrafts ?? []) { + checks.push( + check( + `global does not include ${forbidden.type} draft ${forbidden.path}`, + !findGlobalDraft(drafts, forbidden.type, forbidden.path, forbidden.triggerKind), + summarizeGlobalDrafts(drafts) + ) + ); + } + + return checks; +} + export function validateAppState(input: { actual: AppFilesState; initial?: AppFilesState; @@ -433,6 +613,202 @@ function summarizeProblems(problems: string[], limit = 5): string | undefined { return `${problems.slice(0, limit).join("; ")}; ...and ${problems.length - limit} more`; } +function findGlobalDraft( + drafts: GlobalDraft[], + type: string, + path: string, + triggerKind?: string +): GlobalDraft | undefined { + return drafts.find( + (draft) => + draft.type === type && + draft.path === path && + (triggerKind === undefined || draft.triggerKind === triggerKind) + ); +} + +function summarizeGlobalDrafts(drafts: GlobalDraft[]): string { + const summary = drafts + .map((draft) => formatGlobalDraftKey(draft)) + .join(", "); + return `drafts: ${summary || "none"}`; +} + +function formatGlobalDraftKey(draft: GlobalDraft): string { + return `${draft.type}${draft.triggerKind ? `:${draft.triggerKind}` : ""}:${draft.path}`; +} + +function globalValidationExpectsDrafts(input: { + expected?: GlobalDraftState; + validate?: GlobalValidationSpec; +}): boolean { + const validate = input.validate; + return ( + (input.expected?.drafts?.length ?? 0) > 0 || + (validate?.requiredDrafts?.length ?? 0) > 0 || + (validate?.draftCountAtLeast ?? 0) > 0 || + (validate?.draftCountExactly ?? 0) > 0 + ); +} + +function globalDraftStatesEqual(left: GlobalDraftState, right: GlobalDraftState): boolean { + return ( + JSON.stringify(canonicalizeGlobalDrafts(left.drafts ?? [])) === + JSON.stringify(canonicalizeGlobalDrafts(right.drafts ?? [])) + ); +} + +function describeGlobalDraftStateMismatch( + actual: GlobalDraftState, + expected: GlobalDraftState +): string { + const actualDrafts = actual.drafts ?? []; + const expectedDrafts = expected.drafts ?? []; + const actualByKey = new Map( + actualDrafts.map((draft) => [globalDraftSortKey(draft), draft] as const) + ); + const expectedByKey = new Map( + expectedDrafts.map((draft) => [globalDraftSortKey(draft), draft] as const) + ); + + for (const key of Array.from(expectedByKey.keys()).sort()) { + const expectedDraft = expectedByKey.get(key); + if (expectedDraft && !actualByKey.has(key)) { + return `missing expected draft ${formatGlobalDraftKey(expectedDraft)}; actual=${summarizeGlobalDrafts(actualDrafts)}`; + } + } + + for (const key of Array.from(actualByKey.keys()).sort()) { + const actualDraft = actualByKey.get(key); + if (actualDraft && !expectedByKey.has(key)) { + return `unexpected draft ${formatGlobalDraftKey(actualDraft)}; expected=${summarizeGlobalDrafts(expectedDrafts)}`; + } + } + + for (const key of Array.from(expectedByKey.keys()).sort()) { + const actualDraft = actualByKey.get(key); + const expectedDraft = expectedByKey.get(key); + if (!actualDraft || !expectedDraft) { + continue; + } + + const fieldMismatch = describeGlobalDraftFieldMismatch( + formatGlobalDraftKey(expectedDraft), + actualDraft, + expectedDraft + ); + if (fieldMismatch) { + return fieldMismatch; + } + } + + return `actual=${summarizeGlobalDrafts(actualDrafts)}; expected=${summarizeGlobalDrafts(expectedDrafts)}`; +} + +function describeGlobalDraftFieldMismatch( + key: string, + actual: GlobalDraft, + expected: GlobalDraft +): string | undefined { + const fields: Array<"language" | "summary" | "value" | "isDraft"> = [ + "language", + "summary", + "value", + "isDraft", + ]; + + for (const field of fields) { + const actualValue = comparableGlobalDraftFieldValue(actual, field); + const expectedValue = comparableGlobalDraftFieldValue(expected, field); + if (JSON.stringify(actualValue) === JSON.stringify(expectedValue)) { + continue; + } + + return `${key} ${field} differs: actual=${formatGlobalDraftFieldValue( + actualValue + )}; expected=${formatGlobalDraftFieldValue(expectedValue)}`; + } + + return undefined; +} + +function comparableGlobalDraftFieldValue( + draft: GlobalDraft, + field: "language" | "summary" | "value" | "isDraft" +): unknown { + if (field === "summary" && typeof draft.summary === "string") { + return normalizeText(draft.summary); + } + if (field === "value" && typeof draft.value === "string") { + return normalizeText(draft.value); + } + if (field === "value") { + return canonicalizeJsonValue(draft.value); + } + return draft[field]; +} + +function formatGlobalDraftFieldValue(value: unknown): string { + if (value === undefined) { + return "(missing)"; + } + return truncateForDetails(JSON.stringify(value), 300); +} + +function canonicalizeGlobalDrafts(drafts: GlobalDraft[]): unknown[] { + return drafts + .slice() + .sort((left, right) => globalDraftSortKey(left).localeCompare(globalDraftSortKey(right))) + .map((draft) => + canonicalizeJsonValue({ + type: draft.type, + path: draft.path, + triggerKind: draft.triggerKind, + language: draft.language, + summary: + typeof draft.summary === "string" ? normalizeText(draft.summary) : draft.summary, + value: + typeof draft.value === "string" + ? normalizeText(draft.value) + : canonicalizeJsonValue(draft.value), + isDraft: draft.isDraft, + }) + ); +} + +function globalDraftSortKey(draft: GlobalDraft): string { + return `${draft.type}:${draft.triggerKind ?? ""}:${draft.path}`; +} + +function canonicalizeJsonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalizeJsonValue); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => [key, canonicalizeJsonValue(nested)]) + ); + } + return value; +} + +function stringifyGlobalDraftValue(value: unknown): string { + if (typeof value === "string") { + return value; + } + return JSON.stringify(value ?? null, null, 2); +} + +function truncateForDetails(value: string, maxLength = 500): string { + const normalized = value.replace(/\s+/g, " ").trim(); + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`; +} + function validateCliExpectations( assistantOutput: string, trace: CliTrace | undefined, diff --git a/ai_evals/fixtures/frontend/global/initial/format_greeting_script.json b/ai_evals/fixtures/frontend/global/initial/format_greeting_script.json new file mode 100644 index 0000000000..e66eee2ed2 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/format_greeting_script.json @@ -0,0 +1,23 @@ +{ + "workspace": { + "scripts": [ + { + "path": "f/evals/global/format_greeting", + "summary": "Format a greeting for a provided name", + "description": "Returns a plain greeting for the provided name.", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + }, + "content": "export async function main(name: string) {\n return `Hello, ${name}`\n}\n" + } + ] + } +} diff --git a/ai_evals/modes/global.ts b/ai_evals/modes/global.ts new file mode 100644 index 0000000000..d68df9f5f8 --- /dev/null +++ b/ai_evals/modes/global.ts @@ -0,0 +1,81 @@ +import { readFile } from "node:fs/promises"; +import { runGlobalEval } from "../adapters/frontend/core/global/globalEvalRunner"; +import type { BenchmarkWorkspaceRunnables } from "../adapters/frontend/mockBackend"; +import type { FrontendEvalModelConfig } from "../core/models"; +import type { BenchmarkArtifactFile, GlobalValidationSpec, ModeRunner } from "../core/types"; +import { validateGlobalState, type GlobalDraftState } from "../core/validators"; +import type { WindmillBackendSettings } from "../core/windmillBackendSettings"; +import { getFrontendApiKey } from "./frontendCommon"; + +export interface GlobalInitialFixture { + workspace?: BenchmarkWorkspaceRunnables; +} + +export function createGlobalModeRunner( + modelConfig: FrontendEvalModelConfig, + backendSettings: WindmillBackendSettings, +): ModeRunner { + return { + mode: "global", + concurrency: 3, + judgeThreshold: 80, + async loadInitial(path) { + return path ? await loadGlobalInitialFixture(path) : undefined; + }, + async loadExpected(path) { + return path ? await loadGlobalExpectedFixture(path) : undefined; + }, + async run(prompt, initial, context) { + const result = await runGlobalEval( + prompt, + getFrontendApiKey(modelConfig.provider), + { + workspaceFixtures: initial?.workspace, + maxIterations: context.evalCase?.runtime?.maxTurns, + provider: modelConfig.provider, + model: modelConfig.model, + backend: backendSettings, + runContext: context, + }, + ); + + return { + success: result.success, + actual: result.state, + error: result.error, + assistantMessageCount: result.assistantMessageCount, + toolCallCount: result.toolCallCount, + toolsUsed: result.toolsUsed, + toolCallDetails: result.toolCallDetails, + skillsInvoked: [], + tokenUsage: result.tokenUsage, + }; + }, + validate({ evalCase, actual, expected }) { + return validateGlobalState({ + actual, + expected, + validate: evalCase.validate as GlobalValidationSpec | undefined, + }); + }, + buildArtifacts(actual): BenchmarkArtifactFile[] { + return [ + { + path: "global-drafts.json", + content: JSON.stringify(actual, null, 2) + "\n", + }, + ]; + }, + }; +} + +async function loadGlobalInitialFixture(path: string): Promise { + const parsed = JSON.parse(await readFile(path, "utf8")) as GlobalInitialFixture; + return { + workspace: parsed.workspace ?? {}, + }; +} + +async function loadGlobalExpectedFixture(path: string): Promise { + return JSON.parse(await readFile(path, "utf8")) as GlobalDraftState; +} From 2ec1863340e759bba3408dbc4f41b16912b959ea Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 13 May 2026 13:35:30 +0200 Subject: [PATCH 17/40] fix: scope promotion-mode debounce key per repo (#9145) * test(git-sync): regression tests for secondary promotion repos Adds two integration tests that reproduce the bug where a second promotion-mode repo's deployment callback was silently dropped via debounce-key collision, plus the EE ref bump that includes the fix. Updates the two existing promotion-mode debounce-key tests to expect the new repo-namespaced key shape. * test(git-sync): drop redundant distinct-debounce-keys test The behavior test (`test_two_promotion_repos_both_enqueue_callback`) already covers the same regression one layer up: if the debounce keys collide, one callback gets marked skipped, which the behavior test catches. * chore: update ee-repo-ref to 7a32388adaa37eb1dd1820b40e140ff1877110f2 This commit updates the EE repository reference after PR #572 was merged in windmill-ee-private. Previous ee-repo-ref: dbf26f5e4c01c0de536f606679be46eb316aaf31 New ee-repo-ref: 7a32388adaa37eb1dd1820b40e140ff1877110f2 Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- .../tests/workspace_dependencies_git_sync.rs | 171 +++++++++++++++++- 2 files changed, 167 insertions(+), 6 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 8a34b69a0e..cad5dee291 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -f9494c6320bb5fd07c1e9e09734b7fd5fbe7aa38 +7a32388adaa37eb1dd1820b40e140ff1877110f2 diff --git a/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs b/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs index 0049280aec..a5af874f8c 100644 --- a/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs +++ b/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs @@ -573,8 +573,10 @@ async fn test_promotion_individual_branch_debounces_per_path( // Wait for both deployment callbacks to resolve a debounce key. The // alpha key is polled first as a warm-up, then we also wait for beta // so the assertions don't race the second spawned callback. - let expected_alpha = "git_sync:script:f/target/alpha"; - let expected_beta = "git_sync:script:f/target/beta"; + // Keys are namespaced by the repo's resource path so multiple promotion + // repos don't collide on the same key. + let expected_alpha = "git_sync:$res:u/test-user/test_git_repo:script:f/target/alpha"; + let expected_beta = "git_sync:$res:u/test-user/test_git_repo:script:f/target/beta"; let _ = wait_for_debounce_key(&db, expected_alpha, Duration::from_secs(5)).await?; let keys = wait_for_debounce_key(&db, expected_beta, Duration::from_secs(5)).await?; assert!( @@ -589,6 +591,162 @@ async fn test_promotion_individual_branch_debounces_per_path( Ok(()) } +/// Create a second git repository resource for multi-repo tests. +#[allow(dead_code)] +async fn create_second_git_repo_resource(db: &Pool) -> anyhow::Result<()> { + sqlx::query( + r#" + INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) + VALUES ('test-workspace', 'u/test-user/test_git_repo_2', $1::jsonb, 'git_repository', '{}'::jsonb, 'test-user') + ON CONFLICT (workspace_id, path) DO NOTHING + "#, + ) + .bind(json!({ + "url": "https://github.com/test/test2.git", + "branch": "main", + "token": "test-token-2" + })) + .execute(db) + .await?; + Ok(()) +} + +/// Configure git sync with TWO promotion-mode repositories pointing at distinct +/// git repo resources. Both repos use the same sync script and the same item +/// filters — they only differ in the repo they target. +#[allow(dead_code)] +async fn setup_two_promotion_repos_config( + db: &Pool, + sync_script_path: &str, + group_by_folder: bool, +) -> anyhow::Result<()> { + let git_sync_config = json!({ + "include_type": ["script"], + "include_path": ["**"], + "repositories": [ + { + "script_path": sync_script_path, + "git_repo_resource_path": "$res:u/test-user/test_git_repo", + "use_individual_branch": true, + "group_by_folder": group_by_folder + }, + { + "script_path": sync_script_path, + "git_repo_resource_path": "$res:u/test-user/test_git_repo_2", + "use_individual_branch": true, + "group_by_folder": group_by_folder + } + ] + }); + + sqlx::query!( + "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", + git_sync_config, + "test-workspace" + ) + .execute(db) + .await?; + + Ok(()) +} + +/// Regression test for: when two promotion-mode repos are configured with the +/// same `use_individual_branch=true` settings (i.e. a primary and a secondary +/// promotion repo), deploying a single script must enqueue ONE deployment +/// callback per repo. Both callbacks must remain in the queue — neither may +/// be debounced into oblivion by the other. +/// +/// The bug this guards against: the debounce key for promotion mode was +/// derived only from (path_type, path) and omitted any per-repo identifier, +/// so the second repo's push hit ON CONFLICT in `upsert_debounce_key` and +/// `complete_debounced_job` flagged the first repo's job as `status='skipped'` +/// — silently dropping one of the two pushes. +#[cfg(all(feature = "enterprise", feature = "private"))] +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_two_promotion_repos_both_enqueue_callback(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + create_folder(&db, "28103").await?; + create_folder(&db, "target").await?; + create_git_repo_resource(&db).await?; + create_second_git_repo_resource(&db).await?; + let sync_script_path = "f/28103/test_sync_two_promotion_repos"; + create_sync_script(&db, sync_script_path).await?; + setup_two_promotion_repos_config(&db, sync_script_path, false).await?; + + let (client, _port, _server) = init_client(db.clone()).await; + + // Deploy a single script — handle_deployment_metadata should iterate + // both repos and create one callback job per repo. + create_test_script(&client, "f/target/alpha").await?; + + // Both callbacks should reach the queue. With the bug, only one survives + // (the other is moved to v2_job_completed with status='skipped'). + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + let mut last_jobs: Vec = vec![]; + loop { + last_jobs = + get_deployment_callback_jobs(&db, sync_script_path, Duration::from_millis(200)).await?; + if last_jobs.len() >= 2 { + break; + } + if tokio::time::Instant::now() >= deadline { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // Inspect what landed in v2_job_completed so failure messages explain why. + let skipped: Vec<(uuid::Uuid, String)> = sqlx::query_as( + r#" + SELECT c.id, c.status::text + FROM v2_job_completed c + JOIN v2_job j ON j.id = c.id + WHERE j.runnable_path = $1 AND j.kind = 'deploymentcallback' + "#, + ) + .bind(sync_script_path) + .fetch_all(&db) + .await?; + + assert_eq!( + last_jobs.len(), + 2, + "expected 2 deployment callback jobs in v2_job_queue (one per promotion repo), got {} queued + {:?} completed", + last_jobs.len(), + skipped, + ); + + // Per-repo args sanity check: the two jobs must target different repos. + let mut repo_paths: Vec = last_jobs + .iter() + .filter_map(|j| { + j.args + .as_ref() + .and_then(|a| a.get("repo_url_resource_path")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .collect(); + repo_paths.sort(); + repo_paths.dedup(); + assert_eq!( + repo_paths.len(), + 2, + "expected callbacks to target two distinct repos, got: {:?}", + last_jobs.iter().map(|j| &j.args).collect::>() + ); + + // No callback should have been silently skipped via debouncing collision. + assert!( + skipped.iter().all(|(_, s)| s != "skipped"), + "no deployment callback should be marked skipped, got: {:?}", + skipped, + ); + + Ok(()) +} + /// Promotion mode with group_by_folder: items destined for the same per-folder /// branch must share one debounce key so they accumulate into a single sync /// job; scripts in different folders must get distinct keys. @@ -615,8 +773,9 @@ async fn test_promotion_group_by_folder_debounces_per_folder( // One in a different folder — should get its own key. create_test_script(&client, "f/other/gamma").await?; - let expected_grouped = "git_sync:folder:f/grouped"; - let expected_other = "git_sync:folder:f/other"; + // Keys are namespaced by the repo's resource path. + let expected_grouped = "git_sync:$res:u/test-user/test_git_repo:folder:f/grouped"; + let expected_other = "git_sync:$res:u/test-user/test_git_repo:folder:f/other"; // Wait for BOTH folder keys to appear, not just the first one. let keys = wait_for_debounce_key(&db, expected_other, Duration::from_secs(5)).await?; assert!( @@ -629,7 +788,9 @@ async fn test_promotion_group_by_folder_debounces_per_folder( ); // Paths within the same folder must NOT leak as their own keys. assert!( - !keys.iter().any(|k| k.starts_with("git_sync:script:")), + !keys + .iter() + .any(|k| k.contains(":script:f/grouped/") || k.contains(":script:f/other/")), "group_by_folder mode should not emit per-path keys, got: {keys:?}" ); From 818cb31fbc731fa5c70ddf5942bb37bc4bc56e4d Mon Sep 17 00:00:00 2001 From: "windmill-internal-app[bot]" <217088191+windmill-internal-app[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 12:32:35 +0000 Subject: [PATCH 18/40] fix: send flow push-loop ping outside transaction so zombie monitor sees it (#9136) * fix: send flow push-loop ping outside transaction so zombie monitor sees it * fix: keep flow push-loop ping using now() with reusable sqlx cache --------- Co-authored-by: windmill-internal-app[bot] <1429786+windmill-internal-app[bot]@users.noreply.github.com> Co-authored-by: Ruben Fiszel --- backend/windmill-worker/src/worker_flow.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 53c94a7458..68ca83c270 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -3919,11 +3919,14 @@ async fn push_next_flow_job( for (i, payload_tag) in job_payloads.into_iter().enumerate() { if i % 100 == 0 && i != 0 { tracing::info!(id = %flow_job.id, root_id = %job_root, "pushed (non-commited yet) first {i} subflows of {len}"); + // Ping on the pool, outside `tx`, so the zombie flow monitor sees it before the + // push transaction commits — otherwise large parallel pushes can be flagged as + // zombie and trigger a cancel/push deadlock. sqlx::query!( - "UPDATE v2_job_runtime SET ping = now() WHERE id = $1 AND ping < now()", + "UPDATE v2_job_runtime SET ping = now() WHERE id = $1", flow_job.id, ) - .execute(&mut *tx) + .execute(db) .warn_after_seconds(3) .await?; } From d243e0cde899b6d34ee6d54be76bd46f08c4b64a Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 13 May 2026 14:38:49 +0200 Subject: [PATCH 19/40] align global flow tool arguments (#9146) --- ai_evals/cases/global.yaml | 34 +++++ .../copilot/chat/global/core.test.ts | 77 ++++++++-- .../components/copilot/chat/global/core.ts | 144 +++++++++++------- 3 files changed, 191 insertions(+), 64 deletions(-) diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 709081942b..b4526f8a85 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -53,3 +53,37 @@ - uppercases the provided name in the greeting - returns a message ending with an exclamation mark - does not deploy or save the draft to the workspace + +- id: global-test3-flow-create + prompt: |- + Create a draft flow at `f/evals/global/sum_numbers`. + It should take two numeric inputs, `a` and `b`, and return their sum. + Leave it as an AI draft only; do not deploy or save it. + runtime: + maxTurns: 8 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: flow + path: f/evals/global/sum_numbers + valueIncludes: + - modules + - rawscript + - flow_input.a + - flow_input.b + toolExpect: + requiredToolsUsed: + - write_flow + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: write_flow + field: modules + stringStartsWithAnyOf: + - "[" + judgeChecklist: + - creates a flow draft at f/evals/global/sum_numbers + - the flow accepts numeric inputs a and b + - the flow returns the sum of a and b + - the result stays as an AI draft and is not deployed or saved to the workspace diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 2664c1495d..2732db4dac 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -113,19 +113,17 @@ describe('global AI tools', () => { await callGlobalTool('write_flow', { path: 'f/flows/empty-module', summary: 'Flow with empty module', - value: JSON.stringify({ - modules: [ - { - id: 'empty_step', - value: { - type: 'rawscript', - language: 'bun', - content: '', - input_transforms: {} - } + modules: JSON.stringify([ + { + id: 'empty_step', + value: { + type: 'rawscript', + language: 'bun', + content: '', + input_transforms: {} } - ] - }) + } + ]) }) const code = 'export async function main() {\n\treturn 42\n}' @@ -145,4 +143,59 @@ describe('global AI tools', () => { }) ).resolves.toBe(code) }) + + it('writes flows with flow-mode arguments and reads compact flow value', async () => { + const writeResult = JSON.parse( + await callGlobalTool('write_flow', { + path: 'f/flows/with-schema-and-groups', + summary: 'Flow with schema and groups', + modules: JSON.stringify([ + { + id: 'start', + summary: 'Start', + value: { + type: 'identity' + } + } + ]), + schema: JSON.stringify({ + type: 'object', + properties: { + name: { type: 'string' } + }, + required: ['name'] + }), + groups: JSON.stringify([{ summary: 'Main', start_id: 'start', end_id: 'start' }]) + }) + ) + + expect(writeResult.item.value.value).toBeUndefined() + + const raw = await callGlobalTool('read_workspace_item', { + type: 'flow', + path: 'f/flows/with-schema-and-groups' + }) + const item = JSON.parse(raw) + + expect(item.value).toMatchObject({ + modules: [ + { + id: 'start', + summary: 'Start', + value: { type: 'identity' } + } + ], + schema: { + type: 'object', + properties: { + name: { type: 'string' } + }, + required: ['name'] + }, + preprocessor_module: null, + failure_module: null, + groups: [{ summary: 'Main', start_id: 'start', end_id: 'start' }] + }) + expect(item.value.value).toBeUndefined() + }) }) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index de18426a3c..210636e43d 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -37,6 +37,7 @@ import { import { applyEditableFlowJsonToFlow, buildEditableFlowJson, + type EditableFlowJson, validateEditableFlowJson } from '../flow/editableFlowJson' import { createInlineScriptSession } from '../flow/inlineScriptsUtils' @@ -59,7 +60,6 @@ import { type ToolCallbacks, type ToolDisplayAction } from '../shared' -import { flowModuleSchema, flowModulesSchema } from '../flow/openFlowZod.gen' import { resourceRequestSchema, scheduleRequestSchema, @@ -149,22 +149,6 @@ const writeScriptSchema = z.object({ content: z.string().describe('Full script source code.') }) -const flowValueSchema = z - .looseObject({ - modules: flowModulesSchema.describe('Sequential flow modules.'), - preprocessor_module: flowModuleSchema - .nullable() - .optional() - .describe( - "Optional preprocessor module with id 'preprocessor'. Runs before normal modules; cannot reference results.*." - ), - failure_module: flowModuleSchema - .nullable() - .optional() - .describe("Optional failure handler module with id 'failure'.") - }) - .describe('OpenFlow value: modules plus optional preprocessor_module and failure_module.') - const readFlowModuleCodeSchema = z.object({ path: z.string().describe('Workspace path of the flow.'), module_id: z @@ -184,24 +168,81 @@ const setFlowModuleCodeSchema = z.object({ code: z.string().describe('New script source. Replaces the module\'s value.content entirely.') }) -// `value` is taken as a JSON string rather than a typed object because the -// underlying flowValueSchema is recursive (modules can contain modules), which -// makes z.toJSONSchema emit $defs/$ref. Gemini's tools API rejects those -// keywords ("Unknown name $ref/$defs"). The string is parsed and validated -// against flowValueSchema inside the handler. Same trick as set_flow_json in -// chat/flow/core.ts (see comment on its schema). +// Flow structure fields are taken as JSON strings rather than typed objects +// because the underlying flow module schema is recursive (modules can contain +// modules), which makes z.toJSONSchema emit $defs/$ref. Gemini's tools API +// rejects those keywords ("Unknown name $ref/$defs"). Same trick as +// set_flow_json in chat/flow/core.ts. const writeFlowSchema = z.object({ path: z .string() .describe('Workspace path of the flow, e.g. f/folder/name or u/user/name.'), summary: z.string().optional().describe('Short human-readable summary.'), - value: z + modules: z.string().describe('JSON string containing the complete flow modules array.'), + schema: z .string() + .optional() + .nullable() + .describe('JSON string containing the flow input schema.'), + preprocessor_module: z + .string() + .optional() + .nullable() + .describe('JSON string containing the optional preprocessor module.'), + failure_module: z + .string() + .optional() + .nullable() + .describe('JSON string containing the optional failure module.'), + groups: z + .string() + .optional() + .nullable() .describe( - 'JSON string of the OpenFlow value object: { modules, preprocessor_module?, failure_module? }. Pass it as a JSON-encoded string, not a nested object.' + 'JSON string containing the optional array of semantic flow groups. Pass null to clear groups.' ) }) +function parseOptionalJsonArg(value: unknown, field: string): unknown { + if (value === undefined || value === null) { + return value + } + + try { + return typeof value === 'string' ? JSON.parse(value) : value + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Invalid JSON for ${field}: ${message}`) + } +} + +function editableFlowToDraftValue(editable: EditableFlowJson): FlowDraftValue { + const value: FlowValue = { + modules: editable.modules, + preprocessor_module: editable.preprocessor_module ?? undefined, + failure_module: editable.failure_module ?? undefined, + groups: editable.groups ?? undefined + } + return { + value, + schema: editable.schema, + groups: editable.groups + } +} + +function flowDraftAsEditableInput(flowDraft: FlowDraftValue): { + value: FlowValue + schema?: Record | null | undefined +} { + return { + value: + flowDraft.groups === undefined + ? flowDraft.value + : { ...flowDraft.value, groups: flowDraft.groups ?? undefined }, + schema: flowDraft.schema + } +} + const writeScheduleSchema = scheduleRequestSchema const writeTriggerSchema = z.object({ @@ -428,7 +469,7 @@ Important rules: - Use search_resource_types before write_resource to discover the resource_type name and the JSON Schema its value must match. - Use get_instructions before writing a script, flow, resource, or app. For scripts, pass the target language; when modifying, use the language from the item you read. - Schedules, triggers, and variables do not need get_instructions — their tool schemas describe every field. -- A workspace item is { type, path, summary?, language?, triggerKind?, value, isDraft }. For scripts, value is the source code string. For flows, value is { value: , schema, groups } so the inputs schema and groups round-trip through deploy. For schedules/triggers/resources/variables, value is the full request body for that type. For apps, value is { files, runnables, data?, policy?, custom_path? } with frontend file contents and backend runnable definitions. +- A workspace item is { type, path, summary?, language?, triggerKind?, value, isDraft }. For scripts, value is the source code string. For flows, read_workspace_item returns value as the compact flow object { modules, schema, preprocessor_module, failure_module, groups }; write_flow takes the same flow fields as top-level tool arguments plus path/summary. For schedules/triggers/resources/variables, value is the full request body for that type. For apps, value is { files, runnables, data?, policy?, custom_path? } with frontend file contents and backend runnable definitions. - Apps (raw apps): use list_workspace_items with types: ['app'] to find them, read_workspace_item with type 'app' for a metadata summary (file paths + runnable list, no contents), then read_app_file to read individual files. Edit with write_app_file / patch_app_file / delete_app_file for frontend files and write_app_runnable / delete_app_runnable for backend runnables. Frontend file paths start with "/" (e.g. /index.tsx). Backend inline runnables are addressed as "backend//main.{ts|py}". /wmill.d.ts is generated and cannot be written. - To create a new raw app, use init_app. Before calling it, confirm framework (react19 / react18 / svelte5 / vue), path, and summary with the user — do not silently default to react19, even though it is the recommended choice. - Apps cannot be deployed from chat. The app editor bundles JS/CSS before save; tell the user to open the app editor to deploy app drafts. @@ -496,7 +537,7 @@ function serializeWorkspaceItemForRead(item: WorkspaceItem): unknown { if (item.type !== 'flow' || !item.value) return item const flowDraft = item.value as FlowDraftValue const session = createInlineScriptSession() - const editable = buildEditableFlowJson(flowDraft, session) + const editable = buildEditableFlowJson(flowDraftAsEditableInput(flowDraft), session) return { type: 'flow', path: item.path, @@ -1074,8 +1115,9 @@ function getFlowInstructions(): string { return `# Global draft flow instructions - Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata. -- A flow draft is a workspace item: \`{ type: 'flow', path, summary?, value, isDraft }\` where \`value\` is \`{ value: , schema, groups }\`. The inputs schema and groups are kept alongside the OpenFlow value so deploy round-trips them. -- \`value.modules\` contains normal sequential modules. Use top-level \`value.preprocessor_module\` and \`value.failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`value.modules\`. +- \`write_flow\` mirrors flow mode's \`set_flow_json\`: pass \`path\`, optional \`summary\`, required \`modules\`, and optional \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`. The flow-structure arguments are JSON strings, matching the tool schema descriptions. +- \`read_workspace_item\` returns a compact flow \`value\` object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`. +- \`modules\` contains normal sequential modules. Use top-level \`preprocessor_module\` and \`failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`modules\`. - Every module needs a stable unique \`id\` and a useful \`summary\` when the schema supports it. - Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed. - When writing rawscript module code, call \`get_instructions\` with \`subject: "script"\` and the rawscript language first. @@ -1087,7 +1129,7 @@ function getFlowInstructions(): string { - \`read_flow_module_code(path, module_id)\` — returns the raw inline script content for one module. - \`set_flow_module_code(path, module_id, code)\` — overwrites that module's inline script content; saves to the AI draft. - Use \`patch_flow_json\` for *structural* edits: module ids, paths, input_transforms, branch arrangement, summaries, preprocessor/failure swaps, schema/groups. Use \`set_flow_module_code\` for changes inside a specific rawscript body. -- \`write_flow\` is for full overwrites / create-from-scratch. Its \`value\` argument is the **non-compact** OpenFlow value (rawscript content is the actual code, not a placeholder). +- \`write_flow\` is for full overwrites / create-from-scratch. Its \`modules\`, \`preprocessor_module\`, and \`failure_module\` arguments use **non-compact** flow modules (rawscript content is the actual code, not a placeholder). # Windmill flow authoring reference @@ -1271,35 +1313,29 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( writeFlowSchema, 'write_flow', - 'Create or overwrite an AI draft flow. Does not save or deploy. Read the existing flow first when overwriting. value must be a JSON-encoded string of the OpenFlow value object.' + 'Create or overwrite an AI draft flow. Does not save or deploy. Read the existing flow first when overwriting. Uses the same flow-structure arguments as set_flow_json plus path and summary.' ), showDetails: true, streamArguments: true, showFade: true, fn: async (ctx) => { const parsed = writeFlowSchema.parse(ctx.args) - let value: unknown - try { - value = JSON.parse(parsed.value) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - throw new Error(`Invalid JSON for value: ${message}`) - } - const validated = flowValueSchema.safeParse(value) - if (!validated.success) { - throw new Error( - `Invalid flow value: ${validated.error.issues - .slice(0, 5) - .map((i) => `${i.path.join('.')}: ${i.message}`) - .join('; ')}` - ) - } + const editable = validateEditableFlowJson({ + modules: parseOptionalJsonArg(parsed.modules, 'modules'), + schema: parseOptionalJsonArg(parsed.schema, 'schema'), + preprocessor_module: parseOptionalJsonArg( + parsed.preprocessor_module, + 'preprocessor_module' + ), + failure_module: parseOptionalJsonArg(parsed.failure_module, 'failure_module'), + groups: parseOptionalJsonArg(parsed.groups, 'groups') + }) return writeDraft( { type: 'flow', path: parsed.path, summary: parsed.summary, - value: { value: validated.data as FlowValue, schema: null, groups: null }, + value: editableFlowToDraftValue(editable), isDraft: true }, ctx @@ -1683,7 +1719,7 @@ async function patchFlowJson( // model uses set_flow_module_code to change inline script bodies. const base = await loadFlowDraftValue(path, ctx.workspace) const session = createInlineScriptSession() - const editable = buildEditableFlowJson(base.flow, session) + const editable = buildEditableFlowJson(flowDraftAsEditableInput(base.flow), session) const currentJson = JSON.stringify(editable) const updatedJson = findAndReplace( currentJson, @@ -1730,7 +1766,7 @@ async function readFlowModuleCode( }) const base = await loadFlowDraftValue(args.path, workspace) const session = createInlineScriptSession() - buildEditableFlowJson(base.flow, session) + buildEditableFlowJson(flowDraftAsEditableInput(base.flow), session) const content = session.get(args.module_id) if (content === undefined) { throw new Error( @@ -1753,7 +1789,7 @@ async function setFlowModuleCode( }) const base = await loadFlowDraftValue(args.path, workspace) const session = createInlineScriptSession() - const editable = buildEditableFlowJson(base.flow, session) + const editable = buildEditableFlowJson(flowDraftAsEditableInput(base.flow), session) if (!session.has(args.module_id)) { throw new Error( `Module "${args.module_id}" is not an inline rawscript in flow "${args.path}". Use patch_flow_json or write_flow for structural changes.` @@ -2342,6 +2378,10 @@ async function writeDraft(item: WorkspaceItem, ctx: WriteDraftCtx): Promise Date: Wed, 13 May 2026 13:04:31 +0000 Subject: [PATCH 20/40] fix(bun): pass --preserve-symlinks on unbundled execution (#9147) * fix(bun): pass --preserve-symlinks on unbundled execution Bun 1.2/1.3 moved its global package cache to a content-addressed layout and the installer now creates a single directory symlink from node_modules/ to the cache entry. Without --preserve-symlinks, Bun resolves modules from each file's realpath, so any require/import inside an installed package walks up from cache_nomount/bun/... and never finds the sibling deps living under /node_modules/. This manifested as e.g. ENOENT while resolving package 'zod/v3' from '/tmp/windmill/cache_nomount/bun/@langchain/core@1.1.44@@@1/dist/...' on //nobundling scripts that pull @langchain/core, even though zod is correctly installed alongside it in node_modules. The bundled execution path already had --preserve-symlinks since #4132 (needed because we symlink the cached bundle file into the job dir). The unbundled path didn't, because at the time Bun installed via per- file hardlinks and the realpath of node_modules entries was the job dir itself. The Bun installer's layout change made the flag necessary on the unbundled path as well. Add the flag to all three unbundled `bun run` invocations: - nsjail unbundled path - non-nsjail unbundled path - dedicated worker (always unbundled) This also fixes a latent bug on the first run of any bun script that imports a package whose internals reference siblings (the build_cache path runs unbundled this round while it builds the bundle for next time). Co-Authored-By: Claude Opus 4.7 (1M context) * test(bun): regression test for nobundling + transitive require resolution Adds an integration test that mirrors the original failure: a //nobundling script importing @langchain/core, which (in its CJS internals) does require('zod/v3'). Before --preserve-symlinks was added to the unbundled bun run invocations, this failed with: ENOENT while resolving package 'zod/v3' from '.../cache_nomount/bun/@langchain/core@@@@1/dist/runnables/base.js' The test covers the non-nsjail unbundled path. Reproducibility of the pre-fix failure depends on Bun's installer choosing the directory-symlink layout for the node_modules entry (the default on Bun 1.2/1.3+ with the new content-addressed global cache that produced the user's error). Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- backend/tests/bun_jobs.rs | 57 +++++++++++++++++++++ backend/windmill-worker/src/bun_executor.rs | 3 ++ 2 files changed, 60 insertions(+) diff --git a/backend/tests/bun_jobs.rs b/backend/tests/bun_jobs.rs index fa15866bca..d51e51f58b 100644 --- a/backend/tests/bun_jobs.rs +++ b/backend/tests/bun_jobs.rs @@ -577,6 +577,63 @@ export function main() { Ok(()) } +/// Regression test: a `//nobundling` script that pulls a package whose CJS +/// internals do bare-specifier `require()` of a sibling dependency. +/// +/// Before the `--preserve-symlinks` fix, Bun 1.2/1.3+ would follow the +/// directory symlink in `node_modules/@langchain/core` to its global cache +/// entry, walk parent dirs from the cache realpath, and fail to find +/// `node_modules/zod` — producing: +/// ENOENT while resolving package 'zod/v3' from +/// '.../cache_nomount/bun/@langchain/core@@@@1/dist/runnables/base.js' +/// +/// The fix passes `--preserve-symlinks` so Bun resolves from the +/// symlink path under `/node_modules/`, where `zod` is a sibling. +#[sqlx::test(fixtures("base"))] +async fn test_bun_nobundling_transitive_require(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let content = r#"//nobundling +import { ChatPromptTemplate } from "@langchain/core/prompts"; + +export async function main() { + const tpl = ChatPromptTemplate.fromMessages([ + ["system", "you are a {role}"], + ["human", "{input}"], + ]); + const out = await tpl.formatMessages({ role: "tester", input: "ping" }); + return out.length; +} +"# + .to_owned(); + + let job = JobPayload::Code(RawCode { + hash: None, + content, + path: None, + language: ScriptLang::Bun, + lock: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + modules: None, + tag: None, + }); + + let result = run_job_in_new_worker_until_complete(&db, false, job, port) + .await + .json_result() + .unwrap(); + + assert_eq!(result, serde_json::json!(2)); + Ok(()) +} + // ============================================================================ // Native Mode Tests (requires deno_core feature) // ============================================================================ diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 5e640c1fde..97ff28cae1 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -2172,6 +2172,7 @@ try {{ "--", &BUN_PATH, "run", + "--preserve-symlinks", "-i", "--prefer-offline", "-r", @@ -2238,6 +2239,7 @@ try {{ } else { vec![ "run", + "--preserve-symlinks", "-i", "--prefer-offline", "-r", @@ -3895,6 +3897,7 @@ pub async fn start_worker( common_bun_proc_envs, vec![ "run", + "--preserve-symlinks", "-i", "--prefer-offline", "-r", From dd19e52a84fb9a9f48e3ad061b084841c2ee7464 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 13 May 2026 14:58:35 +0000 Subject: [PATCH 21/40] perf(dynselect): only retrigger when helper args actually change (#9148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(dynselect): only retrigger when helper-script args actually change Parse the inline helper's signature with the existing WASM parser and restrict the form-arg diff to keys the helper actually consumes. Typing into unrelated fields no longer queues a dynselect job every second. Falls back to the previous full-args comparison when the helper is deployed or parsing fails. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(dynselect): avoid double helper-script fetch on mount usePromise defaults to loadInit=true, so refresh() ran before the JobLoader child was bound (firing a no-op pending promise) and the $effect then fired a second refresh once the bind:this resolved. Disable loadInit so the effect owns the single first call. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(dynselect): use parser directly instead of inferArgs inferArgs mutates a Schema object we never use and goes through a shared cache; when fed an empty schema for non-main entrypoints the caller cannot reliably read back the resulting properties. Add parseEntrypointArgs that just runs the parser and returns the parameter name Set (or undefined when unknown / unsupported / has rest args / function not found). DynamicInput uses that and keeps the previous params in flight while the next parse is computing. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(dynselect): support deployed helpers in smart retrigger Add getHelperEntrypointArgs which dispatches on HelperScript.source: inline parses immediately; deployed fetches the script (or the flow's inline dyn-select code) once and caches per (workspace, kind, path, entrypoint). Without this the /scripts/get/* run view fell back to the full-args comparison and still retriggered on unrelated fields. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(dynselect): zero-arg helpers report empty deps, not unknown Codex review flagged that a valid zero-parameter entrypoint was being treated as "couldn't determine signature" and falling back to the full-args comparison. Distinguish "function found with no params" from "function not found" via the parser's auto_kind field — only the latter sets it, so empty args + auto_kind=null means a real zero-arg helper and we return an empty Set (no retrigger on unrelated fields). Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/lib/components/DynamicInput.svelte | 41 ++++++++- frontend/src/lib/infer.ts | 90 ++++++++++++++++++- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/DynamicInput.svelte b/frontend/src/lib/components/DynamicInput.svelte index 692272d50e..f4e2ba9c75 100644 --- a/frontend/src/lib/components/DynamicInput.svelte +++ b/frontend/src/lib/components/DynamicInput.svelte @@ -25,6 +25,7 @@ import { type DynamicInput } from '$lib/utils' import { deepEqual } from 'fast-equals' import { untrack } from 'svelte' + import { getHelperEntrypointArgs } from '$lib/infer' interface Props { value?: any @@ -48,7 +49,9 @@ }) let resultJobLoader: JobLoader | undefined = $state() - let _items = usePromise(getItemsFromOptions, { clearValueOnRefresh: false }) + // loadInit:false — the $effect below owns the first refresh once + // resultJobLoader is bound; without this the promise is kicked off twice. + let _items = usePromise(getItemsFromOptions, { clearValueOnRefresh: false, loadInit: false }) let items = $derived(_items.value) let filterText: string = $state('') @@ -125,9 +128,43 @@ }, 1000) }) + // Parameter names declared by the helper function. When known, we restrict + // the change-detection to only those keys so typing in unrelated form fields + // no longer retriggers the dynselect job. `undefined` means we couldn't + // determine the signature → fall back to a full-args comparison. + let helperParams = $state | undefined>(undefined) + + $effect(() => { + const script = helperScript + const ep = entrypoint + if (!script) { + helperParams = undefined + return + } + let cancelled = false + void getHelperEntrypointArgs(script, ep || undefined).then((params) => { + if (!cancelled) helperParams = params + }) + return () => { + cancelled = true + } + }) + + function filterArgs(args: Record | undefined) { + if (!args || !helperParams) return args + const filtered: Record = {} + for (const k of helperParams) { + if (k in args) filtered[k] = args[k] + } + return filtered + } + $effect(() => { ;[filterText, entrypoint, helperScript] - if (resultJobLoader && (open || neverLoaded || !deepEqual(lastArgs, nargs))) { + if ( + resultJobLoader && + (open || neverLoaded || !deepEqual(filterArgs(lastArgs), filterArgs(nargs))) + ) { neverLoaded = false lastArgs = $state.snapshot(otherArgs) _items.refresh() diff --git a/frontend/src/lib/infer.ts b/frontend/src/lib/infer.ts index 1c5ec7557e..7c8b83a64f 100644 --- a/frontend/src/lib/infer.ts +++ b/frontend/src/lib/infer.ts @@ -7,7 +7,13 @@ import { } from '$lib/gen' import { get, writable } from 'svelte/store' import type { Schema, SupportedLanguage } from './common.js' -import { emptySchema, getHubFlowIdFromPath, isHubFlowPath, sortObject } from './utils.js' +import { + type DynamicInput, + emptySchema, + getHubFlowIdFromPath, + isHubFlowPath, + sortObject +} from './utils.js' import { tick } from 'svelte' import initTsParser, { parse_deno, parse_outputs } from 'windmill-parser-wasm-ts' @@ -286,6 +292,88 @@ const SQL_LANGUAGES = [ 'duckdb' ] +/** + * Returns the parameter names of `entrypoint` in `code` (or `main` if not given), + * or `undefined` if the function can't be found, the code can't be parsed, the + * language isn't supported here, or the signature contains rest/keyword args + * (in which case the callee should fall back to a conservative full comparison). + * + * Lighter than {@link inferArgs} — does not touch any schema. + */ +export async function parseEntrypointArgs( + language: SupportedLanguage | 'bunnative' | undefined, + code: string, + entrypoint?: string +): Promise | undefined> { + if (!code) return undefined + try { + let sig: MainArgSignature + if (language === 'python3') { + await initWasmPython() + sig = JSON.parse(parse_python(code, entrypoint)) + } else if ( + language === 'deno' || + language === 'nativets' || + language === 'bun' || + language === 'bunnative' + ) { + await initWasmTs() + sig = JSON.parse(parse_deno(code, entrypoint)) + } else { + return undefined + } + if (sig.type === 'Invalid') return undefined + if (sig.star_args || sig.star_kwargs) return undefined + if (!Array.isArray(sig.args)) return undefined + // The parser sets auto_kind when no matching entrypoint function was + // found — empty args in that case means "unknown signature", not + // "function takes no params", so we fall back to a full comparison. + if (sig.args.length === 0 && sig.auto_kind != null) return undefined + return new Set(sig.args.map((a) => a.name)) + } catch { + return undefined + } +} + +const helperEntrypointCache = new Map | undefined>() + +/** + * Resolves a {@link DynamicInput.HelperScript} to its entrypoint parameter + * names. For deployed helpers it fetches the script (or the flow's inline + * dyn-select code) once and caches the result per workspace+path+entrypoint. + */ +export async function getHelperEntrypointArgs( + helper: DynamicInput.HelperScript, + entrypoint?: string +): Promise | undefined> { + if (helper.source === 'inline') { + return parseEntrypointArgs(helper.lang, helper.code, entrypoint) + } + const workspace = get(workspaceStore) + if (!workspace) return undefined + const cacheKey = `${workspace}::${helper.runnable_kind}::${helper.path}::${entrypoint ?? ''}` + if (helperEntrypointCache.has(cacheKey)) return helperEntrypointCache.get(cacheKey) + let result: Set | undefined + try { + if (helper.runnable_kind === 'script') { + const script = await ScriptService.getScriptByPath({ workspace, path: helper.path }) + result = await parseEntrypointArgs(script.language, script.content ?? '', entrypoint) + } else { + const flow = await FlowService.getFlowByPath({ workspace, path: helper.path }) + const schema = flow.schema as Record | undefined + const code = schema?.['x-windmill-dyn-select-code'] + const lang = schema?.['x-windmill-dyn-select-lang'] + if (typeof code === 'string' && typeof lang === 'string') { + result = await parseEntrypointArgs(lang as SupportedLanguage, code, entrypoint) + } + } + } catch { + result = undefined + } + helperEntrypointCache.set(cacheKey, result) + return result +} + export async function inferArgs( language: SupportedLanguage | 'bunnative' | undefined, code: string, From c5092069cbeda2c4c18bea80dd629c7c087b30bf Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 13 May 2026 15:05:25 +0000 Subject: [PATCH 22/40] fix: align script path existence check with deploy logic; hide Delete for non-admin (#9152) - exists_script_by_path now filters archived = false, matching the conflict check in create_script_internal. Previously the frontend blocked creating a new script at a path occupied only by archived scripts, even though renaming to that same path was allowed. - Hide the Delete entry in the script details "..." menu unless the user is admin. The backend delete_script_by_hash already requires admin, so non-admins would always see an error after clicking. --- ...a8a5f2e75d3b12ee4718452e82c7318b1bcf4.json | 23 ------------------- backend/windmill-api-scripts/src/scripts.rs | 22 ++++++++---------- .../scripts/get/[...hash]/+page.svelte | 18 ++++++++------- 3 files changed, 19 insertions(+), 44 deletions(-) delete mode 100644 backend/.sqlx/query-2a49e5b5486b650d96f3e9038cba8a5f2e75d3b12ee4718452e82c7318b1bcf4.json diff --git a/backend/.sqlx/query-2a49e5b5486b650d96f3e9038cba8a5f2e75d3b12ee4718452e82c7318b1bcf4.json b/backend/.sqlx/query-2a49e5b5486b650d96f3e9038cba8a5f2e75d3b12ee4718452e82c7318b1bcf4.json deleted file mode 100644 index 77f61ccc47..0000000000 --- a/backend/.sqlx/query-2a49e5b5486b650d96f3e9038cba8a5f2e75d3b12ee4718452e82c7318b1bcf4.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1)", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "exists", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "2a49e5b5486b650d96f3e9038cba8a5f2e75d3b12ee4718452e82c7318b1bcf4" -} diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index de0f38cd34..93c524f10a 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -978,12 +978,10 @@ async fn create_script_internal<'c>( .fetch_one(&mut *tx) .await?; } - let clashing_script = sqlx::query_as::<_, Script>( - &format!( - "SELECT {} FROM script WHERE path = $1 AND archived = false AND workspace_id = $2", - windmill_common::scripts::SCRIPT_COLUMNS, - ), - ) + let clashing_script = sqlx::query_as::<_, Script>(&format!( + "SELECT {} FROM script WHERE path = $1 AND archived = false AND workspace_id = $2", + windmill_common::scripts::SCRIPT_COLUMNS, + )) .bind(&ns.path) .bind(&w_id) .fetch_optional(&mut *tx) @@ -2248,7 +2246,7 @@ async fn exists_script_by_path( let path = path.to_path(); let exists = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1)", + "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false ORDER BY created_at DESC LIMIT 1)", path, w_id ) @@ -2282,12 +2280,10 @@ async fn get_script_by_hash_internal<'c>( .fetch_optional(&mut **db) .await? } else { - sqlx::query_as::<_, ScriptWithStarred>( - &format!( - "SELECT {}, NULL as starred FROM script WHERE hash = $1 AND workspace_id = $2", - windmill_common::scripts::SCRIPT_COLUMNS, - ), - ) + sqlx::query_as::<_, ScriptWithStarred>(&format!( + "SELECT {}, NULL as starred FROM script WHERE hash = $1 AND workspace_id = $2", + windmill_common::scripts::SCRIPT_COLUMNS, + )) .bind(hash) .bind(workspace_id) .fetch_optional(&mut **db) diff --git a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte index ff80e76177..12ca517490 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte @@ -565,14 +565,16 @@ }) } - menuItems.push({ - label: 'Delete', - Icon: Trash, - onclick: async () => { - deleteScript(script.hash) - }, - color: 'red' - }) + if ($userStore?.is_admin) { + menuItems.push({ + label: 'Delete', + Icon: Trash, + onclick: async () => { + deleteScript(script.hash) + }, + color: 'red' + }) + } } return menuItems From 110bef0a6e76615c7b371c5c0f5bc1f4e7a73a64 Mon Sep 17 00:00:00 2001 From: "windmill-internal-app[bot]" <217088191+windmill-internal-app[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 15:12:11 +0000 Subject: [PATCH 23/40] fix: Allow devops role to use all_workspaces runs filter in admins workspace (#9153) Co-authored-by: windmill-internal-app[bot] <1429786+windmill-internal-app[bot]@users.noreply.github.com> --- frontend/src/lib/components/RunsPage.svelte | 6 +++--- frontend/src/lib/components/runs/runsFilter.ts | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte index 86b7588099..360eb9276d 100644 --- a/frontend/src/lib/components/RunsPage.svelte +++ b/frontend/src/lib/components/RunsPage.svelte @@ -10,7 +10,7 @@ } from '$lib/gen' import { sendUserToast } from '$lib/toast' - import { userStore, workspaceStore, userWorkspaces, superadmin } from '$lib/stores' + import { userStore, workspaceStore, userWorkspaces, superadmin, devopsRole } from '$lib/stores' import { Button, ButtonType, @@ -82,7 +82,7 @@ usernames, folders, jobTriggerKinds, - isSuperAdmin: !!$superadmin, + isSuperAdminOrDevops: !!$superadmin || !!$devopsRole, isAdminsWorkspace: $workspaceStore === 'admins' }) ) @@ -750,7 +750,7 @@ )} schema={runsFilterSearchbarSchema} presets={buildRunsFilterPresets({ - isSuperadmin: !!$superadmin, + isSuperAdminOrDevops: !!$superadmin || !!$devopsRole, isAdminsWorkspace: $workspaceStore === 'admins' })} bind:value={filters.val} diff --git a/frontend/src/lib/components/runs/runsFilter.ts b/frontend/src/lib/components/runs/runsFilter.ts index cbece7aefb..9d0b85dd75 100644 --- a/frontend/src/lib/components/runs/runsFilter.ts +++ b/frontend/src/lib/components/runs/runsFilter.ts @@ -23,14 +23,14 @@ export function buildRunsFilterSearchbarSchema({ usernames, folders, jobTriggerKinds, - isSuperAdmin, + isSuperAdminOrDevops, isAdminsWorkspace }: { paths: string[] usernames: string[] folders: string[] jobTriggerKinds: JobTriggerKind[] - isSuperAdmin: boolean + isSuperAdminOrDevops: boolean isAdminsWorkspace: boolean }) { return { @@ -206,12 +206,12 @@ export function buildRunsFilterSearchbarSchema({ label: 'Show future jobs (Default: true)', description: 'Include jobs that are planned later' }, - ...(isSuperAdmin && + ...(isSuperAdminOrDevops && isAdminsWorkspace && { all_workspaces: { type: 'boolean' as const, label: 'All workspaces', - description: 'Show jobs of all workspaces (superadmin only)' + description: 'Show jobs of all workspaces (superadmin or devops only)' } }) } satisfies FilterSchemaRec @@ -230,16 +230,16 @@ export function allowWildcards(filters: Partial | undefined) } export const buildRunsFilterPresets = ({ - isSuperadmin, + isSuperAdminOrDevops, isAdminsWorkspace }: { - isSuperadmin: boolean + isSuperAdminOrDevops: boolean isAdminsWorkspace: boolean }) => [ { name: 'Hide schedules', value: 'job_trigger_kind:\\ !schedule' }, { name: 'Hide future jobs', value: 'show_future_jobs:\\ false' }, { name: 'Show skipped', value: 'show_skipped:\\ true' }, - ...(isSuperadmin && isAdminsWorkspace + ...(isSuperAdminOrDevops && isAdminsWorkspace ? [{ name: 'All workspaces', value: 'all_workspaces:\\ true' }] : []) ] From d666e8431cdbf14d9373d9ef625b5aafc50ac50a Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 13 May 2026 15:26:21 +0000 Subject: [PATCH 24/40] feat: read-only flag on API tokens (#9144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: read-only flag on API tokens, orthogonal to scopes Add a per-token `read_only` boolean set at creation time. When true, the token can only call HTTP methods classified as Read (GET/HEAD/OPTIONS). Mutating methods and job-run actions are rejected with 403, regardless of which scopes are attached. Surfaced as a prominent toggle in the standard token-creation flow and a discreet `2xs` toggle in MCP mode (where users often want write access, so we don't bias them toward enabling it). MCP enforcement: read-only tokens hide all script/flow/hub tools from `list_tools` and only see endpoint tools whose method is GET, and the runner rejects `call_tool` on anything mutating. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: review fixes for read-only token flag - Exempt /api/mcp/* and /mcp/* paths from the read-only middleware check. MCP transport runs over POST (streamable HTTP / SSE), so otherwise the middleware would 403 every MCP request before the runner could enforce read-only at the tool-call level. - Tighten is_endpoint_read_only to GET only, matching the read_only_hint that create_endpoint_annotations actually emits. - Add unit test for check_read_only_for_route covering GET/HEAD/OPTIONS, mutating methods, and run paths. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: bump ee-repo-ref to read-only-trigger-toggle Co-Authored-By: Claude Opus 4.7 (1M context) * fix(frontend): make read-only toggle discreet in both modes Match the MCP-mode treatment in standard mode: text-tertiary, 2xs, shared "Read-only" label. The tooltip switches per mode so the explanation still fits the context. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(frontend): gate read-only toggle behind Limit token permissions The read-only toggle now only shows when the user has limited the token's scopes (standard mode) or in MCP mode (which always picks an MCP scope). Turning the limit off also resets read-only so it doesn't silently stick. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): hide incompatible MCP tools when read-only is on When the read-only toggle is on in MCP mode: - Endpoint badges and the custom-mode endpoint MultiSelect filter to GET. - Already-selected non-GET endpoints are pruned from the scope. - The scripts/flows preview is replaced with a note explaining they're hidden (the runner already rejects script/flow runs for read-only). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(frontend): place read-only toggle at top of limited scope area The previous gate required at least one scope to be picked before the read-only toggle appeared, which made it look missing while the user was still building their scope list. Move the toggle inside ScopesPicker: - Standard mode: sits directly under the "Limit token permissions" toggle whenever Limit is on, before the scope selector. - MCP mode: sits at the top of the MCP scope block. readOnly is now $bindable on ScopesPicker so CreateToken still owns the value. The auto-reset on un-limit moves into ScopesPicker too. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(frontend): nest read-only toggle inside the scope list card Place the read-only toggle at the top of the scope list (between the Selected Scopes summary and the bordered domain list) via a new optional topSlot snippet on ScopeSelector. Keeps ScopeSelector decoupled from read-only specifics; ScopesPicker fills the slot. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: update ee-repo-ref to 9bc8160be50b3e57a60daf4e1b71c389a6e02b8a This commit updates the EE repository reference after PR #571 was merged in windmill-ee-private. Previous ee-repo-ref: f53d26e6685dfd60bfa67686fbd7358169cfd130 New ee-repo-ref: 9bc8160be50b3e57a60daf4e1b71c389a6e02b8a Automated by sync-ee-ref workflow. * fix: address CI review for read-only token flag - P1 (Codex): narrow the MCP middleware exemption from "any /api/mcp/*" to just the streamable HTTP transport endpoints (/api/mcp/gateway, /api/mcp/w/{ws}/{mcp,sse,list_tools}). Without this, a read-only token could POST /api/mcp/gateway/oauth/server/approve and mint a follow-on non-read-only MCP token via the OAuth code/token exchange. - P2 (Claude/cubic): fix test comment/assertion mismatch — the run-path assertion now exercises GET (which is what the RUN_PATH_ACTIONS elevation comment describes) in addition to POST. Add a regression assertion for /api/mcp/gateway/oauth/server/approve. - P2 (cubic): short-circuit script/flow/hub-script/resource fetches in MCP list_tools when read_only is on — they would only be discarded below, so skipping the DB and resource fan-out is pure win. - P2 (cubic): when scopes are pre-supplied via the CreateToken prop, the ScopesPicker isn't rendered, which previously hid the read-only toggle entirely. Render it next to the pre-supplied scopes display. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...104a5af2fd565706204b7d5d33594ff91e61e.json | 66 ++++++ ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...ef468969b5452150ee6fffe31d59b52a4f1c2.json | 23 ++ ...c9fb0714c9c91a7ea3924811d345bee94c013.json | 66 ++++++ ...43e8e250cc9772cca4a7dae3442be86f4060c.json | 53 +++++ backend/ee-repo-ref.txt | 2 +- .../20260513095235_token_read_only.down.sql | 1 + .../20260513095235_token_read_only.up.sql | 4 + backend/tests/trigger_listener_queries.rs | 1 + backend/windmill-api-auth/src/auth.rs | 54 ++++- backend/windmill-api-auth/src/lib.rs | 17 +- backend/windmill-api-auth/src/scopes.rs | 41 ++++ .../tests/native_triggers.rs | 1 + backend/windmill-api-users/src/users.rs | 5 +- backend/windmill-api/openapi.yaml | 9 + backend/windmill-api/src/lib.rs | 1 + backend/windmill-mcp/src/server/backend.rs | 7 + backend/windmill-mcp/src/server/endpoints.rs | 6 + backend/windmill-mcp/src/server/mod.rs | 2 +- backend/windmill-mcp/src/server/runner.rs | 197 ++++++++++-------- .../windmill-native-triggers/src/handler.rs | 1 + .../components/mcp/McpScopeSelector.svelte | 70 +++++-- .../components/settings/CreateToken.svelte | 18 +- .../components/settings/ScopeSelector.svelte | 17 +- .../components/settings/ScopesPicker.svelte | 57 ++++- .../components/settings/TokensTable.svelte | 11 +- 26 files changed, 604 insertions(+), 128 deletions(-) create mode 100644 backend/.sqlx/query-52777947d60d5ddd6d28852a1cf104a5af2fd565706204b7d5d33594ff91e61e.json create mode 100644 backend/.sqlx/query-9a1bf7c424154172b56bbd29b51ef468969b5452150ee6fffe31d59b52a4f1c2.json create mode 100644 backend/.sqlx/query-e001fb68c60fa736cecce52be49c9fb0714c9c91a7ea3924811d345bee94c013.json create mode 100644 backend/.sqlx/query-ed82ffc4d806a19519701e39abc43e8e250cc9772cca4a7dae3442be86f4060c.json create mode 100644 backend/migrations/20260513095235_token_read_only.down.sql create mode 100644 backend/migrations/20260513095235_token_read_only.up.sql diff --git a/backend/.sqlx/query-52777947d60d5ddd6d28852a1cf104a5af2fd565706204b7d5d33594ff91e61e.json b/backend/.sqlx/query-52777947d60d5ddd6d28852a1cf104a5af2fd565706204b7d5d33594ff91e61e.json new file mode 100644 index 0000000000..3aba64d16c --- /dev/null +++ b/backend/.sqlx/query-52777947d60d5ddd6d28852a1cf104a5af2fd565706204b7d5d33594ff91e61e.json @@ -0,0 +1,66 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes, workspace_id, read_only FROM token WHERE email = $1\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "label", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "token_prefix", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "expiration", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "read_only", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + true, + false, + true, + false, + false, + true, + true, + false + ] + }, + "hash": "52777947d60d5ddd6d28852a1cf104a5af2fd565706204b7d5d33594ff91e61e" +} diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 36ddb8ab9f..713ccb9dd3 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - true + null ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-9a1bf7c424154172b56bbd29b51ef468969b5452150ee6fffe31d59b52a4f1c2.json b/backend/.sqlx/query-9a1bf7c424154172b56bbd29b51ef468969b5452150ee6fffe31d59b52a4f1c2.json new file mode 100644 index 0000000000..b4715fa36b --- /dev/null +++ b/backend/.sqlx/query-9a1bf7c424154172b56bbd29b51ef468969b5452150ee6fffe31d59b52a4f1c2.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id, read_only)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10\n WHERE $9::varchar IS NULL OR NOT EXISTS(\n SELECT 1 FROM workspace WHERE id = $9 AND deleted = true\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Timestamptz", + "Bool", + "TextArray", + "Varchar", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "9a1bf7c424154172b56bbd29b51ef468969b5452150ee6fffe31d59b52a4f1c2" +} diff --git a/backend/.sqlx/query-e001fb68c60fa736cecce52be49c9fb0714c9c91a7ea3924811d345bee94c013.json b/backend/.sqlx/query-e001fb68c60fa736cecce52be49c9fb0714c9c91a7ea3924811d345bee94c013.json new file mode 100644 index 0000000000..d0d9bd8d95 --- /dev/null +++ b/backend/.sqlx/query-e001fb68c60fa736cecce52be49c9fb0714c9c91a7ea3924811d345bee94c013.json @@ -0,0 +1,66 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes, workspace_id, read_only FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL)\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "label", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "token_prefix", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "expiration", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "read_only", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + true, + false, + true, + false, + false, + true, + true, + false + ] + }, + "hash": "e001fb68c60fa736cecce52be49c9fb0714c9c91a7ea3924811d345bee94c013" +} diff --git a/backend/.sqlx/query-ed82ffc4d806a19519701e39abc43e8e250cc9772cca4a7dae3442be86f4060c.json b/backend/.sqlx/query-ed82ffc4d806a19519701e39abc43e8e250cc9772cca4a7dae3442be86f4060c.json new file mode 100644 index 0000000000..b1f0fc0cff --- /dev/null +++ b/backend/.sqlx/query-ed82ffc4d806a19519701e39abc43e8e250cc9772cca4a7dae3442be86f4060c.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE token SET last_used_at = now() WHERE\n token_hash = $1\n AND (expiration > NOW() OR expiration IS NULL)\n AND (workspace_id IS NULL OR workspace_id = $2)\n RETURNING owner, email, super_admin, scopes, label, read_only", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "owner", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "super_admin", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 4, + "name": "label", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "read_only", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true, + true, + false, + true, + true, + false + ] + }, + "hash": "ed82ffc4d806a19519701e39abc43e8e250cc9772cca4a7dae3442be86f4060c" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index cad5dee291..92dd5ba787 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -7a32388adaa37eb1dd1820b40e140ff1877110f2 +9bc8160be50b3e57a60daf4e1b71c389a6e02b8a diff --git a/backend/migrations/20260513095235_token_read_only.down.sql b/backend/migrations/20260513095235_token_read_only.down.sql new file mode 100644 index 0000000000..e5380a3b02 --- /dev/null +++ b/backend/migrations/20260513095235_token_read_only.down.sql @@ -0,0 +1 @@ +ALTER TABLE token DROP COLUMN IF EXISTS read_only; diff --git a/backend/migrations/20260513095235_token_read_only.up.sql b/backend/migrations/20260513095235_token_read_only.up.sql new file mode 100644 index 0000000000..4fdeb9db3a --- /dev/null +++ b/backend/migrations/20260513095235_token_read_only.up.sql @@ -0,0 +1,4 @@ +-- Add a flag to restrict a token to read-only HTTP endpoints. +-- Orthogonal to `scopes`: even if scopes grant write/run, this flag denies +-- mutating methods (POST/PUT/PATCH/DELETE) and Run actions. +ALTER TABLE token ADD COLUMN read_only BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/tests/trigger_listener_queries.rs b/backend/tests/trigger_listener_queries.rs index c169d5289a..d1c20da407 100644 --- a/backend/tests/trigger_listener_queries.rs +++ b/backend/tests/trigger_listener_queries.rs @@ -174,6 +174,7 @@ fn make_authed() -> windmill_api_auth::ApiAuthed { scopes: None, username_override: None, token_prefix: None, + read_only: false, } } diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index ac4017b81c..bda9f54bdd 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -194,6 +194,7 @@ impl AuthCache { scopes: None, username_override, token_prefix: claims.audit_span, + read_only: false, }; let job_id = claims.job_id.and_then(|j| uuid::Uuid::from_str(&j).ok()); AUTH_CACHE.insert( @@ -221,11 +222,20 @@ impl AuthCache { token_hash = $1 AND (expiration > NOW() OR expiration IS NULL) AND (workspace_id IS NULL OR workspace_id = $2) - RETURNING owner, email, super_admin, scopes, label", + RETURNING owner, email, super_admin, scopes, label, read_only", t_hash, w_id.as_ref(), ) - .map(|x| (x.owner, x.email, x.super_admin, x.scopes, x.label)) + .map(|x| { + ( + x.owner, + x.email, + x.super_admin, + x.scopes, + x.label, + x.read_only, + ) + }) .fetch_optional(&self.db) .await .ok() @@ -234,7 +244,9 @@ impl AuthCache { if let Some(user) = user_o { let authed_o = { match user { - (Some(owner), Some(email), super_admin, _, label) if w_id.is_some() => { + (Some(owner), Some(email), super_admin, _, label, read_only) + if w_id.is_some() => + { let username_override = username_override_from_label(label); if let Some((prefix, name)) = owner.split_once('/') { if prefix == "u" { @@ -280,6 +292,7 @@ impl AuthCache { scopes: None, username_override, token_prefix: Some(safe_token_prefix(token)), + read_only, }) } else { let groups = vec![name.to_string()]; @@ -305,6 +318,7 @@ impl AuthCache { scopes: None, username_override, token_prefix: Some(safe_token_prefix(token)), + read_only, }) } } else { @@ -320,10 +334,11 @@ impl AuthCache { scopes: None, username_override, token_prefix: Some(safe_token_prefix(token)), + read_only, }) } } - (_, Some(email), super_admin, scopes, label) => { + (_, Some(email), super_admin, scopes, label, read_only) => { let username_override = username_override_from_label(label); if w_id.is_some() { let row_o = sqlx::query!( @@ -368,6 +383,7 @@ impl AuthCache { scopes, username_override, token_prefix: Some(safe_token_prefix(token)), + read_only, }) } None if super_admin => Some(ApiAuthed { @@ -380,6 +396,7 @@ impl AuthCache { scopes, username_override, token_prefix: Some(safe_token_prefix(token)), + read_only, }), None => None, } @@ -394,6 +411,7 @@ impl AuthCache { scopes, username_override, token_prefix: Some(safe_token_prefix(token)), + read_only, }) } } @@ -428,6 +446,7 @@ impl AuthCache { scopes: None, username_override: None, token_prefix: Some(safe_token_prefix(token)), + read_only: false, }; Some(OptJobAuthed { authed, job_id: None }) } else { @@ -630,6 +649,7 @@ pub async fn resolve_opt_job_authed( scopes: None, username_override: None, token_prefix: None, + read_only: false, }; return Ok((OptJobAuthed { authed, job_id: None }, parts)); } @@ -667,12 +687,11 @@ pub async fn resolve_opt_job_authed( cache.get_opt_job_authed(workspace_id.clone(), &token).await { let authed = &mut opt_job_authed.authed; + let path = original_uri.path(); + let method = parts.method.as_str(); if authed.scopes.is_some() { transform_old_scope_to_new_scope(authed.scopes.as_mut()); - let path = original_uri.path(); - let method = parts.method.as_str(); - if let Err(err) = crate::scopes::check_scopes_for_route( authed.scopes.as_deref(), path, @@ -681,6 +700,27 @@ pub async fn resolve_opt_job_authed( return Err((err, parts)); } } + if authed.read_only { + // MCP transport runs over POST (streamable HTTP / SSE handshake), + // so the middleware can't safely reject mutating methods on it — + // the MCP runner itself filters out write tools and rejects + // mutating tool calls for read-only tokens. Narrow to the actual + // transport endpoints: anything else under `/api/mcp/*` (OAuth + // approve, token exchange, client registration) must still go + // through the read-only check, otherwise a read-only token + // could approve an OAuth flow that mints a new non-read-only + // token. + let is_mcp_transport = path == "/api/mcp/gateway" + || (path.starts_with("/api/mcp/w/") + && (path.ends_with("/mcp") + || path.ends_with("/sse") + || path.ends_with("/list_tools"))); + if !is_mcp_transport { + if let Err(err) = crate::scopes::check_read_only_for_route(path, method) { + return Err((err, parts)); + } + } + } parts.extensions.insert(authed.clone()); Span::current().record("username", &authed.username.as_str()); diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index c5b9b5adc7..b9bc2e2417 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -55,6 +55,7 @@ pub struct ApiAuthed { pub scopes: Option>, pub username_override: Option, pub token_prefix: Option, + pub read_only: bool, } impl ApiAuthed { @@ -103,6 +104,7 @@ impl From for ApiAuthed { scopes: value.scopes, username_override: None, token_prefix: value.token_prefix, + read_only: false, } } } @@ -183,6 +185,10 @@ impl windmill_mcp::server::McpAuth for ApiAuthed { fn scopes(&self) -> Option<&[String]> { self.scopes.as_deref() } + + fn read_only(&self) -> bool { + self.read_only + } } // ------------ Utility functions ------------ @@ -478,6 +484,7 @@ pub async fn fetch_api_authed_from_permissioned_as( scopes: authed.scopes, username_override: None, token_prefix: authed.token_prefix, + read_only: false, }; API_AUTHED_CACHE.insert( @@ -506,6 +513,8 @@ pub struct NewToken { pub impersonate_email: Option, pub scopes: Option>, pub workspace_id: Option, + #[serde(default)] + pub read_only: Option, } impl NewToken { @@ -515,8 +524,9 @@ impl NewToken { impersonate_email: Option, scopes: Option>, workspace_id: Option, + read_only: Option, ) -> Self { - Self { label, expiration, impersonate_email, scopes, workspace_id } + Self { label, expiration, impersonate_email, scopes, workspace_id, read_only } } } @@ -564,8 +574,8 @@ pub async fn create_token_internal( } let rows = sqlx::query!( "INSERT INTO token - (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id) - SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9 + (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id, read_only) + SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 WHERE $9::varchar IS NULL OR NOT EXISTS( SELECT 1 FROM workspace WHERE id = $9 AND deleted = true )", @@ -578,6 +588,7 @@ pub async fn create_token_internal( is_super_admin, token_config.scopes.as_ref().map(|x| x.as_slice()), token_config.workspace_id, + token_config.read_only.unwrap_or(false), ) .execute(&mut *tx) .await?; diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index 6df2f74ae8..87ca3a8862 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -686,6 +686,19 @@ fn scope_grants_access( Ok(true) } +/// Enforces a token's `read_only` flag: only methods classified as `Read` +/// (GET/HEAD/OPTIONS) are allowed. Run actions and mutating methods are +/// rejected. Independent of `scopes`. +pub fn check_read_only_for_route(route_path: &str, http_method: &str) -> Result<()> { + if map_http_method_to_action(http_method, route_path) == ScopeAction::Read { + Ok(()) + } else { + Err(Error::PermissionDenied( + "Token is read-only. Mutating endpoints are not allowed.".to_string(), + )) + } +} + /// Helper function to check if scopes allow access to a route pub fn check_scopes_for_route( token_scopes: Option<&[String]>, @@ -778,6 +791,34 @@ mod tests { assert_eq!(route_suffix, Some("flow_conversations/list".to_string())); } + #[test] + fn test_check_read_only_for_route() { + // Plain GETs pass. + assert!(check_read_only_for_route("/api/w/x/scripts/list", "GET").is_ok()); + assert!(check_read_only_for_route("/api/w/x/scripts/get/foo", "HEAD").is_ok()); + assert!(check_read_only_for_route("/api/w/x/anything", "OPTIONS").is_ok()); + + // Mutating methods are rejected. + assert!(check_read_only_for_route("/api/w/x/scripts/create", "POST").is_err()); + assert!(check_read_only_for_route("/api/w/x/scripts/update", "PUT").is_err()); + assert!(check_read_only_for_route("/api/w/x/scripts/delete", "DELETE").is_err()); + assert!(check_read_only_for_route("/api/w/x/scripts/patch", "PATCH").is_err()); + + // Run paths are rejected even on GET (map_http_method_to_action elevates + // them to Run via RUN_PATH_ACTIONS). + assert!(check_read_only_for_route("/api/w/x/jobs/run/p/f/foo", "GET").is_err()); + assert!(check_read_only_for_route("/api/w/x/jobs/run/p/f/foo", "POST").is_err()); + + // OAuth/registration endpoints under /api/mcp/* must NOT be exempted by + // the auth middleware — they go through this check on the gateway side + // because they can mint non-read-only tokens. The middleware decides + // which paths to exempt; this helper is method-only, so we just assert + // that mutating methods still fail. + assert!( + check_read_only_for_route("/api/mcp/gateway/oauth/server/approve", "POST").is_err() + ); + } + #[test] fn test_specific_scope_access() { let scopes = vec!["jobs:read".to_string()]; diff --git a/backend/windmill-api-integration-tests/tests/native_triggers.rs b/backend/windmill-api-integration-tests/tests/native_triggers.rs index 0d1530a7d9..159236ed4b 100644 --- a/backend/windmill-api-integration-tests/tests/native_triggers.rs +++ b/backend/windmill-api-integration-tests/tests/native_triggers.rs @@ -51,6 +51,7 @@ fn test_authed() -> ApiAuthed { scopes: None, username_override: None, token_prefix: None, + read_only: false, } } diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index e4540ca903..2ae6c838c1 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -297,6 +297,7 @@ pub struct TruncatedToken { pub last_used_at: chrono::DateTime, pub scopes: Option>, pub workspace_id: Option, + pub read_only: bool, } // NewToken is re-exported from windmill-api-auth above @@ -2249,7 +2250,7 @@ async fn list_tokens( sqlx::query_as!( TruncatedToken, "SELECT label, token_prefix, expiration, created_at, \ - last_used_at, scopes, workspace_id FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL) + last_used_at, scopes, workspace_id, read_only FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL) ORDER BY created_at DESC LIMIT $2 OFFSET $3", email, per_page as i64, @@ -2261,7 +2262,7 @@ async fn list_tokens( sqlx::query_as!( TruncatedToken, "SELECT label, token_prefix, expiration, created_at, \ - last_used_at, scopes, workspace_id FROM token WHERE email = $1 + last_used_at, scopes, workspace_id, read_only FROM token WHERE email = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", email, per_page as i64, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 258e268f88..953fe8e278 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -22631,10 +22631,13 @@ components: type: string workspace_id: type: string + read_only: + type: boolean required: - token_prefix - created_at - last_used_at + - read_only ExternalJwtToken: type: object @@ -22683,6 +22686,12 @@ components: type: string workspace_id: type: string + read_only: + type: boolean + description: | + If true, the token is restricted to read-only HTTP methods + (GET/HEAD/OPTIONS). Mutating endpoints and job-run actions are + rejected with 403, regardless of the scopes attached. NewTokenImpersonate: type: object diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 0fde5cacd1..75b3469c41 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -330,6 +330,7 @@ async fn inject_agent_authed( scopes: None, username_override: None, token_prefix: None, + read_only: false, }, job_id: None, }); diff --git a/backend/windmill-mcp/src/server/backend.rs b/backend/windmill-mcp/src/server/backend.rs index 0b942353b3..7a6e007c4d 100644 --- a/backend/windmill-mcp/src/server/backend.rs +++ b/backend/windmill-mcp/src/server/backend.rs @@ -33,6 +33,13 @@ pub trait McpAuth: Send + Sync + Clone + 'static { /// Get token scopes fn scopes(&self) -> Option<&[String]>; + /// True if the token was created with the `read_only` flag. + /// When set, write-capable tools must be hidden from `list_tools` and + /// rejected by `call_tool`. Defaults to false so existing impls compile. + fn read_only(&self) -> bool { + false + } + /// Check if the user has an MCP scope fn has_mcp_scope(&self) -> bool { self.scopes() diff --git a/backend/windmill-mcp/src/server/endpoints.rs b/backend/windmill-mcp/src/server/endpoints.rs index 2401b10757..373db36eb1 100644 --- a/backend/windmill-mcp/src/server/endpoints.rs +++ b/backend/windmill-mcp/src/server/endpoints.rs @@ -26,6 +26,12 @@ pub struct EndpointTool { pub body_field_renames: Option, } +/// True if this endpoint is safe to expose to a read-only token. Mirrors the +/// `read_only_hint` computed by `create_endpoint_annotations`: only `GET`. +pub fn is_endpoint_read_only(tool: &EndpointTool) -> bool { + tool.method.as_ref() == "GET" +} + /// Convert a single endpoint tool to MCP tool pub fn endpoint_tool_to_mcp_tool(tool: &EndpointTool) -> Tool { let mut combined_properties = serde_json::Map::new(); diff --git a/backend/windmill-mcp/src/server/mod.rs b/backend/windmill-mcp/src/server/mod.rs index d489b3f429..688c12b827 100644 --- a/backend/windmill-mcp/src/server/mod.rs +++ b/backend/windmill-mcp/src/server/mod.rs @@ -12,7 +12,7 @@ pub mod tools; // Re-export main types pub use backend::{BackendResult, McpAuth, McpBackend}; -pub use endpoints::{endpoint_tool_to_mcp_tool, EndpointTool}; +pub use endpoints::{endpoint_tool_to_mcp_tool, is_endpoint_read_only, EndpointTool}; pub use runner::Runner; pub use tools::create_tool_from_item; diff --git a/backend/windmill-mcp/src/server/runner.rs b/backend/windmill-mcp/src/server/runner.rs index 0cb2962c55..6d33573d95 100644 --- a/backend/windmill-mcp/src/server/runner.rs +++ b/backend/windmill-mcp/src/server/runner.rs @@ -145,94 +145,100 @@ impl ServerHandler for Runner { parse_mcp_scopes(scopes).map_err(|e| ErrorData::internal_error(e, None))?; let favorites_only = scope_config.favorites; - - // Fetch all items concurrently - let (scripts, flows, resource_types, hub_scripts) = tokio::try_join!( - self.backend - .list_scripts(&auth, &workspace_id, favorites_only, None), - self.backend - .list_flows(&auth, &workspace_id, favorites_only, None), - self.backend.list_resource_types(&auth, &workspace_id), - async { - if let Some(ref apps) = scope_config.hub_apps { - self.backend.list_hub_scripts(Some(apps)).await - } else { - Ok(vec![]) - } - } - )?; - - // Filter items based on scope - let filtered_scripts: Vec<_> = scripts - .into_iter() - .filter(|s| !scope_config.granular || scope_config.is_allowed("script", &s.path)) - .collect(); - - let filtered_flows: Vec<_> = flows - .into_iter() - .filter(|f| !scope_config.granular || scope_config.is_allowed("flow", &f.path)) - .collect(); - - // Collect all needed resource types from all schemas - let mut needed_resource_types: HashSet = HashSet::new(); - for script in &filtered_scripts { - needed_resource_types.extend(extract_resource_types_from_schema(&script.get_schema())); - } - for flow in &filtered_flows { - needed_resource_types.extend(extract_resource_types_from_schema(&flow.get_schema())); - } - for hub_script in &hub_scripts { - needed_resource_types - .extend(extract_resource_types_from_schema(&hub_script.get_schema())); - } - - // Pre-fetch all resources - let resource_futures: Vec<_> = needed_resource_types - .into_iter() - .map(|rt| { - let backend = self.backend.clone(); - let auth = auth.clone(); - let workspace_id = workspace_id.clone(); - async move { - backend - .list_resources(&auth, &workspace_id, &rt) - .await - .map(|resources| (rt, resources)) - } - }) - .collect(); - - let resource_results = futures::future::try_join_all(resource_futures).await?; - let resources_cache: HashMap> = - resource_results.into_iter().collect(); + let read_only = auth.read_only(); let mut tools = Vec::new(); - for script in &filtered_scripts { - tools.push(create_tool_from_item( - script, - self.backend.as_ref(), - &resources_cache, - &resource_types, - )); - } + // Read-only tokens cannot run scripts/flows/hub-scripts (running is a + // mutating action), so skip the script/flow/hub/resource fetches + // entirely — they would only be discarded below. + if !read_only { + let (scripts, flows, resource_types, hub_scripts) = tokio::try_join!( + self.backend + .list_scripts(&auth, &workspace_id, favorites_only, None), + self.backend + .list_flows(&auth, &workspace_id, favorites_only, None), + self.backend.list_resource_types(&auth, &workspace_id), + async { + if let Some(ref apps) = scope_config.hub_apps { + self.backend.list_hub_scripts(Some(apps)).await + } else { + Ok(vec![]) + } + } + )?; - for flow in &filtered_flows { - tools.push(create_tool_from_item( - flow, - self.backend.as_ref(), - &resources_cache, - &resource_types, - )); - } + let filtered_scripts: Vec<_> = scripts + .into_iter() + .filter(|s| !scope_config.granular || scope_config.is_allowed("script", &s.path)) + .collect(); - for hub_script in &hub_scripts { - tools.push(create_tool_from_item( - hub_script, - self.backend.as_ref(), - &resources_cache, - &resource_types, - )); + let filtered_flows: Vec<_> = flows + .into_iter() + .filter(|f| !scope_config.granular || scope_config.is_allowed("flow", &f.path)) + .collect(); + + // Collect all needed resource types from all schemas + let mut needed_resource_types: HashSet = HashSet::new(); + for script in &filtered_scripts { + needed_resource_types + .extend(extract_resource_types_from_schema(&script.get_schema())); + } + for flow in &filtered_flows { + needed_resource_types + .extend(extract_resource_types_from_schema(&flow.get_schema())); + } + for hub_script in &hub_scripts { + needed_resource_types + .extend(extract_resource_types_from_schema(&hub_script.get_schema())); + } + + // Pre-fetch all resources + let resource_futures: Vec<_> = needed_resource_types + .into_iter() + .map(|rt| { + let backend = self.backend.clone(); + let auth = auth.clone(); + let workspace_id = workspace_id.clone(); + async move { + backend + .list_resources(&auth, &workspace_id, &rt) + .await + .map(|resources| (rt, resources)) + } + }) + .collect(); + + let resource_results = futures::future::try_join_all(resource_futures).await?; + let resources_cache: HashMap> = + resource_results.into_iter().collect(); + + for script in &filtered_scripts { + tools.push(create_tool_from_item( + script, + self.backend.as_ref(), + &resources_cache, + &resource_types, + )); + } + + for flow in &filtered_flows { + tools.push(create_tool_from_item( + flow, + self.backend.as_ref(), + &resources_cache, + &resource_types, + )); + } + + for hub_script in &hub_scripts { + tools.push(create_tool_from_item( + hub_script, + self.backend.as_ref(), + &resources_cache, + &resource_types, + )); + } } // Add endpoint tools from the generated MCP tools, filtered by scope @@ -241,6 +247,9 @@ impl ServerHandler for Runner { if scope_config.granular && !scope_config.is_allowed("endpoint", &endpoint_tool.name) { continue; } + if read_only && !crate::server::is_endpoint_read_only(&endpoint_tool) { + continue; + } tools.push(endpoint_tool_to_mcp_tool(&endpoint_tool)); } @@ -259,6 +268,7 @@ impl ServerHandler for Runner { let scopes = auth.scopes().unwrap_or(&[]); let scope_config = parse_mcp_scopes(scopes).map_err(|e| ErrorData::internal_error(e, None))?; + let read_only = auth.read_only(); let args = request.arguments.map(Value::Object).unwrap_or(Value::Null); @@ -278,6 +288,15 @@ impl ServerHandler for Runner { None, )); } + if read_only && !crate::server::is_endpoint_read_only(endpoint_tool) { + return Err(ErrorData::internal_error( + format!( + "Access denied: endpoint '{}' is not read-only and this token is restricted to read-only operations", + endpoint_tool.name + ), + None, + )); + } // This is an endpoint tool, call via backend let result = self @@ -294,6 +313,18 @@ impl ServerHandler for Runner { } } + // Anything below this point runs a script or flow, which is a mutating + // action and must be denied for read-only tokens. + if read_only { + return Err(ErrorData::internal_error( + format!( + "Access denied: tool '{}' runs a script/flow and this token is restricted to read-only operations", + request.name + ), + None, + )); + } + // Resolve the tool name to (type, path, is_hub) let (type_str, is_hub, is_hashed) = parse_tool_prefix(&request.name).map_err(|e| { ErrorData::internal_error(format!("Failed to parse tool name: {}", e), None) diff --git a/backend/windmill-native-triggers/src/handler.rs b/backend/windmill-native-triggers/src/handler.rs index 1b6aa83b92..fda28af407 100644 --- a/backend/windmill-native-triggers/src/handler.rs +++ b/backend/windmill-native-triggers/src/handler.rs @@ -94,6 +94,7 @@ async fn new_webhook_token( None, Some(scopes), Some(workspace_id.to_owned()), + None, ); let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?; diff --git a/frontend/src/lib/components/mcp/McpScopeSelector.svelte b/frontend/src/lib/components/mcp/McpScopeSelector.svelte index 61ae086515..cf95617790 100644 --- a/frontend/src/lib/components/mcp/McpScopeSelector.svelte +++ b/frontend/src/lib/components/mcp/McpScopeSelector.svelte @@ -15,9 +15,27 @@ workspaceId: string scope: string initialScope?: string + readOnly?: boolean } - let { workspaceId, scope = $bindable(), initialScope }: Props = $props() + let { workspaceId, scope = $bindable(), initialScope, readOnly = false }: Props = $props() + + // Endpoints we can actually advertise to a read-only MCP token. Mirrors the + // runner's filter (only GET endpoints). + const visibleEndpointTools = $derived( + readOnly ? mcpEndpointTools.filter((e) => e.method === 'GET') : mcpEndpointTools + ) + + // When read-only flips on, prune already-selected non-GET endpoints so the + // scope string doesn't keep references to tools the server will reject. + $effect(() => { + if (!readOnly || selectedEndpoints.length === 0) return + const allowed = new Set(visibleEndpointTools.map((e) => e.name)) + const filtered = selectedEndpoints.filter((n) => allowed.has(n)) + if (filtered.length !== selectedEndpoints.length) { + selectedEndpoints = filtered + } + }) const parsedInitial = parseInitialScope(initialScope) @@ -410,7 +428,7 @@ selectedFlows = [] } function selectAllEndpoints() { - selectedEndpoints = [...mcpEndpointTools.map((e) => e.name)] + selectedEndpoints = [...visibleEndpointTools.map((e) => e.name)] } function clearAllEndpoints() { selectedEndpoints = [] @@ -529,7 +547,7 @@
{@render sectionHeader('API Endpoints', selectAllEndpoints, clearAllEndpoints)} e.name))} + items={safeSelectItems(visibleEndpointTools.map((e) => e.name))} placeholder="Select endpoints" bind:value={selectedEndpoints} /> @@ -594,29 +612,35 @@
{:else}
- Scripts & Flows that will be available via MCP -
- {#if includedRunnables.length > 0 && includedRunnables.length <= 5} - {#each includedRunnables as scriptOrFlow (scriptOrFlow)} - {scriptOrFlow} - {/each} - {:else if includedRunnables.length > 0} - {#each includedRunnables.slice(0, 3) as scriptOrFlow (scriptOrFlow)} - {scriptOrFlow} - {/each} - - +{includedRunnables.length - 3} more - - {:else} -

- {warning} -

- {/if} -
+ {#if !readOnly} + Scripts & Flows that will be available via MCP +
+ {#if includedRunnables.length > 0 && includedRunnables.length <= 5} + {#each includedRunnables as scriptOrFlow (scriptOrFlow)} + {scriptOrFlow} + {/each} + {:else if includedRunnables.length > 0} + {#each includedRunnables.slice(0, 3) as scriptOrFlow (scriptOrFlow)} + {scriptOrFlow} + {/each} + + +{includedRunnables.length - 3} more + + {:else} +

+ {warning} +

+ {/if} +
+ {:else} +

+ Scripts and flows are hidden because this token is read-only. +

+ {/if} API endpoint tools that will be available via MCP
- {#each mcpEndpointTools as endpoint (endpoint.name)} + {#each visibleEndpointTools as endpoint (endpoint.name)} {#snippet text()}
diff --git a/frontend/src/lib/components/settings/CreateToken.svelte b/frontend/src/lib/components/settings/CreateToken.svelte index 6ba2461725..80c6cb34ef 100644 --- a/frontend/src/lib/components/settings/CreateToken.svelte +++ b/frontend/src/lib/components/settings/CreateToken.svelte @@ -46,6 +46,7 @@ let mcpLabelAutofilled = $state(false) let pickedScopes = $state(null) + let readOnly = $state(false) function ensureCurrentWorkspaceIncluded( workspacesList: UserWorkspace[], @@ -67,6 +68,7 @@ newTokenWorkspace = defaultNewTokenWorkspace ?? $workspaceStore newToken = undefined newMcpToken = undefined + readOnly = false if (!newTokenLabel) { newTokenLabel = 'MCP token' mcpLabelAutofilled = true @@ -80,6 +82,7 @@ newTokenExpiration = undefined newTokenWorkspace = defaultNewTokenWorkspace newMcpToken = undefined + readOnly = false if (mcpLabelAutofilled) { newTokenLabel = undefined } @@ -100,7 +103,8 @@ label: newTokenLabel, expiration: date?.toISOString(), scopes: tokenScopes, - workspace_id: mcpMode ? newTokenWorkspace || $workspaceStore : newTokenWorkspace + workspace_id: mcpMode ? newTokenWorkspace || $workspaceStore : newTokenWorkspace, + read_only: readOnly } as NewToken }) @@ -184,6 +188,17 @@ {#each scopes as scope (scope)} {/each} +
+ +
{/if} @@ -192,6 +207,7 @@ mode={mcpCreationMode ? 'mcp' : 'standard'} workspaceId={newTokenWorkspace || $workspaceStore || ''} bind:value={pickedScopes} + bind:readOnly /> {/if} diff --git a/frontend/src/lib/components/settings/ScopeSelector.svelte b/frontend/src/lib/components/settings/ScopeSelector.svelte index da9d4949a6..91a62e65f7 100644 --- a/frontend/src/lib/components/settings/ScopeSelector.svelte +++ b/frontend/src/lib/components/settings/ScopeSelector.svelte @@ -7,10 +7,14 @@ import Tooltip from '../Tooltip.svelte' import { twMerge } from 'tailwind-merge' + import type { Snippet } from 'svelte' + interface Props { selectedScopes?: string[] disabled?: boolean class?: string + /** Renders above the scope-list card, below the Selected Scopes summary. */ + topSlot?: Snippet } interface ScopeState { @@ -30,7 +34,12 @@ domains: Record } - let { selectedScopes = $bindable([]), disabled = false, class: className = '' }: Props = $props() + let { + selectedScopes = $bindable([]), + disabled = false, + class: className = '', + topSlot + }: Props = $props() let scopeDomains = $state(null) let loading = $state(false) @@ -535,6 +544,12 @@ {/if}
+ {#if topSlot} +
+ {@render topSlot()} +
+ {/if} +
{#each scopeDomains as domain} {@const domainState = getDomainState(domain.name)} diff --git a/frontend/src/lib/components/settings/ScopesPicker.svelte b/frontend/src/lib/components/settings/ScopesPicker.svelte index 2e96269a00..e8cdccf40c 100644 --- a/frontend/src/lib/components/settings/ScopesPicker.svelte +++ b/frontend/src/lib/components/settings/ScopesPicker.svelte @@ -10,9 +10,28 @@ initialScopes?: string[] /** Final scope value: null = unrestricted/full access, array = explicit list */ value: string[] | null + /** Read-only flag; also forwarded to McpScopeSelector to filter incompatible + * endpoints/runnables. Two-way bound so the inline toggle below the + * "Limit token permissions" switch (and the MCP variant) writes back. */ + readOnly?: boolean } - let { mode, workspaceId = '', initialScopes, value = $bindable() }: Props = $props() + let { + mode, + workspaceId = '', + initialScopes, + value = $bindable(), + readOnly = $bindable(false) + }: Props = $props() + + // In standard mode, only meaningful when the user has turned "Limit token + // permissions" on. Reset when they un-limit so the flag doesn't quietly + // stick if they re-enable later. + $effect(() => { + if (mode === 'standard' && !limited && readOnly) { + readOnly = false + } + }) const initialMcpScope = $derived( (initialScopes ?? []).length > 0 ? (initialScopes ?? []).join(' ') : undefined @@ -51,9 +70,41 @@ size="xs" /> {#if limited} - + + {#snippet topSlot()} +
+ +
+ {/snippet} +
{/if}
{:else} - +
+
+ +
+ +
{/if} diff --git a/frontend/src/lib/components/settings/TokensTable.svelte b/frontend/src/lib/components/settings/TokensTable.svelte index 816d710e3c..5907c808a6 100644 --- a/frontend/src/lib/components/settings/TokensTable.svelte +++ b/frontend/src/lib/components/settings/TokensTable.svelte @@ -170,7 +170,7 @@ {#snippet body()}
{#if tokens && tokens.length > 0} - {#each tokens as { token_prefix, expiration, label, scopes, workspace_id } (token_prefix)} + {#each tokens as { token_prefix, expiration, label, scopes, workspace_id, read_only } (token_prefix)} {@const badge = expirationBadge(expiration, label)} @@ -185,8 +185,15 @@ +
+ {#if read_only} + Read-only + {/if} + {scopes?.join(', ') ?? ''} +
+
{#if filteredUsers && users} - {#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, role_source, disabled }, i (email)} + {#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, role_source, disabled, workspace_id }, i (email + '::' + (workspace_id ?? ''))} + {@const isServiceAccount = login_type === 'service_account'}
- {email} + {#if isServiceAccount} + + {email} + {:else} + {email} + {/if} + {#if workspace_id} + + {truncate(workspace_id, 20)} + + {/if} {#if disabled} {/if} -
- {#key `${super_admin}_${devops}_${role_source}`} - { - if (email == $userStore?.email) { - sendUserToast('You cannot demote yourself', true) + {#if isServiceAccount} +
+ + Operator + + Service accounts are always operators. +
+ {:else} +
+ {#key `${super_admin}_${devops}_${role_source}`} + { + if (email == $userStore?.email) { + sendUserToast('You cannot demote yourself', true) + listUsers(activeOnly) + return + } + + let role = e.detail + + if (role === 'super_admin') { + await UserService.globalUserUpdate({ + email, + requestBody: { + is_super_admin: true, + is_devops: false + } + }) + } + if (role === 'devops') { + await UserService.globalUserUpdate({ + email, + requestBody: { + is_super_admin: false, + is_devops: true + } + }) + } + if (role === 'user') { + await UserService.globalUserUpdate({ + email, + requestBody: { + is_super_admin: false, + is_devops: false + } + }) + } + sendUserToast('User updated') listUsers(activeOnly) - return - } - - let role = e.detail - - if (role === 'super_admin') { - await UserService.globalUserUpdate({ - email, - requestBody: { - is_super_admin: true, - is_devops: false - } - }) - } - if (role === 'devops') { - await UserService.globalUserUpdate({ - email, - requestBody: { - is_super_admin: false, - is_devops: true - } - }) - } - if (role === 'user') { - await UserService.globalUserUpdate({ - email, - requestBody: { - is_super_admin: false, - is_devops: false - } - }) - } - sendUserToast('User updated') - listUsers(activeOnly) - }} - > - {#snippet children({ item })} - - - - {/snippet} - - {/key} - {#if role_source === 'instance_group' && (super_admin || devops)} - closeDrawer?.()} - > - Set by instance group - - {/if} -
+ }} + > + {#snippet children({ item })} + + + + {/snippet} +
+ {/key} + {#if role_source === 'instance_group' && (super_admin || devops)} + closeDrawer?.()} + > + Set by instance group + + {/if} +
+ {/if}
- - { - const btn = editWrappers[email]?.querySelector( - '[aria-label="Popup button"]' - ) - if (btn instanceof HTMLElement) btn.click() - } - }, - { - displayName: disabled ? 'Enable' : 'Disable', - icon: disabled ? CheckCircle2 : Ban, - action: () => { - if (!disabled) { - disableUserEmail = email - disableConfirmedCallback = async () => { - try { - await UserService.globalUserUpdate({ - email, - requestBody: { disabled: true } - }) - sendUserToast('User disabled') - listUsers(activeOnly) - } catch (e) { - sendUserToast('Failed to disable user', true) + {#if isServiceAccount} + {#if workspace_id} + Manage in workspace + {/if} + {:else} + + { + const btn = editWrappers[email]?.querySelector( + '[aria-label="Popup button"]' + ) + if (btn instanceof HTMLElement) btn.click() + } + }, + { + displayName: disabled ? 'Enable' : 'Disable', + icon: disabled ? CheckCircle2 : Ban, + action: () => { + if (!disabled) { + disableUserEmail = email + disableConfirmedCallback = async () => { + try { + await UserService.globalUserUpdate({ + email, + requestBody: { disabled: true } + }) + sendUserToast('User disabled') + listUsers(activeOnly) + } catch (e) { + sendUserToast('Failed to disable user', true) + } } + } else { + UserService.globalUserUpdate({ + email, + requestBody: { disabled: false } + }) + .then(() => { + sendUserToast('User enabled') + listUsers(activeOnly) + }) + .catch(() => { + sendUserToast('Failed to enable user', true) + }) } - } else { - UserService.globalUserUpdate({ - email, - requestBody: { disabled: false } - }) - .then(() => { - sendUserToast('User enabled') - listUsers(activeOnly) - }) - .catch(() => { - sendUserToast('Failed to enable user', true) - }) + } + }, + { + displayName: 'Reassign', + icon: ArrowRightLeft, + action: () => { + offboardingEmail = email + offboardingReassignOnly = true + } + }, + { + displayName: 'Remove', + icon: UserMinus, + type: 'delete', + action: () => { + offboardingEmail = email + offboardingReassignOnly = false } } - }, - { - displayName: 'Reassign', - icon: ArrowRightLeft, - action: () => { - offboardingEmail = email - offboardingReassignOnly = true - } - }, - { - displayName: 'Remove', - icon: UserMinus, - type: 'delete', - action: () => { - offboardingEmail = email - offboardingReassignOnly = false - } - } - ]} - /> + ]} + /> + {/if}
From d870edc959481a06c894b4eda5e2be1a0269d7d0 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 14 May 2026 06:54:32 +0000 Subject: [PATCH 28/40] fix(mcp): sanitize and enrich nested resource schemas (#9158) * fix(mcp): sanitize and enrich nested resource schemas * fix(mcp): strip Windmill-internal keys inline during resource enrichment * fix(mcp): strip resourceType unconditionally in schema sanitizer --- backend/windmill-api/src/mcp/core.rs | 73 +--- backend/windmill-mcp/src/common/schema.rs | 448 +++++++++++++++++++++- 2 files changed, 439 insertions(+), 82 deletions(-) diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index 07ce233278..52fec66096 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -7,6 +7,7 @@ use async_trait::async_trait; use serde_json::Value; use std::collections::HashMap; use windmill_common::{db::UserDB, utils::StripPath, DB}; +use windmill_mcp::common::schema::enrich_resource_schemas; use windmill_mcp::common::transform::apply_key_transformation; use windmill_mcp::common::types::{ FlowInfo, HubScriptInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo, @@ -203,71 +204,13 @@ impl McpBackend for WindmillBackend { schema_obj.properties.insert(new_key, value); } - for (_key, prop_value) in schema_obj.properties.iter_mut() { - if let Value::Object(prop_map) = prop_value { - if let Some(format_value) = prop_map.get("format") { - if let Value::String(format_str) = format_value { - if format_str.starts_with("resource-") { - let resource_type_key = - format_str.split("-").last().unwrap_or_default().to_string(); - let resource_type = resources_types - .iter() - .find(|rt| rt.name == resource_type_key); - let resource_type_obj = resource_type.cloned(); - - if let Some(resource_cache) = resources_cache.get(&resource_type_key) { - let resources_count = resource_cache.len(); - let description = match resource_type_obj { - Some(resource_type_obj) => format!( - "This is a resource named `{}` with the following description: `{}`.\\nThe path of the resource should be used to specify the resource.\\n{}", - resource_type_obj.name, - resource_type_obj.description.as_deref().unwrap_or("No description"), - if resources_count == 0 { - "This resource does not have any available instances, you should create one from your windmill workspace." - } else if resources_count > 1 { - "This resource has multiple available instances, you should precisely select the one you want to use." - } else { - "There is 1 resource available." - } - ), - None => "An object parameter.".to_string(), - }; - prop_map.insert( - "type".to_string(), - Value::String("string".to_string()), - ); - prop_map - .insert("description".to_string(), Value::String(description)); - if resources_count > 0 { - let resources_description = resource_cache - .iter() - .map(|resource| { - format!( - "{}: $res:{}", - resource - .description - .as_deref() - .unwrap_or("No title"), - resource.path - ) - }) - .collect::>() - .join("\\n"); - - prop_map.insert( - "description".to_string(), - Value::String(format!( - "{}\\nHere are the available resources, in the format title:path. Title can be empty. Path should be used to specify the resource:\\n{}", - prop_map.get("description").unwrap_or(&Value::String("No description".to_string())), - resources_description - )), - ); - } - } - } - } - } - } + // Enrich every resource reference in the schema — including those + // inside `items`, nested `properties`, etc. — with a description + // listing the available resources. Both shapes are handled: + // { type: "object", format: "resource-" } (top-level scalar) + // { type: "resource", resourceType: "" } (inside list items) + for prop_value in schema_obj.properties.values_mut() { + enrich_resource_schemas(prop_value, resources_cache, resources_types); } schema_obj diff --git a/backend/windmill-mcp/src/common/schema.rs b/backend/windmill-mcp/src/common/schema.rs index 054b0e999a..de90248689 100644 --- a/backend/windmill-mcp/src/common/schema.rs +++ b/backend/windmill-mcp/src/common/schema.rs @@ -2,11 +2,11 @@ //! //! Contains functions for converting Windmill schemas into MCP-compatible formats. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; -use serde_json::Value; +use serde_json::{Map, Value}; -use super::types::SchemaType; +use super::types::{ResourceInfo, ResourceType, SchemaType}; use windmill_common::scripts::Schema; /// Convert a Windmill Schema to a SchemaType @@ -22,38 +22,195 @@ pub fn convert_schema_to_schema_type(schema: Option) -> SchemaType { schema_obj } -/// Extract resource type keys from a schema -/// -/// Scans the schema properties for fields with format "resource-{type}" -/// and returns a set of all unique resource type names found. -pub fn extract_resource_types_from_schema(schema: &SchemaType) -> HashSet { - let mut resource_types = HashSet::new(); - for (_key, prop_value) in schema.properties.iter() { - if let Value::Object(prop_map) = prop_value { - if let Some(Value::String(format_str)) = prop_map.get("format") { - if let Some(rt) = format_str.strip_prefix("resource-") { - resource_types.insert(rt.to_string()); - } +/// If `node` is a schema describing a Windmill resource reference, return the +/// resource type name. Recognizes both on-disk shapes: +/// - Form A (top-level scalar): `{ type: "object", format: "resource-" }` +/// - Form B (inside `items`): `{ type: "resource", resourceType: "" }` +fn resource_type_of_schema_node(node: &Value) -> Option { + let obj = node.as_object()?; + if let Some(Value::String(fmt)) = obj.get("format") { + if let Some(name) = fmt.strip_prefix("resource-") { + return Some(name.to_string()); + } + } + if obj.get("type").and_then(Value::as_str) == Some("resource") { + if let Some(Value::String(name)) = obj.get("resourceType") { + return Some(name.clone()); + } + } + None +} + +/// Recursively collect resource type names referenced at any depth in `node`. +fn collect_resource_types(node: &Value, out: &mut HashSet) { + if let Some(rt) = resource_type_of_schema_node(node) { + out.insert(rt); + } + let Some(obj) = node.as_object() else { return }; + if let Some(Value::Object(props)) = obj.get("properties") { + for v in props.values() { + collect_resource_types(v, out); + } + } + if let Some(items) = obj.get("items") { + collect_resource_types(items, out); + } + if let Some(additional) = obj.get("additionalProperties") { + if additional.is_object() { + collect_resource_types(additional, out); + } + } + for kw in ["allOf", "oneOf", "anyOf"] { + if let Some(Value::Array(arr)) = obj.get(kw) { + for s in arr { + collect_resource_types(s, out); } } } +} + +/// Extract resource type keys referenced anywhere in a schema (top-level +/// properties, nested objects, array items, additionalProperties, and +/// allOf/oneOf/anyOf subschemas). Recognizes both the `format: resource-` +/// and `type: resource` + `resourceType` shapes. +pub fn extract_resource_types_from_schema(schema: &SchemaType) -> HashSet { + let mut resource_types = HashSet::new(); + for prop_value in schema.properties.values() { + collect_resource_types(prop_value, &mut resource_types); + } resource_types } +/// Rewrite a single schema node that points to a Windmill resource: set +/// `type: "string"` and inject a description listing the available resources. +/// Mirrors the top-level behavior that used to live in +/// `transform_schema_for_resources`. No-op if the resource type isn't in the +/// pre-fetched cache. +fn apply_resource_enrichment( + prop_map: &mut Map, + resource_type_key: &str, + resources_cache: &HashMap>, + resources_types: &[ResourceType], +) { + let Some(resource_cache) = resources_cache.get(resource_type_key) else { + return; + }; + let resource_type = resources_types + .iter() + .find(|rt| rt.name == resource_type_key); + let resources_count = resource_cache.len(); + let description = match resource_type { + Some(rt) => format!( + "This is a resource named `{}` with the following description: `{}`.\\nThe path of the resource should be used to specify the resource.\\n{}", + rt.name, + rt.description.as_deref().unwrap_or("No description"), + if resources_count == 0 { + "This resource does not have any available instances, you should create one from your windmill workspace." + } else if resources_count > 1 { + "This resource has multiple available instances, you should precisely select the one you want to use." + } else { + "There is 1 resource available." + } + ), + None => "An object parameter.".to_string(), + }; + prop_map.insert("type".to_string(), Value::String("string".to_string())); + prop_map.insert("description".to_string(), Value::String(description)); + // Drop the Windmill-internal keys we just consumed so the node is clean + // regardless of whether `make_schema_compatible` runs after us. (Its strip + // only fires while `type == "resource"`, which is no longer true here.) + prop_map.remove("resourceType"); + if prop_map + .get("format") + .and_then(Value::as_str) + .is_some_and(|s| s.starts_with("resource-")) + { + prop_map.remove("format"); + } + if resources_count > 0 { + let resources_description = resource_cache + .iter() + .map(|resource| { + format!( + "{}: $res:{}", + resource.description.as_deref().unwrap_or("No title"), + resource.path + ) + }) + .collect::>() + .join("\\n"); + let prior_description = prop_map + .get("description") + .and_then(Value::as_str) + .unwrap_or("No description") + .to_string(); + prop_map.insert( + "description".to_string(), + Value::String(format!( + "{}\\nHere are the available resources, in the format title:path. Title can be empty. Path should be used to specify the resource:\\n{}", + prior_description, resources_description + )), + ); + } +} + +/// Walk a schema and enrich every Windmill-resource reference (in either shape, +/// at any nesting depth) with `type: "string"` and a description listing +/// available resources. The non-standard keys (`format: resource-*`, +/// `resourceType`) consumed by the enrichment are stripped in place. +pub fn enrich_resource_schemas( + node: &mut Value, + resources_cache: &HashMap>, + resources_types: &[ResourceType], +) { + if let Some(rt_key) = resource_type_of_schema_node(node) { + if let Value::Object(obj) = node { + apply_resource_enrichment(obj, &rt_key, resources_cache, resources_types); + } + } + let Some(obj) = node.as_object_mut() else { + return; + }; + if let Some(Value::Object(props)) = obj.get_mut("properties") { + for v in props.values_mut() { + enrich_resource_schemas(v, resources_cache, resources_types); + } + } + if let Some(items) = obj.get_mut("items") { + enrich_resource_schemas(items, resources_cache, resources_types); + } + if let Some(additional) = obj.get_mut("additionalProperties") { + if additional.is_object() { + enrich_resource_schemas(additional, resources_cache, resources_types); + } + } + for kw in ["allOf", "oneOf", "anyOf"] { + if let Some(Value::Array(arr)) = obj.get_mut(kw) { + for s in arr.iter_mut() { + enrich_resource_schemas(s, resources_cache, resources_types); + } + } + } +} + /// Transform a JSON schema for maximum MCP client compatibility. /// /// Ensures schemas conform to JSON Schema draft 2020-12 by: /// - Converting `integer` type to `number` (some clients don't support integer) /// - Removing invalid non-array `enum` values /// - Stripping non-standard keywords (`originalType`, `format` with `resource-*` prefix) +/// - Rewriting the Windmill pseudo-type `type: "resource"` to `type: "string"` /// - Fixing contradictory schemas (`type: "string"` with `properties` → `type: "object"`) /// - Removing `default: null` when the type doesn't include `null` /// - Adding `type: "object"` to empty schemas that have no type pub fn make_schema_compatible(schema: &mut Value) { let Value::Object(obj) = schema else { return }; - // 1. Strip non-standard keywords that aren't part of JSON Schema + // 1. Strip non-standard keywords that aren't part of JSON Schema. The + // Windmill-internal `resourceType` is dropped unconditionally so it can't + // leak through even on enrichment cache-miss paths. obj.remove("originalType"); + obj.remove("resourceType"); // 2. Strip non-standard format values (resource-* is Windmill-internal) if obj @@ -64,6 +221,14 @@ pub fn make_schema_compatible(schema: &mut Value) { obj.remove("format"); } + // 2b. Rewrite Windmill pseudo-type `resource` to `string`. The parser emits + // this shape (with a sibling `resourceType` key) for `list[ResourceType]` + // params; "resource" is not in the JSON Schema 2020-12 type enum and is + // rejected by strict validators (e.g. Anthropic's tool registration). + if obj.get("type").and_then(|v| v.as_str()) == Some("resource") { + obj.insert("type".to_string(), Value::String("string".to_string())); + } + // 3. Fix contradictory type: if `properties` is present, type must be "object" if obj.contains_key("properties") { match obj.get("type").and_then(|v| v.as_str()) { @@ -151,8 +316,27 @@ pub fn make_schema_compatible(schema: &mut Value) { #[cfg(test)] mod tests { - use super::make_schema_compatible; + use super::*; + use crate::common::types::{ResourceInfo, ResourceType}; use serde_json::json; + use std::collections::HashMap; + + fn aws_resources() -> (HashMap>, Vec) { + let mut cache = HashMap::new(); + cache.insert( + "c_aws_account".to_string(), + vec![ResourceInfo { + path: "f/platform/aws_dev".to_string(), + description: Some("Dev account".to_string()), + resource_type: "c_aws_account".to_string(), + }], + ); + let types = vec![ResourceType { + name: "c_aws_account".to_string(), + description: Some("AWS account".to_string()), + }]; + (cache, types) + } #[test] fn converts_nested_integer_types() { @@ -356,6 +540,96 @@ mod tests { assert_eq!(schema, json!({})); } + #[test] + fn rewrites_resource_pseudo_type_to_string() { + let mut schema = json!({ + "type": "resource", + "resourceType": "c_aws_account" + }); + + make_schema_compatible(&mut schema); + + assert_eq!(schema["type"], json!("string")); + assert!(schema.get("resourceType").is_none()); + } + + #[test] + fn rewrites_resource_pseudo_type_inside_array_items() { + // Phocas repro: list[ResourceType] parameter. Anthropic rejected this + // with "tools..custom.input_schema: JSON schema is invalid" because + // "resource" is not in the draft 2020-12 type enum. + let mut schema = json!({ + "type": "object", + "properties": { + "accounts": { + "type": "array", + "items": { + "type": "resource", + "resourceType": "c_aws_account" + } + } + } + }); + + make_schema_compatible(&mut schema); + + assert_eq!( + schema["properties"]["accounts"]["items"]["type"], + json!("string") + ); + assert!(schema["properties"]["accounts"]["items"] + .get("resourceType") + .is_none()); + } + + #[test] + fn rewrites_resource_pseudo_type_inside_nested_properties() { + let mut schema = json!({ + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "db": { + "type": "resource", + "resourceType": "postgresql" + } + } + } + } + }); + + make_schema_compatible(&mut schema); + + assert_eq!( + schema["properties"]["config"]["properties"]["db"]["type"], + json!("string") + ); + assert!(schema["properties"]["config"]["properties"]["db"] + .get("resourceType") + .is_none()); + } + + #[test] + fn strips_nested_resource_format_inside_array_items() { + let mut schema = json!({ + "type": "object", + "properties": { + "dbs": { + "type": "array", + "items": { + "type": "object", + "format": "resource-postgresql" + } + } + } + }); + + make_schema_compatible(&mut schema); + + assert!(schema["properties"]["dbs"]["items"].get("format").is_none()); + } + #[test] fn adds_type_to_schema_with_properties_but_no_type() { let mut schema = json!({ @@ -368,4 +642,144 @@ mod tests { assert_eq!(schema["type"], json!("object")); } + + #[test] + fn extract_resource_types_top_level_form_a() { + let schema: SchemaType = serde_json::from_value(json!({ + "type": "object", + "properties": { + "db": { "type": "object", "format": "resource-postgresql" } + }, + "required": [] + })) + .unwrap(); + + let types = extract_resource_types_from_schema(&schema); + assert!(types.contains("postgresql")); + assert_eq!(types.len(), 1); + } + + #[test] + fn extract_resource_types_inside_array_items_form_b() { + let schema: SchemaType = serde_json::from_value(json!({ + "type": "object", + "properties": { + "accounts": { + "type": "array", + "items": { "type": "resource", "resourceType": "c_aws_account" } + } + }, + "required": [] + })) + .unwrap(); + + let types = extract_resource_types_from_schema(&schema); + assert!(types.contains("c_aws_account")); + } + + #[test] + fn extract_resource_types_inside_nested_properties_and_one_of() { + let schema: SchemaType = serde_json::from_value(json!({ + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "db": { "type": "resource", "resourceType": "postgresql" } + } + }, + "either": { + "oneOf": [ + { "type": "object", "format": "resource-mysql" }, + { "type": "string" } + ] + } + }, + "required": [] + })) + .unwrap(); + + let types = extract_resource_types_from_schema(&schema); + assert!(types.contains("postgresql")); + assert!(types.contains("mysql")); + } + + #[test] + fn enrich_top_level_form_a_resource() { + let (cache, types) = aws_resources(); + let mut node = json!({ + "type": "object", + "format": "resource-c_aws_account" + }); + + enrich_resource_schemas(&mut node, &cache, &types); + + assert_eq!(node["type"], json!("string")); + assert!(node.get("format").is_none()); + let desc = node["description"].as_str().unwrap(); + assert!(desc.contains("c_aws_account")); + assert!(desc.contains("$res:f/platform/aws_dev")); + } + + #[test] + fn enrich_inside_array_items_form_b() { + // Phocas repro: items use the parser-style `type: resource` form. + let (cache, types) = aws_resources(); + let mut node = json!({ + "type": "array", + "items": { "type": "resource", "resourceType": "c_aws_account" } + }); + + enrich_resource_schemas(&mut node, &cache, &types); + + // The items schema should be rewritten to string with a description + // listing the available resources, and the Windmill-internal + // resourceType key should be stripped. + assert_eq!(node["items"]["type"], json!("string")); + assert!(node["items"].get("resourceType").is_none()); + let desc = node["items"]["description"].as_str().unwrap(); + assert!(desc.contains("$res:f/platform/aws_dev")); + } + + #[test] + fn enrich_is_noop_when_resource_type_not_in_cache() { + let mut node = json!({ + "type": "object", + "format": "resource-unknown_type" + }); + let before = node.clone(); + + enrich_resource_schemas(&mut node, &HashMap::new(), &[]); + + assert_eq!(node, before); + } + + #[test] + fn enrich_deeply_nested_resource() { + let (cache, types) = aws_resources(); + let mut node = json!({ + "type": "object", + "properties": { + "outer": { + "type": "object", + "properties": { + "inner": { + "type": "array", + "items": { + "type": "resource", + "resourceType": "c_aws_account" + } + } + } + } + } + }); + + enrich_resource_schemas(&mut node, &cache, &types); + + assert_eq!( + node["properties"]["outer"]["properties"]["inner"]["items"]["type"], + json!("string") + ); + } } From 5e909b2b4f2819f19deaf06d9e78e6458b324683 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 14 May 2026 08:16:24 +0000 Subject: [PATCH 29/40] feat(git-sync): sync extra_perms for flows/scripts/apps (#9162) * feat(git-sync): sync extra_perms for flows/scripts/apps * fix(git-sync): hash extra_perms, fix script no-op + up-to-date checks * refactor(git-sync): route extra_perms through /acls/* instead of update endpoints * fix(git-sync): dispatch raw_app perm changes via DeployedObject::RawApp * fix(git-sync): wire applyExtraPermsDiff into pushRawApp + per-change logs * feat(git-sync): opt-in tarball perms, no-op when yaml omits, audit logs, validation * fix(git-sync): replace remaining bool literal in EE-only trigger export call * fix(git-sync): raw_app /acls/* hits app table; refetch after create * fix(git-sync): drop unnecessary post-deploy refetch (folder perms not merged) * fix(git-sync): raw_app /acls/get; reject malformed local extra_perms * audit: distinct raw_apps prefix so dashboards can split raw_app ACL events --- .../windmill-api-groups/src/granular_acls.rs | 303 +++++++++++++----- backend/windmill-api/src/workspaces_export.rs | 203 +++++++++--- cli/src/commands/app/app.ts | 50 ++- cli/src/commands/app/raw_apps.ts | 69 ++-- cli/src/commands/flow/flow.ts | 58 +++- cli/src/commands/script/script.ts | 36 +++ cli/src/commands/sync/pull.ts | 6 +- cli/src/core/extra_perms.ts | 192 +++++++++++ 8 files changed, 731 insertions(+), 186 deletions(-) create mode 100644 cli/src/core/extra_perms.ts diff --git a/backend/windmill-api-groups/src/granular_acls.rs b/backend/windmill-api-groups/src/granular_acls.rs index 95243b6626..3f0549f4e8 100644 --- a/backend/windmill-api-groups/src/granular_acls.rs +++ b/backend/windmill-api-groups/src/granular_acls.rs @@ -12,6 +12,9 @@ use axum::{ Json, Router, }; use windmill_api_auth::require_owner_of_path; +use windmill_audit::audit_oss::audit_log; +use windmill_audit::ActionKind; +use windmill_common::scripts::ScriptHash; use windmill_common::DB; use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; @@ -24,6 +27,31 @@ use windmill_common::{ utils::{not_found_if_none, StripPath}, }; +/// Map a granular-ACL kind segment to the audit-log action prefix used by the +/// per-kind CRUD endpoints (e.g. `flows.update`, `scripts.update`). The +/// resulting action is suffixed with `.grant_acl` / `.revoke_acl` so the +/// audit log keeps a per-resource record of every `/acls/*` mutation — +/// folder/group already log via their dedicated permission-history tables. +fn audit_action_prefix_for_acl_kind(kind: &str) -> Option<&'static str> { + match kind { + "script" => Some("scripts"), + "flow" => Some("flows"), + "app" => Some("apps"), + // Distinct prefix so dashboards aggregating on `action` can separate + // raw_app ACL mutations from regular app ones without parsing the + // `kind` parameters field. (The granular_acls SQL routes raw_app + // writes to the same `app` table; audit log identity is separate.) + "raw_app" => Some("raw_apps"), + "resource" => Some("resources"), + "variable" => Some("variables"), + "schedule" => Some("schedules"), + "http_trigger" | "websocket_trigger" | "kafka_trigger" | "nats_trigger" + | "postgres_trigger" | "mqtt_trigger" | "gcp_trigger" | "azure_trigger" | "sqs_trigger" + | "email_trigger" => Some("triggers"), + _ => None, + } +} + const KINDS: [&str; 20] = [ "script", "group_", @@ -131,9 +159,15 @@ async fn add_granular_acl( } } + // v2 raw apps are stored in the `app` table (with `app_version.raw_app = true` + // distinguishing them from regular apps); the legacy `raw_app` table no longer + // backs the workspace export, so granting/revoking on `raw_app` must hit `app` + // for the change to be visible. Git-sync dispatch still uses + // DeployedObject::RawApp so the worker writes back `.raw_app.json`. + let table = if kind == "raw_app" { "app" } else { kind }; // SAFETY: `kind` has been validated against the `KINDS` allowlist before reaching this function. let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!( - "UPDATE {kind} SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2), \ + "UPDATE {table} SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2), \ true) WHERE {identifier} = $3 AND workspace_id = $4 RETURNING extra_perms" )) .bind(vec![owner.clone()]) @@ -175,6 +209,34 @@ async fn add_granular_acl( Some(&owner), ) .await?; + } else if let Some(prefix) = audit_action_prefix_for_acl_kind(kind) { + // Mirror the folder/group permission-history coverage for every other + // ACLable kind. Folder/group already wrote a dedicated history row + // above; everything else (script/flow/app/raw_app/resource/...) lands + // in the general audit_log table here. + let access = if write.unwrap_or(false) { + "write" + } else { + "read" + }; + let action = format!("{}.grant_acl", prefix); + audit_log( + &mut *tx, + &authed, + action.as_str(), + ActionKind::Update, + &w_id, + Some(path), + Some( + [ + ("kind", kind), + ("owner", owner.as_str()), + ("access", access), + ] + .into(), + ), + ) + .await?; } tx.commit().await?; @@ -193,46 +255,67 @@ async fn add_granular_acl( ) .await? } - // "app" => { - // handle_deployment_metadata( - // &authed.email, - // &authed.username, - // &db, - // &w_id, - // DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 }, - // Some(format!("App '{}' changed permissions", path)), - // // true, - // ) - // .await? - // } - // "script" => { - // handle_deployment_metadata( - // &authed.email, - // &authed.username, - // &db, - // &w_id, - // DeployedObject::Script { - // path: path.to_string(), - // parent_path: None, - // hash: ScriptHash(0), - // }, - // Some(format!("Script '{}' changed permissions", path)), - // // true, - // ) - // .await? - // } - // "flow" => { - // handle_deployment_metadata( - // &authed.email, - // &authed.username, - // &db, - // &w_id, - // DeployedObject::Flow { path: path.to_string(), parent_path: None }, - // Some(format!("Flow '{}' changed permissions", path)), - // // true, - // ) - // .await? - // } + "app" => { + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 }, + Some(format!("App '{}' changed permissions", path)), + true, + None, + ) + .await? + } + "raw_app" => { + // RawApp deliberately uses its own DeployedObject variant: the + // git-sync worker reads `path_type` ("app" vs "raw_app") to decide + // whether to write `.app.json` or `.raw_app.json`. + // Collapsing this into `App` would dispatch raw_app perm changes + // against the wrong file in the repo. + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::RawApp { path: path.to_string(), parent_path: None, version: 0 }, + Some(format!("Raw App '{}' changed permissions", path)), + true, + None, + ) + .await? + } + "script" => { + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Script { + path: path.to_string(), + parent_path: None, + hash: ScriptHash(0), + }, + Some(format!("Script '{}' changed permissions", path)), + true, + None, + ) + .await? + } + "flow" => { + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Flow { path: path.to_string(), parent_path: None, version: 0 }, + Some(format!("Flow '{}' changed permissions", path)), + true, + None, + ) + .await? + } _ => (), } @@ -295,13 +378,17 @@ async fn remove_granular_acl( require_owner_of_path(&authed, path)?; } + // See add_granular_acl: kind="raw_app" must hit the `app` table because v2 + // raw apps live there and the legacy `raw_app` table no longer backs the + // workspace export. + let table = if kind == "raw_app" { "app" } else { kind }; // SAFETY: `kind` has been validated against the `KINDS` allowlist before reaching this function. let obj_o = sqlx::query_scalar::<_, bool>(&format!( "WITH old AS ( - SELECT extra_perms->$1 as old_write FROM {kind} + SELECT extra_perms->$1 as old_write FROM {table} WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1 ) - UPDATE {kind} SET extra_perms = extra_perms - $1 + UPDATE {table} SET extra_perms = extra_perms - $1 WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1 RETURNING (SELECT old_write FROM old)::bool" )) @@ -335,6 +422,28 @@ async fn remove_granular_acl( Some(&owner), ) .await?; + } else if let Some(prefix) = audit_action_prefix_for_acl_kind(kind) { + // Mirror the add path: standard audit_log row for every kind that + // doesn't have a dedicated permission-history table. + let access = if write { "write" } else { "read" }; + let action = format!("{}.revoke_acl", prefix); + audit_log( + &mut *tx, + &authed, + action.as_str(), + ActionKind::Update, + &w_id, + Some(path), + Some( + [ + ("kind", kind), + ("owner", owner.as_str()), + ("access", access), + ] + .into(), + ), + ) + .await?; } tx.commit().await?; @@ -353,46 +462,68 @@ async fn remove_granular_acl( ) .await? } - // "app" => { - // handle_deployment_metadata( - // &authed.email, - // &authed.username, - // &db, - // &w_id, - // DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 }, - // Some(format!("App '{}' changed permissions", path)), - // // true, - // ) - // .await? - // } - // "script" => { - // handle_deployment_metadata( - // &authed.email, - // &authed.username, - // &db, - // &w_id, - // DeployedObject::Script { - // path: path.to_string(), - // parent_path: None, - // hash: ScriptHash(0), - // }, - // Some(format!("Script '{}' changed permissions", path)), - // // true, - // ) - // .await? - // } - // "flow" => { - // handle_deployment_metadata( - // &authed.email, - // &authed.username, - // &db, - // &w_id, - // DeployedObject::Flow { path: path.to_string(), parent_path: None }, - // Some(format!("Flow '{}' changed permissions", path)), - // // true, - // ) - // .await? - // } + "app" => { + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 }, + Some(format!("App '{}' changed permissions", path)), + true, + None, + ) + .await? + } + "raw_app" => { + // See add_granular_acl: raw_app must use its own DeployedObject + // variant so git-sync writes `.raw_app.json`. + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::RawApp { + path: path.to_string(), + parent_path: None, + version: 0, + }, + Some(format!("Raw App '{}' changed permissions", path)), + true, + None, + ) + .await? + } + "script" => { + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Script { + path: path.to_string(), + parent_path: None, + hash: ScriptHash(0), + }, + Some(format!("Script '{}' changed permissions", path)), + true, + None, + ) + .await? + } + "flow" => { + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Flow { path: path.to_string(), parent_path: None, version: 0 }, + Some(format!("Flow '{}' changed permissions", path)), + true, + None, + ) + .await? + } _ => (), } } @@ -421,9 +552,13 @@ async fn get_granular_acls( } else { "path" }; + // See add_granular_acl: raw_app rows live in the `app` table now, so the + // read path must also target `app` — otherwise GET would return stale or + // 404 state while POST /acls/add and /acls/remove write to `app`. + let table = if kind == "raw_app" { "app" } else { kind }; // SAFETY: `kind` has been validated against the `KINDS` allowlist before reaching this function. let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!( - "SELECT extra_perms from {kind} WHERE {identifier} = $1 AND workspace_id = $2" + "SELECT extra_perms from {table} WHERE {identifier} = $1 AND workspace_id = $2" )) .bind(path) .bind(w_id) diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index e6b3b9ee0f..3f0cd3c646 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -110,6 +110,12 @@ struct ScriptMetadata { pub debouncing_settings: DebouncingSettings, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + #[serde(skip_serializing_if = "is_empty_extra_perms")] + pub extra_perms: serde_json::Value, +} + +fn is_empty_extra_perms(value: &serde_json::Value) -> bool { + value.as_object().is_some_and(|o| o.is_empty()) || value.is_null() } pub fn is_none_or_false(val: &Option) -> bool { @@ -224,12 +230,34 @@ pub(crate) struct ArchiveQueryParams { default_ts: Option, /// Settings format version: "v1" (default) returns legacy flat format, "v2" returns grouped format settings_version: Option, + /// Opt-in: include `extra_perms` on flow / script / app rows. Default `false` + /// so cross-workspace tarball imports do not carry over ACLs referring to + /// identities that may not exist in the target workspace. `wmill sync pull` + /// passes `true` to surface ACLs in the git-tracked yaml. + preserve_extra_perms: Option, +} + +/// How to handle `extra_perms` in the serialized output. +/// +/// * `Drop` — strip the field unconditionally (legacy behavior for +/// types that have never carried ACLs in source). +/// * `KeepEvenEmpty` — always keep the field, even when `{}`. Matches the +/// pre-existing serialization for folders and groups so +/// no customer sees a one-time noisy diff on upgrade. +/// * `KeepIfNonEmpty` — keep when there is at least one entry, drop when `{}` +/// or null. New surface for flow / script / app, which +/// never carried ACLs in source before this change. +#[derive(Clone, Copy)] +pub enum ExtraPermsBehavior { + Drop, + KeepEvenEmpty, + KeepIfNonEmpty, } #[inline] pub fn to_string_without_metadata( value: &T, - preserve_extra_perms: bool, + extra_perms: ExtraPermsBehavior, ignore_keys: Option>, ) -> Result where @@ -274,8 +302,19 @@ where o2.remove("on_behalf_of"); o2.remove("on_behalf_of_email"); } - if !preserve_extra_perms && obj.contains_key("extra_perms") { - obj.remove("extra_perms"); + if obj.contains_key("extra_perms") { + let is_empty_extra_perms = obj + .get("extra_perms") + .map(|v| v.as_object().is_some_and(|o| o.is_empty()) || v.is_null()) + .unwrap_or(true); + let drop = match extra_perms { + ExtraPermsBehavior::Drop => true, + ExtraPermsBehavior::KeepEvenEmpty => false, + ExtraPermsBehavior::KeepIfNonEmpty => is_empty_extra_perms, + }; + if drop { + obj.remove("extra_perms"); + } } if obj .get("default_permissioned_as") @@ -442,6 +481,7 @@ pub(crate) async fn tarball_workspace( include_workspace_dependencies, default_ts, settings_version, + preserve_extra_perms, }): Query, ) -> Result<([(HeaderName, String); 2], impl IntoResponse)> { tracing::info!( @@ -452,6 +492,16 @@ pub(crate) async fn tarball_workspace( skip_resources ); + // Opt-in behavior for surfacing per-resource ACLs on flow/app rows. + // Folder and group rows have always carried `extra_perms` in source and + // continue to do so unconditionally (`KeepEvenEmpty`) so existing + // customer git repos see no one-time noisy diff. + let new_kinds_extra_perms = if preserve_extra_perms.unwrap_or(false) { + ExtraPermsBehavior::KeepIfNonEmpty + } else { + ExtraPermsBehavior::Drop + }; + let mut tx = user_db.begin(&authed).await?; // Source-of-truth check for fork-ness: the workspace's parent_workspace_id @@ -498,7 +548,8 @@ pub(crate) async fn tarball_workspace( for folder in folders { archive .write_to_archive( - &to_string_without_metadata(&folder, true, None).unwrap(), + &to_string_without_metadata(&folder, ExtraPermsBehavior::KeepEvenEmpty, None) + .unwrap(), &format!("f/{}/folder.meta.json", folder.name), ) .await?; @@ -506,15 +557,13 @@ pub(crate) async fn tarball_workspace( } { - let scripts = sqlx::query_as::<_, Script>( - &format!( - "SELECT {} FROM script as o WHERE workspace_id = $1 AND archived = false + let scripts = sqlx::query_as::<_, Script>(&format!( + "SELECT {} FROM script as o WHERE workspace_id = $1 AND archived = false AND (draft_only IS NULL OR draft_only = false) AND created_at = (select max(created_at) from script where path = o.path AND \ workspace_id = $1)", - windmill_common::scripts::SCRIPT_COLUMNS, - ), - ) + windmill_common::scripts::SCRIPT_COLUMNS, + )) .bind(&w_id) .fetch_all(&mut *tx) .await?; @@ -587,6 +636,15 @@ pub(crate) async fn tarball_workspace( on_behalf_of_email: script.on_behalf_of_email, modules: script.modules, labels: script.labels, + // Same opt-in contract as flow/app: the tarball only surfaces + // ACLs when `?preserve_extra_perms=true`. Passing `Null` lets the + // `is_empty_extra_perms` skip-serializer drop the field entirely. + extra_perms: if matches!(new_kinds_extra_perms, ExtraPermsBehavior::KeepIfNonEmpty) + { + script.extra_perms + } else { + serde_json::Value::Null + }, }; let metadata_str = serde_json::to_string_pretty(&metadata).unwrap(); archive @@ -605,7 +663,8 @@ pub(crate) async fn tarball_workspace( .await?; for resource in resources { - let resource_str = &to_string_without_metadata(&resource, false, None).unwrap(); + let resource_str = + &to_string_without_metadata(&resource, ExtraPermsBehavior::Drop, None).unwrap(); archive .write_to_archive(&resource_str, &format!("{}.resource.json", resource.path)) .await?; @@ -622,7 +681,9 @@ pub(crate) async fn tarball_workspace( .await?; for resource_type in resource_types { - let resource_str = &to_string_without_metadata(&resource_type, false, None).unwrap(); + let resource_str = + &to_string_without_metadata(&resource_type, ExtraPermsBehavior::Drop, None) + .unwrap(); archive .write_to_archive( &resource_str, @@ -644,7 +705,7 @@ pub(crate) async fn tarball_workspace( .await?; for flow in flows { - let flow_str = &to_string_without_metadata(&flow, false, None).unwrap(); + let flow_str = &to_string_without_metadata(&flow, new_kinds_extra_perms, None).unwrap(); archive .write_to_archive(&flow_str, &format!("{}.flow.json", flow.path)) .await?; @@ -673,7 +734,8 @@ pub(crate) async fn tarball_workspace( Error::internal_err(format!("Error decrypting variable {}: {}", var.path, e)) })?); } - let var_str = &to_string_without_metadata(&var, false, None).unwrap(); + let var_str = + &to_string_without_metadata(&var, ExtraPermsBehavior::Drop, None).unwrap(); archive .write_to_archive(&var_str, &format!("{}.variable.json", var.path)) .await?; @@ -693,7 +755,7 @@ pub(crate) async fn tarball_workspace( .await?; for app in apps { - let app_str = &to_string_without_metadata(&app, false, None).unwrap(); + let app_str = &to_string_without_metadata(&app, new_kinds_extra_perms, None).unwrap(); let kind = if app.raw_app { "raw_app" } else { "app" }; archive .write_to_archive(&app_str, &format!("{}.{}.json", app.path, kind)) @@ -711,7 +773,7 @@ pub(crate) async fn tarball_workspace( workspace_dependencies.len() ); for dep in workspace_dependencies { - // let dep_str = &to_string_without_metadata(&dep, false, None).unwrap(); + // let dep_str = &to_string_without_metadata(&dep, ExtraPermsBehavior::Drop, None).unwrap(); let filename = WorkspaceDependencies::to_path(&dep.name, dep.language)?; tracing::info!( "Adding workspace dependency: name={:?}, language={:?}, filename={}", @@ -739,9 +801,12 @@ pub(crate) async fn tarball_workspace( let schedule_ignore_keys = fork_schedule_ignore_keys(is_fork); for schedule in schedules { - let app_str = - &to_string_without_metadata(&schedule, false, schedule_ignore_keys.clone()) - .unwrap(); + let app_str = &to_string_without_metadata( + &schedule, + ExtraPermsBehavior::Drop, + schedule_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive(&app_str, &format!("{}.schedule.json", schedule.path)) .await?; @@ -777,9 +842,12 @@ pub(crate) async fn tarball_workspace( let http_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in http_triggers { - let trigger_str = - &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) - .unwrap(); + let trigger_str = &to_string_without_metadata( + &trigger, + ExtraPermsBehavior::Drop, + trigger_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -796,9 +864,12 @@ pub(crate) async fn tarball_workspace( let websocket_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in websocket_triggers { - let trigger_str = - &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) - .unwrap(); + let trigger_str = &to_string_without_metadata( + &trigger, + ExtraPermsBehavior::Drop, + trigger_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -815,9 +886,12 @@ pub(crate) async fn tarball_workspace( let kafka_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in kafka_triggers { - let trigger_str = - &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) - .unwrap(); + let trigger_str = &to_string_without_metadata( + &trigger, + ExtraPermsBehavior::Drop, + trigger_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -834,9 +908,12 @@ pub(crate) async fn tarball_workspace( let sqs_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in sqs_triggers { - let trigger_str = - &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) - .unwrap(); + let trigger_str = &to_string_without_metadata( + &trigger, + ExtraPermsBehavior::Drop, + trigger_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -853,9 +930,12 @@ pub(crate) async fn tarball_workspace( let gcp_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in gcp_triggers { - let trigger_str = - &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) - .unwrap(); + let trigger_str = &to_string_without_metadata( + &trigger, + ExtraPermsBehavior::Drop, + trigger_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -872,9 +952,12 @@ pub(crate) async fn tarball_workspace( let azure_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in azure_triggers { - let trigger_str = - &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) - .unwrap(); + let trigger_str = &to_string_without_metadata( + &trigger, + ExtraPermsBehavior::Drop, + trigger_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -891,9 +974,12 @@ pub(crate) async fn tarball_workspace( let nats_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in nats_triggers { - let trigger_str: &String = - &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) - .unwrap(); + let trigger_str: &String = &to_string_without_metadata( + &trigger, + ExtraPermsBehavior::Drop, + trigger_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -910,9 +996,12 @@ pub(crate) async fn tarball_workspace( let postgres_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in postgres_triggers { - let trigger_str = - &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) - .unwrap(); + let trigger_str = &to_string_without_metadata( + &trigger, + ExtraPermsBehavior::Drop, + trigger_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -929,9 +1018,12 @@ pub(crate) async fn tarball_workspace( let mqtt_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in mqtt_triggers { - let trigger_str = - &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) - .unwrap(); + let trigger_str = &to_string_without_metadata( + &trigger, + ExtraPermsBehavior::Drop, + trigger_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -948,9 +1040,12 @@ pub(crate) async fn tarball_workspace( let email_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in email_triggers { - let trigger_str = - &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) - .unwrap(); + let trigger_str = &to_string_without_metadata( + &trigger, + ExtraPermsBehavior::Drop, + trigger_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -978,7 +1073,7 @@ pub(crate) async fn tarball_workspace( for trigger in native_triggers { let trigger_str = &to_string_without_metadata( &trigger, - false, + ExtraPermsBehavior::Drop, Some(native_ignore_keys.clone()), ) .unwrap(); @@ -1021,7 +1116,9 @@ pub(crate) async fn tarball_workspace( disabled: user.disabled, email: user.email, }; - let user_str = &to_string_without_metadata(&user, false, Some(vec!["email"])).unwrap(); + let user_str = + &to_string_without_metadata(&user, ExtraPermsBehavior::Drop, Some(vec!["email"])) + .unwrap(); archive .write_to_archive(&user_str, &format!("users/{}.user.json", user.email)) .await?; @@ -1081,7 +1178,9 @@ pub(crate) async fn tarball_workspace( admins, }; - let group_str = &to_string_without_metadata(&group, true, None).unwrap(); + let group_str = + &to_string_without_metadata(&group, ExtraPermsBehavior::KeepEvenEmpty, None) + .unwrap(); archive .write_to_archive(&group_str, &format!("groups/{}.group.json", group.name)) .await?; diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index 2749c0269c..a6f85fde1c 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -20,12 +20,18 @@ import newCommand from "./new.ts"; import generateAgentsCommand from "./generate_agents.ts"; import { isVersionsGeq1585 } from "../sync/global.ts"; import type { PermissionedAsContext } from "../../core/permissioned_as.ts"; +import { applyExtraPermsDiff } from "../../core/extra_perms.ts"; export interface AppFile { value: any; public?: boolean; summary: string; policy: Policy; + // Mirrors granular ACLs on the app path. Omitted from app.yaml when no + // perms are set. The CLI applies diffs through /acls/add and /acls/remove + // (see applyExtraPermsDiff) — never through update_app — so a perm-only + // change never bumps the app version. + extra_perms?: Record; } const alreadySynced: string[] = []; @@ -168,21 +174,27 @@ export async function pushApp( // On create: backend applies folder defaults } + // extra_perms goes through /acls/* — strip from the body so a perms-only + // edit never bumps the app version (see applyExtraPermsDiff for details). + const { extra_perms: localPerms, ...localAppBody } = localApp as AppFile & { + extra_perms?: Record; + }; + if (app) { - if (isSuperset(localApp, app)) { + if (isSuperset(localAppBody, app)) { log.info(colors.green(`App ${remotePath} is up to date`)); - return; + } else { + log.info(colors.bold.yellow(`Updating app ${remotePath}...`)); + await wmill.updateApp({ + workspace, + path: remotePath, + requestBody: { + deployment_message: message, + ...localAppBody, + ...preserveFields, + }, + }); } - log.info(colors.bold.yellow(`Updating app ${remotePath}...`)); - await wmill.updateApp({ - workspace, - path: remotePath, - requestBody: { - deployment_message: message, - ...localApp, - ...preserveFields, - }, - }); } else { log.info(colors.yellow.bold("Creating new app...")); @@ -191,11 +203,23 @@ export async function pushApp( requestBody: { path: remotePath, deployment_message: message, - ...localApp, + ...localAppBody, ...preserveFields, }, }); } + + // Independent perms sync via /acls/* — self-contained log + non-fatal errors. + // No refetch: extra_perms is item-specific and folder perms are never merged + // onto item.extra_perms, and the body sent to update_app / create_app omits + // the field — so the value we already have from getAppByPath is authoritative. + await applyExtraPermsDiff( + workspace, + "app", + remotePath, + localPerms, + (app as any)?.extra_perms, + ); } export async function generatingPolicy( diff --git a/cli/src/commands/app/raw_apps.ts b/cli/src/commands/app/raw_apps.ts index 43c39ec9e4..5b9e880adc 100644 --- a/cli/src/commands/app/raw_apps.ts +++ b/cli/src/commands/app/raw_apps.ts @@ -19,6 +19,7 @@ import { createBundle, detectFrameworks } from "./bundle.ts"; import { APP_BACKEND_FOLDER } from "./app_metadata.ts"; import { writeIfChanged } from "../../utils/utils.ts"; import { yamlOptions } from "../sync/sync.ts"; +import { applyExtraPermsDiff } from "../../core/extra_perms.ts"; import { EXTENSION_TO_LANGUAGE, getLanguageFromExtension, @@ -35,6 +36,10 @@ export interface AppFile { datatable?: string; schema?: string; }; + // Mirrors granular ACLs on the raw_app path. Synced via /acls/* by + // applyExtraPermsDiff — never through update_app_raw — so a perm-only + // change never bumps the app version. Stripped from the yaml when empty. + extra_perms?: Record; } // Match siblings of a YAML metadata file case-insensitively. A buggy CLI @@ -431,35 +436,45 @@ export async function pushRawApp( value.data = localApp.data; } + // extra_perms is synced independently via /acls/* — strip from the + // up-to-date comparison so a perm-only edit doesn't trigger a rebuild + + // new app_version. The kind segment is "raw_app" so git-sync writes back + // to `.raw_app.json`, not `.app.json`. The backend granular_acls + // handler routes "raw_app" to the `app` table (where v2 raw apps actually + // live) while still dispatching DeployedObject::RawApp for git-sync. + const { extra_perms: localPerms, ...localAppNoPerms } = localApp as AppFile & { + extra_perms?: Record; + }; + if (app) { // Check both metadata/runnables AND files for changes // Files need separate comparison because isSuperset only checks if local keys exist in remote - const metadataUpToDate = isSuperset({ ...localApp, runnables }, app); + const metadataUpToDate = isSuperset({ ...localAppNoPerms, runnables }, app); const filesUpToDate = deepEqual(files, app.value?.files); if (metadataUpToDate && filesUpToDate) { log.info(colors.green(`App ${remotePath} is up to date`)); - return; - } - const { js, css } = await createBundleRaw(); - log.info(colors.bold.yellow(`Updating app ${remotePath}...`)); - await wmill.updateAppRaw({ - workspace, - path: remotePath, - formData: { - app: { - value, - path: remotePath, - summary: localApp.summary, - policy: appForPolicy.policy, - deployment_message: message, - ...(localApp.custom_path - ? { custom_path: localApp.custom_path } - : {}), + } else { + const { js, css } = await createBundleRaw(); + log.info(colors.bold.yellow(`Updating app ${remotePath}...`)); + await wmill.updateAppRaw({ + workspace, + path: remotePath, + formData: { + app: { + value, + path: remotePath, + summary: localApp.summary, + policy: appForPolicy.policy, + deployment_message: message, + ...(localApp.custom_path + ? { custom_path: localApp.custom_path } + : {}), + }, + js, + css, }, - js, - css, - }, - }); + }); + } } else { const { js, css } = await createBundleRaw(); await wmill.createAppRaw({ @@ -480,6 +495,16 @@ export async function pushRawApp( }, }); } + + // No refetch needed: folder perms are never merged into item.extra_perms, + // and the body sent to update_app_raw / create_app_raw omits the field. + await applyExtraPermsDiff( + workspace, + "raw_app", + remotePath, + localPerms, + (app as any)?.extra_perms, + ); } export async function generatingPolicy( diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index b76b890053..7f437b8005 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -19,6 +19,7 @@ import { defaultFlowDefinition } from "../../../bootstrap/flow_bootstrap.ts"; import { SyncOptions, mergeConfigWithConfigFile } from "../../core/conf.ts"; import { FSFSElement, elementsToMap, ignoreF } from "../sync/sync.ts"; import { Flow } from "../../../gen/types.gen.ts"; +import { applyExtraPermsDiff } from "../../core/extra_perms.ts"; import type { PermissionedAsContext } from "../../core/permissioned_as.ts"; import { collectPathScriptPaths, @@ -41,6 +42,11 @@ export interface FlowFile { schema?: any; on_behalf_of_email?: string; has_on_behalf_of?: boolean; + // Mirrors granular ACLs on the flow path. Omitted from flow.yaml when no + // perms are set. The CLI applies diffs through /acls/add and /acls/remove + // (see applyExtraPermsDiff) — never through update_flow — so a perm-only + // change never bumps the flow version. + extra_perms?: Record; } function normalizeOptionalString(value: string | null | undefined): string | undefined { @@ -197,22 +203,30 @@ export async function pushFlow( // On create: backend applies folder defaults — no client-side resolution needed } + // extra_perms is synced independently via /acls/* (see applyExtraPermsDiff) + // so a perm-only edit never bumps the flow version. Strip the field from the + // body that goes to update_flow / create_flow and treat it as a separate + // step both for the up-to-date short-circuit and after the deploy. + const { extra_perms: localPerms, ...localFlowBody } = localFlow as FlowFile & { + extra_perms?: Record; + }; + if (flow) { - if (isSuperset(localFlow, flow)) { + if (isSuperset(localFlowBody, flow)) { log.info(colors.green(`Flow ${remotePath} is up to date`)); - return; - } - log.info(colors.bold.yellow(`Updating flow ${remotePath}...`)); - await wmill.updateFlow({ - workspace: workspace, - path: remotePath.replaceAll(SEP, "/"), - requestBody: { + } else { + log.info(colors.bold.yellow(`Updating flow ${remotePath}...`)); + await wmill.updateFlow({ + workspace: workspace, path: remotePath.replaceAll(SEP, "/"), - deployment_message: message, - ...localFlow, - ...preserveFields, - }, - }); + requestBody: { + path: remotePath.replaceAll(SEP, "/"), + deployment_message: message, + ...localFlowBody, + ...preserveFields, + }, + }); + } } else { log.info(colors.bold.yellow("Creating new flow...")); try { @@ -221,7 +235,7 @@ export async function pushFlow( requestBody: { path: remotePath.replaceAll(SEP, "/"), deployment_message: message, - ...localFlow, + ...localFlowBody, ...preserveFields, }, }); @@ -232,6 +246,22 @@ export async function pushFlow( ); } } + + // Independent of whether the flow body changed, sync extra_perms via /acls/*. + // Self-contained log line + non-fatal failures. + // + // No refetch is needed: extra_perms is item-specific and additive on top of + // folder perms — folder perms are never merged onto item.extra_perms. And + // since the request body sent to update_flow / create_flow doesn't carry + // extra_perms, the value we read in the initial getFlowByPath above is + // also the post-write value (a no-op deploy can't drift it). + await applyExtraPermsDiff( + workspace, + "flow", + remotePath.replaceAll(SEP, "/"), + localPerms, + (flow as any)?.extra_perms, + ); } type Options = GlobalOptions; diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index f1e605d8ff..b9d9c6ee60 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -2,6 +2,7 @@ import { GlobalOptions } from "../../types.ts"; import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace, validatePath } from "../../core/context.ts"; import type { PermissionedAsContext } from "../../core/permissioned_as.ts"; +import { applyExtraPermsDiff } from "../../core/extra_perms.ts"; import { writeFile, stat, mkdir } from "node:fs/promises"; import { Buffer } from "node:buffer"; import { colors } from "@cliffy/ansi/colors"; @@ -83,6 +84,11 @@ export interface ScriptFile { is_template?: boolean; lock?: Array; kind?: "script" | "failure" | "trigger" | "command" | "approval"; + // Mirrors granular ACLs on the script path. Omitted from .script.yaml when + // no perms are set. The CLI applies diffs through /acls/add and /acls/remove + // (see applyExtraPermsDiff) — never through create_script — so a perm-only + // change never bumps the script hash/version. + extra_perms?: Record; } /** @@ -542,6 +548,15 @@ export async function handleFile( deepEqual(modules ?? null, remote.modules ?? null)) ) { log.info(colors.green(`Script ${remotePath} is up to date`)); + // Even when the body is unchanged, perms may still drift — sync them + // independently before returning. + await applyExtraPermsDiff( + workspaceId, + "script", + remotePath.replaceAll(SEP, "/"), + (typed as any)?.extra_perms, + (remote as any)?.extra_perms, + ); return true; } } @@ -581,6 +596,27 @@ export async function handleFile( ) ); } + + // Sync granular ACLs as an independent step — perm-only edits never reach + // create_script (which would bump the script hash) and instead route + // through /acls/* via applyExtraPermsDiff. + // + // No refetch is needed: + // - folder perms are additive at auth time, never merged onto item rows; + // - the body sent to create_script doesn't carry extra_perms, so a fresh + // deploy of an existing path inherits the previous version's perms + // unchanged. The diff against `remote` (captured before the deploy) + // therefore matches what `wmill acl remove` would do — and the granular + // ACL endpoint updates every matching row, so the inheritance on the + // new version doesn't leave ghost entries. + await applyExtraPermsDiff( + workspaceId, + "script", + remotePath.replaceAll(SEP, "/"), + (typed as any)?.extra_perms, + (remote as any)?.extra_perms, + ); + return true; } return false; diff --git a/cli/src/commands/sync/pull.ts b/cli/src/commands/sync/pull.ts index b89d81b91c..adfd486294 100644 --- a/cli/src/commands/sync/pull.ts +++ b/cli/src/commands/sync/pull.ts @@ -98,12 +98,16 @@ export async function downloadZip( } const includeWorkspaceDependenciesValue = !(skipWorkspaceDependencies ?? false); + // `preserve_extra_perms=true` opts the tarball into surfacing granular ACLs + // on flow / script / app rows. Default-off on the server protects cross- + // workspace tarball imports from carrying ACLs that reference identities + // missing in the target workspace; the CLI sync flow explicitly wants them. const baseParams = `&plain_secret=${plainSecrets ?? false }&skip_variables=${skipVariables ?? false}&skip_resources=${skipResources ?? false }&skip_secrets=${skipSecrets ?? false}&include_schedules=${includeSchedules ?? false }&include_triggers=${includeTriggers ?? false}&include_users=${includeUsers ?? false }&include_groups=${includeGroups ?? false}&include_settings=${includeSettings ?? false - }&include_key=${includeKey ?? false}&include_workspace_dependencies=${includeWorkspaceDependenciesValue}&default_ts=${defaultTs ?? "bun"}&skip_resource_types=${skipResourceTypes ?? false}&settings_version=v2`; + }&include_key=${includeKey ?? false}&include_workspace_dependencies=${includeWorkspaceDependenciesValue}&default_ts=${defaultTs ?? "bun"}&skip_resource_types=${skipResourceTypes ?? false}&settings_version=v2&preserve_extra_perms=true`; const baseUrl = workspace.remote + "api/w/" + workspace.workspaceId + "/workspaces/tarball?"; diff --git a/cli/src/core/extra_perms.ts b/cli/src/core/extra_perms.ts new file mode 100644 index 0000000000..7b50d34c8d --- /dev/null +++ b/cli/src/core/extra_perms.ts @@ -0,0 +1,192 @@ +import * as wmill from "../../gen/services.gen.ts"; +import * as log from "./log.ts"; +import { colors } from "@cliffy/ansi/colors"; +import type { AddGranularAclsData } from "../../gen/types.gen.ts"; + +export type ExtraPermsKind = AddGranularAclsData["kind"]; + +type PermsMap = Record; + +type Normalized = { + perms: PermsMap; + /** Keys with non-boolean values: never revoked, never granted — treat as "no opinion". */ + invalidOwners: Set; + /** Top-level value is not a plain object (array, primitive, etc.). The + * entire map is rejected and the caller must treat it like "no opinion" + * to avoid silently revoking every remote ACL. */ + malformedTop: boolean; +}; + +function normalize(value: unknown, source: string): Normalized { + const perms: PermsMap = {}; + const invalidOwners = new Set(); + + if ( + value === null || + value === undefined || + typeof value !== "object" || + Array.isArray(value) + ) { + if (value !== undefined && value !== null) { + log.error( + colors.red( + `extra_perms: ${source} is not a {owner: boolean} map — skipping ACL sync to avoid clobbering remote ACLs`, + ), + ); + return { perms, invalidOwners, malformedTop: true }; + } + return { perms, invalidOwners, malformedTop: false }; + } + + const invalidList: string[] = []; + for (const [k, v] of Object.entries(value as Record)) { + if (typeof v === "boolean") { + perms[k] = v; + } else { + invalidOwners.add(k); + invalidList.push(k); + } + } + if (invalidList.length > 0) { + log.error( + colors.red( + `extra_perms: ${invalidList.length} invalid entry/entries in ${source} (non-boolean value, treating as "no opinion"): ${invalidList.join(", ")}`, + ), + ); + } + return { perms, invalidOwners, malformedTop: false }; +} + +function formatError(e: unknown): string { + if (!e || typeof e !== "object") return String(e); + const anyE = e as { body?: unknown; message?: unknown }; + if (anyE.body !== undefined) { + if (typeof anyE.body === "string") return anyE.body; + try { + return JSON.stringify(anyE.body); + } catch { + // fall through + } + } + if (typeof anyE.message === "string") return anyE.message; + try { + return JSON.stringify(e); + } catch { + return String(e); + } +} + +/** + * Apply the diff between `local` and `remote` granular ACL maps as a sequence + * of `/acls/add` and `/acls/remove` calls. Used by every CLI push path so a + * yaml change that *only* touches `extra_perms` never bumps the script/flow/app + * version — perm mutations route through the dedicated granular-ACL endpoints + * exactly as if the user had clicked through the UI. + * + * **`local === undefined` means "no opinion".** If the yaml does not carry an + * `extra_perms` field at all, this function is a no-op — the remote ACLs are + * left untouched. This is what prevents a stale local checkout from racing + * a concurrent UI grant: only users who explicitly track perms in source (by + * writing `extra_perms:` in the yaml, even as `{}`) get destructive sync. + * + * The function is intentionally independent of the surrounding push logic: + * it has its own log lines and its own non-fatal failure mode. Each grant / + * revoke is logged as a separate line on success; failures are logged in red + * but never throw — a stale yaml referencing a deleted user/group surfaces as + * a red error, not a hard error that would block the surrounding deploy. + * + * @returns number of /acls/* calls actually issued (0 means perms in sync, or + * the local yaml had no `extra_perms` field). + */ +export async function applyExtraPermsDiff( + workspace: string, + kind: ExtraPermsKind, + path: string, + local: unknown, + remote: unknown, +): Promise { + // Absent local field = "no opinion" — never call /acls/* in this case. + // Crucially, this protects users who don't track ACLs in source from having + // their UI-managed perms silently revoked by a stale CLI push. + if (local === undefined || local === null) { + return 0; + } + + const localN = normalize(local, "local yaml"); + // Top-level malformed (array, primitive, etc.) → treat as "no opinion" so a + // typo in yaml can never silently revoke every remote ACL. + if (localN.malformedTop) { + return 0; + } + const remoteN = normalize(remote, "remote response"); + + const localPerms = localN.perms; + const remotePerms = remoteN.perms; + + const toGrant: Array<[string, boolean]> = []; + for (const [owner, write] of Object.entries(localPerms)) { + if (!(owner in remotePerms) || remotePerms[owner] !== write) { + toGrant.push([owner, write]); + } + } + + // Owners with a malformed value in local yaml are treated as "no opinion": + // they are excluded from the revoke set so a typo (`g/devs: "write"`) never + // silently strips an existing ACL. + const toRevoke: string[] = Object.keys(remotePerms).filter( + (owner) => + !(owner in localPerms) && !localN.invalidOwners.has(owner), + ); + + if (toGrant.length === 0 && toRevoke.length === 0) { + return 0; + } + + let calls = 0; + for (const [owner, write] of toGrant) { + const access = write ? "write" : "read"; + try { + await wmill.addGranularAcls({ + workspace, + kind, + path, + requestBody: { owner, write }, + }); + log.info( + colors.green( + ` extra_perms: granted ${access} to ${owner} on ${kind}/${path}`, + ), + ); + calls += 1; + } catch (e: any) { + log.error( + colors.red( + ` extra_perms: failed to grant ${access} to ${owner} on ${kind}/${path}: ${formatError(e)}`, + ), + ); + } + } + + for (const owner of toRevoke) { + try { + await wmill.removeGranularAcls({ + workspace, + kind, + path, + requestBody: { owner }, + }); + log.info( + colors.green(` extra_perms: revoked ${owner} on ${kind}/${path}`), + ); + calls += 1; + } catch (e: any) { + log.error( + colors.red( + ` extra_perms: failed to revoke ${owner} on ${kind}/${path}: ${formatError(e)}`, + ), + ); + } + } + + return calls; +} From 90f494975de2b6b558930869c7211426f465b3a4 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 14 May 2026 09:18:42 +0000 Subject: [PATCH 30/40] chore(main): release 1.702.0 (#9160) * chore(main): release 1.702.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 13 ++ backend/Cargo.lock | 164 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 ++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 135 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35a01e5d48..e1055f0035 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.702.0](https://github.com/windmill-labs/windmill/compare/v1.701.0...v1.702.0) (2026-05-14) + + +### Features + +* **git-sync:** sync extra_perms for flows/scripts/apps ([#9162](https://github.com/windmill-labs/windmill/issues/9162)) ([5e909b2](https://github.com/windmill-labs/windmill/commit/5e909b2b4f2819f19deaf06d9e78e6458b324683)) +* include service accounts in instance settings users list ([#9157](https://github.com/windmill-labs/windmill/issues/9157)) ([e5286f4](https://github.com/windmill-labs/windmill/commit/e5286f46074cf2893e6ccd26175f929f16011c8f)) + + +### Bug Fixes + +* **mcp:** sanitize and enrich nested resource schemas ([#9158](https://github.com/windmill-labs/windmill/issues/9158)) ([d870edc](https://github.com/windmill-labs/windmill/commit/d870edc959481a06c894b4eda5e2be1a0269d7d0)) + ## [1.701.0](https://github.com/windmill-labs/windmill/compare/v1.700.2...v1.701.0) (2026-05-13) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d41a33b16d..980c08f1ae 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -8492,18 +8492,18 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", @@ -13788,7 +13788,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-nats", @@ -13869,7 +13869,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.701.0" +version = "1.702.0" dependencies = [ "async-trait", "aws-config", @@ -13899,7 +13899,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.701.0" +version = "1.702.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13912,7 +13912,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "argon2", @@ -14055,7 +14055,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.701.0" +version = "1.702.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14078,7 +14078,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.701.0" +version = "1.702.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14091,7 +14091,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14117,7 +14117,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.701.0" +version = "1.702.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14127,7 +14127,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.701.0" +version = "1.702.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14144,7 +14144,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.701.0" +version = "1.702.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14166,7 +14166,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14189,7 +14189,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.701.0" +version = "1.702.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14205,7 +14205,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.701.0" +version = "1.702.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14226,7 +14226,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.701.0" +version = "1.702.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14247,7 +14247,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.701.0" +version = "1.702.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14261,7 +14261,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-nats", @@ -14293,7 +14293,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14318,7 +14318,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.701.0" +version = "1.702.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14336,7 +14336,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14358,7 +14358,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.701.0" +version = "1.702.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14378,7 +14378,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.701.0" +version = "1.702.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14408,7 +14408,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14436,7 +14436,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.701.0" +version = "1.702.0" dependencies = [ "lazy_static", "serde", @@ -14448,7 +14448,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.701.0" +version = "1.702.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14473,7 +14473,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.701.0" +version = "1.702.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14487,7 +14487,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.701.0" +version = "1.702.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14520,7 +14520,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.701.0" +version = "1.702.0" dependencies = [ "chrono", "lazy_static", @@ -14534,7 +14534,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14553,7 +14553,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.701.0" +version = "1.702.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14654,7 +14654,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.701.0" +version = "1.702.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14673,7 +14673,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.701.0" +version = "1.702.0" dependencies = [ "regex", "serde", @@ -14688,7 +14688,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14712,7 +14712,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "futures", @@ -14729,7 +14729,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.701.0" +version = "1.702.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14745,7 +14745,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-trait", @@ -14766,7 +14766,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-trait", @@ -14797,7 +14797,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "arc-swap", @@ -14822,7 +14822,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-stream", @@ -14856,7 +14856,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "futures", @@ -14874,7 +14874,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.701.0" +version = "1.702.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14883,7 +14883,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "lazy_static", @@ -14895,7 +14895,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "serde_json", @@ -14907,7 +14907,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "gosyn", @@ -14919,7 +14919,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "lazy_static", @@ -14931,7 +14931,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "serde_json", @@ -14943,7 +14943,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "nu-parser", @@ -14954,7 +14954,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14965,7 +14965,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14977,7 +14977,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14988,7 +14988,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-recursion", @@ -15010,7 +15010,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "serde_json", @@ -15022,7 +15022,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "lazy_static", @@ -15036,7 +15036,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15053,7 +15053,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "lazy_static", @@ -15066,7 +15066,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "serde", @@ -15078,7 +15078,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "lazy_static", @@ -15096,7 +15096,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15112,7 +15112,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15128,7 +15128,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "serde", @@ -15139,7 +15139,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-recursion", @@ -15176,7 +15176,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "const_format", @@ -15214,7 +15214,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.701.0" +version = "1.702.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15225,7 +15225,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-recursion", @@ -15255,7 +15255,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-trait", @@ -15279,7 +15279,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-trait", @@ -15312,7 +15312,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-trait", @@ -15345,7 +15345,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-trait", @@ -15365,7 +15365,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-trait", @@ -15399,7 +15399,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-trait", @@ -15435,7 +15435,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-trait", @@ -15458,7 +15458,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-trait", @@ -15482,7 +15482,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-nats", @@ -15506,7 +15506,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-trait", @@ -15541,7 +15541,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-trait", @@ -15569,7 +15569,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-trait", @@ -15592,7 +15592,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -15611,7 +15611,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-once-cell", @@ -15720,7 +15720,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.701.0" +version = "1.702.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index fb289ca224..ac491d0b34 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.701.0" +version = "1.702.0" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.701.0" +version = "1.702.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index df76a818f9..b0d45a7118 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.701.0" +version = "1.702.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.701.0" +version = "1.702.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.701.0" +version = "1.702.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.701.0" +version = "1.702.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 6fbfa2abf7..357affc663 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.701.0" +version = "1.702.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index da42ceb7e4..50560660e8 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.701.0 + version: 1.702.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 4af41e8bbd..81dabf580d 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.701.0"; +export const VERSION = "v1.702.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index 8c272a54f6..14b3080852 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -79,7 +79,7 @@ export { token, }; -export const VERSION = "1.701.0"; +export const VERSION = "1.702.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 11b7fa5754..99aae7ab57 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.701.0", + "version": "1.702.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.701.0", + "version": "1.702.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index c84813bec9..d255e844a3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.701.0", + "version": "1.702.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 4e1edb4776..6f25fead34 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.701.0" +wmill = ">=1.702.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index b37703c634..bc0d834c8b 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.701.0 + version: 1.702.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 4d85c1e4bb..0cbe0fa9ce 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.701.0' + ModuleVersion = '1.702.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index e28c23ee90..fc7a5207b9 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.701.0" +version = "1.702.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 2df684ab26..e02b58b027 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.701.0", + "version": "1.702.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 57b7a2133a..b0801284a9 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.701.0", + "version": "1.702.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 708c818787..53e4682d0a 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.701.0 +1.702.0 From bf99283c3333bcdbc7679f4aea04ba29e41a48a5 Mon Sep 17 00:00:00 2001 From: "windmill-internal-app[bot]" <217088191+windmill-internal-app[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 10:46:43 +0000 Subject: [PATCH 31/40] fix(nativets): pass tracing-enabled OtelConfig to deno_telemetry::init (#9163) * [ee] fix(nativets): pass tracing-enabled OtelConfig to deno_telemetry::init * chore: update ee-repo-ref to 19a76a09ffb43649ee19e62d07e8b8a42d78757b This commit updates the EE repository reference after PR #573 was merged in windmill-ee-private. Previous ee-repo-ref: daf7ce31f192d64ca6b5602af626ab6453d2aff4 New ee-repo-ref: 19a76a09ffb43649ee19e62d07e8b8a42d78757b Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] <1429786+windmill-internal-app[bot]@users.noreply.github.com> Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 92dd5ba787..919919c4c6 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -9bc8160be50b3e57a60daf4e1b71c389a6e02b8a +19a76a09ffb43649ee19e62d07e8b8a42d78757b From 6637e00375b58579d014624fa51db49c43c2025e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 14 May 2026 10:54:19 +0000 Subject: [PATCH 32/40] test(nativets): cover deno_telemetry init contract + http trace export E2E (#9165) --- .../tests/otel_e2e.rs | 206 ++++++++++++++++++ .../tests/otel_init.rs | 88 ++++++++ 2 files changed, 294 insertions(+) create mode 100644 backend/windmill-runtime-nativets/tests/otel_e2e.rs create mode 100644 backend/windmill-runtime-nativets/tests/otel_init.rs diff --git a/backend/windmill-runtime-nativets/tests/otel_e2e.rs b/backend/windmill-runtime-nativets/tests/otel_e2e.rs new file mode 100644 index 0000000000..cadddb2fcf --- /dev/null +++ b/backend/windmill-runtime-nativets/tests/otel_e2e.rs @@ -0,0 +1,206 @@ +//! End-to-end coverage for the EE HTTP-tracing path on nativets. +//! +//! Pairs with `otel_init.rs` (which pins only the `OTEL_GLOBALS` +//! population contract). This test exercises the full chain that +//! actually delivers a span to a collector: +//! +//! `deno_telemetry::init` with EE config (Rust) +//! → `globalThis.__bootstrapOtel()` (JS, flips TRACING_ENABLED) +//! → user `fetch()` → deno_fetch's `builtinTracer().startSpan` +//! → `BatchSpanProcessor` → `HttpExporter` (OTLP/HTTP-binary) +//! → our mock OTLP listener captures the request bytes +//! +//! Without the v1.702.0 fix (#573 EE / #9163 OSS), the third arrow +//! panics in a tokio worker. With the fix in place, the span is +//! emitted and shows up at the listener — which is what the customer +//! is paying for when they enable HTTP tracing on nativets. +//! +//! `#[ignore]`'d: spins a V8 isolate (~seconds) and binds two TCP +//! listeners. Run with `cargo test -p windmill-runtime-nativets +//! --test otel_e2e -- --ignored`. +//! +//! Owns its own test binary so `OTEL_GLOBALS`'s `OnceCell` doesn't +//! race with `otel_init.rs`. + +use std::sync::Arc; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::sync::Mutex; + +use windmill_runtime_nativets::{deno_telemetry, transpile_ts, NativeAnnotation, PrewarmedIsolate}; + +/// Bind 127.0.0.1:0 and spawn an accept loop. Each connection is +/// read until idle/EOF, the body is appended to `captured`, then we +/// respond with HTTP 200. Returns the bound port. +async fn spawn_capturing_http(captured: Arc>>>) -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { + break; + }; + let captured = captured.clone(); + tokio::spawn(async move { + let mut buf = Vec::new(); + let mut tmp = [0u8; 8192]; + let _ = tokio::time::timeout(Duration::from_millis(300), async { + loop { + match sock.read(&mut tmp).await { + Ok(0) | Err(_) => break, + Ok(n) => buf.extend_from_slice(&tmp[..n]), + } + } + }) + .await; + let _ = sock + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + .await; + captured.lock().await.push(buf); + }); + } + }); + port +} + +/// Initialize `deno_telemetry` with the exact `OtelConfig` shape that +/// the EE `load_internal_otel_exporter` ships in production. Keeps +/// this test in lockstep with the actual call site: if production +/// drifts away from `tracing_enabled + Capture`, this test breaks +/// before the customer-facing panic does. +fn init_with_ee_otel_config() { + deno_telemetry::init( + deno_telemetry::OtelRuntimeConfig { + runtime_name: "windmill-nativets".into(), + runtime_version: "0".into(), + }, + deno_telemetry::OtelConfig { + tracing_enabled: true, + console: deno_telemetry::OtelConsoleConfig::Capture, + ..Default::default() + }, + ) + .expect("deno_telemetry init failed"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "spins V8 + tcp listeners; run with --ignored"] +async fn fetch_after_init_otel_emits_span_to_collector() { + let _ = rustls::crypto::ring::default_provider().install_default(); + + // 1. Stand up two listeners: one that pretends to be the user's + // fetch target, one that pretends to be the OTLP collector. + let fetch_hits: Arc>>> = Arc::new(Mutex::new(Vec::new())); + let otlp_hits: Arc>>> = Arc::new(Mutex::new(Vec::new())); + let fetch_port = spawn_capturing_http(fetch_hits.clone()).await; + let otlp_port = spawn_capturing_http(otlp_hits.clone()).await; + + // 2. Point the deno_telemetry exporter at the mock collector and + // initialize. Mirrors `load_internal_otel_exporter` in EE. + // + // SAFETY: test runs in its own process binary; no other thread + // reads OTEL_EXPORTER_OTLP_ENDPOINT before init returns. + unsafe { + std::env::set_var( + "OTEL_EXPORTER_OTLP_ENDPOINT", + format!("http://127.0.0.1:{otlp_port}"), + ); + } + init_with_ee_otel_config(); + assert!( + deno_telemetry::OTEL_GLOBALS.get().is_some(), + "OTEL_GLOBALS missing — load_internal_otel_exporter's config regressed?" + ); + + // 3. Run user TS that bootstraps OTel and then issues a fetch + // at the mock target. `__bootstrapOtel` is fire-and-forget + // (resolves a dynamic import on the microtask queue); the + // `setTimeout` loop yields a few times so the import resolves + // and the `TRACING_ENABLED` flag is set before fetch runs. + let ts = format!( + r#" +declare const globalThis: any; +export async function main(): Promise {{ + globalThis.__bootstrapOtel(); + // Yield multiple microtask + timer turns so the dynamic import + // in __bootstrapOtel resolves and TRACING_ENABLED flips before + // fetch runs (otherwise deno_fetch skips the span entirely). + for (let i = 0; i < 5; i++) {{ + await new Promise(r => setTimeout(r, 10)); + }} + const resp = await fetch("http://127.0.0.1:{fetch_port}/probe"); + return resp.status; +}} +"# + ); + let js = transpile_ts(ts).expect("transpile failed"); + let ann = NativeAnnotation { useragent: None, proxy: None }; + + let mut iso = PrewarmedIsolate::spawn(String::new(), js, ann, vec![], None); + iso.wait_ready().await.expect("isolate failed to pre-warm"); + let res = iso + .start_execution("{}".to_string()) + .wait() + .await + .expect("isolate panicked"); + + // The exact bug: pre-fix, fetch panics this isolate. Post-fix, + // we get back the mock target's 200. + let raw = res.result.expect("user script returned an error"); + assert_eq!(raw.get(), "200", "fetch should return mock target status"); + + assert_eq!( + fetch_hits.lock().await.len(), + 1, + "fetch target should have been hit exactly once" + ); + + // 4. Force the BatchSpanProcessor to flush so the exporter posts + // to our mock collector synchronously (default flush interval + // is ~5s; tests can't wait that long). + deno_telemetry::flush(); + // Exporter is async over the OTel runtime; give it a beat to + // actually send the HTTP request. + tokio::time::sleep(Duration::from_millis(500)).await; + + let otlp_captured = otlp_hits.lock().await; + assert!( + !otlp_captured.is_empty(), + "OTLP collector should have received at least one export — \ + spans aren't reaching the collector after init" + ); + + // Verify the export carries our fetch span. OTLP is protobuf, so + // grep the raw bytes for OTel HTTP semantic-convention markers + // that deno_fetch's auto-instrumentation attaches: + // - the target URL ("url.full" attribute) + // - "http.request.method" attribute + let combined: Vec = otlp_captured.iter().flatten().copied().collect(); + let bytes_contain = |needle: &[u8]| combined.windows(needle.len()).any(|w| w == needle); + + let url_marker = format!("http://127.0.0.1:{fetch_port}/probe"); + let has_url = bytes_contain(url_marker.as_bytes()); + let has_method_attr = bytes_contain(b"http.request.method"); + + if !(has_url && has_method_attr) { + eprintln!( + "OTLP bytes ({}): {:?}", + combined.len(), + String::from_utf8_lossy(&combined) + ); + } + + assert!( + has_url, + "exported OTLP body should reference the fetched URL ({}); got {} bytes", + url_marker, + combined.len() + ); + assert!( + has_method_attr, + "exported OTLP body should carry HTTP semconv attributes (http.request.method); got {} bytes", + combined.len() + ); +} diff --git a/backend/windmill-runtime-nativets/tests/otel_init.rs b/backend/windmill-runtime-nativets/tests/otel_init.rs new file mode 100644 index 0000000000..fdf1ac944d --- /dev/null +++ b/backend/windmill-runtime-nativets/tests/otel_init.rs @@ -0,0 +1,88 @@ +//! Regression test for the deno_telemetry 0.31 nativets-fetch panic. +//! +//! Setup: when EE's `load_internal_otel_exporter` runs (HTTP tracing +//! enabled), it calls `deno_telemetry::init` and then flips +//! `DENO_OTEL_INITIALIZED=true` so `js_eval` will run +//! `globalThis.__bootstrapOtel()` inside the nativets JsRuntime. That +//! bootstrap unconditionally sets JS-side `TRACING_ENABLED=true`, which +//! makes `deno_fetch`'s `fetch()` call `builtinTracer().startSpan(...)` +//! — and `OtelTracer::builtin()` does `OTEL_GLOBALS.get().unwrap()`. +//! +//! In deno_telemetry 0.31, `init` was given an early-return guard: if +//! `tracing_enabled`, `metrics_enabled`, and `console` are all +//! off/Ignore, it returns `Ok(())` *without populating OTEL_GLOBALS*. +//! v1.700.0 was passing `OtelConfig::default()` (all off) — so the JS +//! bootstrap proceeded but the Rust-side OnceCell was empty, and the +//! first `fetch()` panicked the tokio worker in a context that cannot +//! unwind. Fixed in v1.702.0 by passing `tracing_enabled: true` + +//! `console: Capture` from `load_internal_otel_exporter` (PRs #573 EE +//! / #9163 OSS), matching the JS bootstrap shape `[1, 0, 1, 0]`. +//! +//! This test pins that contract directly against `deno_telemetry:: +//! init` — the function the EE call site invokes — so the next time +//! the dep is bumped and someone is tempted to "simplify" the config +//! back to `OtelConfig::default()`, CI catches it. +//! +//! Lives in `tests/` (not `src/`) so it gets its own test binary and +//! its own process — `OTEL_GLOBALS` is a `OnceCell`, so sharing it +//! with the smoke tests in `src/smoke_tests.rs` would race. + +use windmill_runtime_nativets::deno_telemetry; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ee_otel_init_config_populates_otel_globals() { + // deno_telemetry::init builds an HttpExporter (uses rustls) — it + // needs a process-wide CryptoProvider, which the real binary + // installs in setup_deno_runtime / main. Mirror that here. + let _ = rustls::crypto::ring::default_provider().install_default(); + + // Pre-condition: nothing else in this test binary has touched the + // OnceCell, so it must be empty. + assert!( + deno_telemetry::OTEL_GLOBALS.get().is_none(), + "OTEL_GLOBALS must start empty in a fresh test process" + ); + + // Step 1: reproduce the footgun. `OtelConfig::default()` has + // tracing/metrics off and `console = Ignore`, which trips the + // 0.31 early-return — Ok(()) but OnceCell stays empty. + deno_telemetry::init( + deno_telemetry::OtelRuntimeConfig { + runtime_name: "windmill-nativets-test".into(), + runtime_version: "0".into(), + }, + deno_telemetry::OtelConfig::default(), + ) + .expect("init with default config returns Ok (early-return)"); + + assert!( + deno_telemetry::OTEL_GLOBALS.get().is_none(), + "deno_telemetry 0.31 contract: init with all-disabled config \ + must NOT populate OTEL_GLOBALS — if this changes on a future \ + bump, load_internal_otel_exporter can drop the explicit \ + tracing_enabled/console fields." + ); + + // Step 2: re-call init with the exact config shape that the EE + // `load_internal_otel_exporter` ships — this is what production + // hits, so the test exercises the actual prod call path. + deno_telemetry::init( + deno_telemetry::OtelRuntimeConfig { + runtime_name: "windmill-nativets".into(), + runtime_version: "0".into(), + }, + deno_telemetry::OtelConfig { + tracing_enabled: true, + console: deno_telemetry::OtelConsoleConfig::Capture, + ..Default::default() + }, + ) + .expect("init with tracing_enabled config must succeed on a fresh OnceCell"); + + assert!( + deno_telemetry::OTEL_GLOBALS.get().is_some(), + "load_internal_otel_exporter's OtelConfig must populate \ + OTEL_GLOBALS so OtelTracer::builtin() does not panic on \ + .unwrap() once __bootstrapOtel flips JS-side TRACING_ENABLED" + ); +} From 7f589a8c7d6cbeb5f7b3ee39bf5dd6fc4beb44ff Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 14 May 2026 10:54:35 +0000 Subject: [PATCH 33/40] chore(main): release 1.702.1 (#9166) * chore(main): release 1.702.1 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 7 + backend/Cargo.lock | 156 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 125 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1055f0035..c3524f552c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.702.1](https://github.com/windmill-labs/windmill/compare/v1.702.0...v1.702.1) (2026-05-14) + + +### Bug Fixes + +* **nativets:** pass tracing-enabled OtelConfig to deno_telemetry::init ([#9163](https://github.com/windmill-labs/windmill/issues/9163)) ([bf99283](https://github.com/windmill-labs/windmill/commit/bf99283c3333bcdbc7679f4aea04ba29e41a48a5)) + ## [1.702.0](https://github.com/windmill-labs/windmill/compare/v1.701.0...v1.702.0) (2026-05-14) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 980c08f1ae..185cb538c9 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -13788,7 +13788,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-nats", @@ -13869,7 +13869,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.702.0" +version = "1.702.1" dependencies = [ "async-trait", "aws-config", @@ -13899,7 +13899,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.702.0" +version = "1.702.1" dependencies = [ "axum 0.8.9", "chrono", @@ -13912,7 +13912,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "argon2", @@ -14055,7 +14055,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.702.0" +version = "1.702.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14078,7 +14078,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.702.0" +version = "1.702.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14091,7 +14091,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14117,7 +14117,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.702.0" +version = "1.702.1" dependencies = [ "reqwest 0.12.28", "serde", @@ -14127,7 +14127,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.702.0" +version = "1.702.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14144,7 +14144,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.702.0" +version = "1.702.1" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14166,7 +14166,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14189,7 +14189,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.702.0" +version = "1.702.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14205,7 +14205,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.702.0" +version = "1.702.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14226,7 +14226,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.702.0" +version = "1.702.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14247,7 +14247,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.702.0" +version = "1.702.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14261,7 +14261,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-nats", @@ -14293,7 +14293,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14318,7 +14318,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.702.0" +version = "1.702.1" dependencies = [ "axum 0.8.9", "flate2", @@ -14336,7 +14336,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14358,7 +14358,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.702.0" +version = "1.702.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14378,7 +14378,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.702.0" +version = "1.702.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14408,7 +14408,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14436,7 +14436,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.702.0" +version = "1.702.1" dependencies = [ "lazy_static", "serde", @@ -14448,7 +14448,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.702.0" +version = "1.702.1" dependencies = [ "argon2", "axum 0.8.9", @@ -14473,7 +14473,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.702.0" +version = "1.702.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14487,7 +14487,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.702.0" +version = "1.702.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14520,7 +14520,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.702.0" +version = "1.702.1" dependencies = [ "chrono", "lazy_static", @@ -14534,7 +14534,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14553,7 +14553,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.702.0" +version = "1.702.1" dependencies = [ "aes-gcm", "aho-corasick", @@ -14654,7 +14654,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.702.0" +version = "1.702.1" dependencies = [ "chrono", "itertools 0.14.0", @@ -14673,7 +14673,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.702.0" +version = "1.702.1" dependencies = [ "regex", "serde", @@ -14688,7 +14688,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14712,7 +14712,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "futures", @@ -14729,7 +14729,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.702.0" +version = "1.702.1" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14745,7 +14745,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", @@ -14766,7 +14766,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", @@ -14797,7 +14797,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "arc-swap", @@ -14822,7 +14822,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-stream", @@ -14856,7 +14856,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "futures", @@ -14874,7 +14874,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.702.0" +version = "1.702.1" dependencies = [ "convert_case 0.6.0", "serde", @@ -14883,7 +14883,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "lazy_static", @@ -14895,7 +14895,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "serde_json", @@ -14907,7 +14907,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "gosyn", @@ -14919,7 +14919,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "lazy_static", @@ -14931,7 +14931,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "serde_json", @@ -14943,7 +14943,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "nu-parser", @@ -14954,7 +14954,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14965,7 +14965,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14977,7 +14977,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "rustpython-ast", @@ -14988,7 +14988,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-recursion", @@ -15010,7 +15010,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "serde_json", @@ -15022,7 +15022,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "lazy_static", @@ -15036,7 +15036,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15053,7 +15053,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "lazy_static", @@ -15066,7 +15066,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "serde", @@ -15078,7 +15078,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "lazy_static", @@ -15096,7 +15096,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15112,7 +15112,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "rustpython-ast", @@ -15128,7 +15128,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "serde", @@ -15139,7 +15139,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-recursion", @@ -15176,7 +15176,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "const_format", @@ -15214,7 +15214,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.702.0" +version = "1.702.1" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15225,7 +15225,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-recursion", @@ -15255,7 +15255,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", @@ -15279,7 +15279,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", @@ -15312,7 +15312,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", @@ -15345,7 +15345,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", @@ -15365,7 +15365,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", @@ -15399,7 +15399,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", @@ -15435,7 +15435,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", @@ -15458,7 +15458,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", @@ -15482,7 +15482,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-nats", @@ -15506,7 +15506,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", @@ -15541,7 +15541,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", @@ -15569,7 +15569,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", @@ -15592,7 +15592,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -15611,7 +15611,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-once-cell", @@ -15720,7 +15720,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.702.0" +version = "1.702.1" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ac491d0b34..ccc6467e67 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.702.0" +version = "1.702.1" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.702.0" +version = "1.702.1" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index b0d45a7118..b595cfeca1 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.702.0" +version = "1.702.1" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.702.0" +version = "1.702.1" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.702.0" +version = "1.702.1" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.702.0" +version = "1.702.1" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 357affc663..3be5965444 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.702.0" +version = "1.702.1" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 50560660e8..3d18bddf60 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.702.0 + version: 1.702.1 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 81dabf580d..4df4d4ca7d 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.702.0"; +export const VERSION = "v1.702.1"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index 14b3080852..24772ad250 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -79,7 +79,7 @@ export { token, }; -export const VERSION = "1.702.0"; +export const VERSION = "1.702.1"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 99aae7ab57..efb4fd9529 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.702.0", + "version": "1.702.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.702.0", + "version": "1.702.1", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index d255e844a3..96b2bf4953 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.702.0", + "version": "1.702.1", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 6f25fead34..131a52700e 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.702.0" +wmill = ">=1.702.1" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index bc0d834c8b..bd9543130e 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.702.0 + version: 1.702.1 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 0cbe0fa9ce..fcae29637a 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.702.0' + ModuleVersion = '1.702.1' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index fc7a5207b9..42acf4e884 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.702.0" +version = "1.702.1" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index e02b58b027..e297eb78e0 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.702.0", + "version": "1.702.1", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index b0801284a9..adf6d83bf6 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.702.0", + "version": "1.702.1", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 53e4682d0a..c3e939e87b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.702.0 +1.702.1 From f414ffc4849cf4b92fcd5ca9611ecd246e59a7bd Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 14 May 2026 13:29:56 +0000 Subject: [PATCH 34/40] fix: never mark failure/trigger/approval scripts as auto_kind=lib (#9168) --- ...ind_lib_for_runnable_script_kinds.down.sql | 3 + ..._kind_lib_for_runnable_script_kinds.up.sql | 9 ++ backend/tests/script_auto_kind_failure.rs | 122 ++++++++++++++++++ backend/windmill-api-scripts/src/scripts.rs | 16 +++ 4 files changed, 150 insertions(+) create mode 100644 backend/migrations/20260514130526_fix_auto_kind_lib_for_runnable_script_kinds.down.sql create mode 100644 backend/migrations/20260514130526_fix_auto_kind_lib_for_runnable_script_kinds.up.sql create mode 100644 backend/tests/script_auto_kind_failure.rs diff --git a/backend/migrations/20260514130526_fix_auto_kind_lib_for_runnable_script_kinds.down.sql b/backend/migrations/20260514130526_fix_auto_kind_lib_for_runnable_script_kinds.down.sql new file mode 100644 index 0000000000..bb9b57ac0b --- /dev/null +++ b/backend/migrations/20260514130526_fix_auto_kind_lib_for_runnable_script_kinds.down.sql @@ -0,0 +1,3 @@ +-- No-op: clearing a stray `auto_kind = 'lib'` value on failure/trigger/approval +-- scripts is not reversible (the original NULL/'lib' distinction is lost), and +-- restoring `'lib'` here would re-hide these scripts from their pickers. diff --git a/backend/migrations/20260514130526_fix_auto_kind_lib_for_runnable_script_kinds.up.sql b/backend/migrations/20260514130526_fix_auto_kind_lib_for_runnable_script_kinds.up.sql new file mode 100644 index 0000000000..e2038bb78c --- /dev/null +++ b/backend/migrations/20260514130526_fix_auto_kind_lib_for_runnable_script_kinds.up.sql @@ -0,0 +1,9 @@ +-- Failure, Trigger, and Approval scripts are runnable entrypoints by +-- definition. A prior parser regression occasionally classified them as +-- `auto_kind = 'lib'`, which hid them from the flow error-handler / +-- trigger / approval pickers. Clear those stray values so existing +-- affected scripts re-appear without requiring a redeploy. +UPDATE script +SET auto_kind = NULL +WHERE auto_kind = 'lib' + AND kind IN ('failure', 'trigger', 'approval'); diff --git a/backend/tests/script_auto_kind_failure.rs b/backend/tests/script_auto_kind_failure.rs new file mode 100644 index 0000000000..4110bd4ba5 --- /dev/null +++ b/backend/tests/script_auto_kind_failure.rs @@ -0,0 +1,122 @@ +use std::collections::HashMap; + +use sqlx::{Pool, Postgres}; +use windmill_api_client::types::{NewScript, ScriptLang}; +use windmill_test_utils::init_client; + +fn quick_ns(content: &str, path: &str, kind: Option<&str>) -> NewScript { + NewScript { + content: content.into(), + language: ScriptLang::Bun, + lock: None, + parent_hash: None, + path: path.into(), + concurrent_limit: None, + concurrency_time_window_s: None, + cache_ttl: None, + dedicated_worker: None, + description: "".to_string(), + draft_only: None, + envs: vec![], + is_template: None, + kind: kind.map(|s| s.to_string()), + summary: "".to_string(), + tag: None, + schema: HashMap::new(), + ws_error_handler_muted: Some(false), + priority: None, + delete_after_secs: None, + timeout: None, + restart_unless_cancelled: None, + deployment_message: None, + concurrency_key: None, + visible_to_runner_only: None, + auto_kind: None, + codebase: None, + has_preprocessor: None, + on_behalf_of_email: None, + assets: vec![], + modules: None, + } +} + +/// Regression: a `failure`-kind script must never be marked `auto_kind = 'lib'` +/// even if the parser fails to detect a `main` function, because the flow +/// error-handler picker filters out lib scripts and would otherwise hide it. +#[sqlx::test(fixtures("base"))] +async fn failure_kind_script_without_main_is_not_marked_lib( + db: Pool, +) -> anyhow::Result<()> { + let (client, _port, _s) = init_client(db.clone()).await; + + // Content with no `main` — TS parser would normally set auto_kind = 'lib'. + client + .create_script( + "test-workspace", + &quick_ns( + "export function notMain() { return 42 }", + "u/test-user/failure_no_main", + Some("failure"), + ), + ) + .await + .unwrap(); + + let auto_kind: Option = sqlx::query_scalar( + "SELECT auto_kind FROM script \ + WHERE workspace_id = $1 AND path = $2", + ) + .bind("test-workspace") + .bind("u/test-user/failure_no_main") + .fetch_one(&db) + .await?; + + assert_ne!( + auto_kind.as_deref(), + Some("lib"), + "failure-kind script must not be marked as 'lib' auto_kind, got {:?}", + auto_kind + ); + + Ok(()) +} + +/// Sibling: a normal `script` kind WITHOUT main should still be marked `lib` +/// (so it stays hidden from the regular script picker). Guards against an +/// over-broad sanitizer accidentally clearing the value for plain scripts. +#[sqlx::test(fixtures("base"))] +async fn regular_script_without_main_is_still_marked_lib( + db: Pool, +) -> anyhow::Result<()> { + let (client, _port, _s) = init_client(db.clone()).await; + + client + .create_script( + "test-workspace", + &quick_ns( + "export function notMain() { return 42 }", + "u/test-user/script_no_main", + Some("script"), + ), + ) + .await + .unwrap(); + + let auto_kind: Option = sqlx::query_scalar( + "SELECT auto_kind FROM script \ + WHERE workspace_id = $1 AND path = $2", + ) + .bind("test-workspace") + .bind("u/test-user/script_no_main") + .fetch_one(&db) + .await?; + + assert_eq!( + auto_kind.as_deref(), + Some("lib"), + "regular script without main should be marked 'lib', got {:?}", + auto_kind + ); + + Ok(()) +} diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 93c524f10a..2ca655ef32 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -1214,6 +1214,22 @@ async fn create_script_internal<'c>( } }; + // Failure, Trigger, and Approval scripts are runnable entrypoints by + // definition. They must never be marked `auto_kind = 'lib'`, or they + // disappear from the flow error-handler / trigger / approval pickers + // (which filter out lib scripts). Strip a stray `lib` here so a parser + // misclassification — e.g. failing to detect `main` after a deno_ast + // bump — cannot orphan these scripts in the UI. + let auto_kind = if matches!( + ns.kind, + Some(ScriptKind::Failure) | Some(ScriptKind::Trigger) | Some(ScriptKind::Approval) + ) && auto_kind.as_deref() == Some("lib") + { + None + } else { + auto_kind + }; + let ci_test_refs = windmill_common::schema::parse_ci_test_annotation(&ns.content, &lang.as_comment_lit()); let auto_kind = if ci_test_refs.is_some() { From 1c05604e4cf53d394c089e9106620a6839de1217 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 14 May 2026 13:37:50 +0000 Subject: [PATCH 35/40] add .claude worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 10889080ad..5f733611de 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ rust-client/Cargo.toml # Worktree-specific Claude Code settings (generated by scripts/worktree-env) .claude/settings.local.json +.claude/worktrees/ # Symlinked cache directories (for git worktrees) backend/target From d48d61cc79114f0b36736306d4015789be10c1f4 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 14 May 2026 14:03:47 +0000 Subject: [PATCH 36/40] feat(otel-tracing-proxy): configurable tracing MITM NO_PROXY hosts (#9169) * feat(otel-tracing-proxy): configurable NO_PROXY hosts * refactor(otel-tracing-proxy): NO_PROXY only governs job-side bypass * fix(otel-tracing-proxy): restore empty NO_PROXY default * test(otel-tracing-proxy): unit tests for NO_PROXY normalization * fix(otel-tracing-proxy): gate normalize_no_proxy_hosts to EE features --- backend/src/monitor.rs | 6 +- .../windmill-common/src/instance_config.rs | 6 ++ backend/windmill-worker/src/worker.rs | 71 ++++++++++++++++++- .../src/lib/components/InstanceSetting.svelte | 25 +++++++ 4 files changed, 104 insertions(+), 4 deletions(-) diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 0ddc02b256..d5ea145cfc 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -886,11 +886,13 @@ pub async fn reload_otel_tracing_proxy_setting(conn: &Connection) { let mut current = OTEL_TRACING_PROXY_SETTINGS.write().await; if current.enabled != new_settings.enabled || current.enabled_languages != new_settings.enabled_languages + || current.no_proxy_hosts != new_settings.no_proxy_hosts { tracing::info!( - "OTEL tracing proxy settings changed: enabled={}, languages={:?}", + "OTEL tracing proxy settings changed: enabled={}, languages={:?}, no_proxy_hosts={:?}", new_settings.enabled, - new_settings.enabled_languages + new_settings.enabled_languages, + new_settings.no_proxy_hosts, ); *current = new_settings; } diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index e19182be30..3b168761e1 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -616,6 +616,12 @@ pub struct OtelTracingProxySettings { pub enabled: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub enabled_languages: Vec, + /// Comma-separated list of host patterns injected as NO_PROXY into jobs so their HTTP + /// clients bypass the local MITM tracing proxy. Independent of the worker's own + /// NO_PROXY env (which governs the proxy's upstream relay). Use this for clients that + /// pin their own CA (kubectl, helm, terraform providers, aws cli for EKS, etc.). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub no_proxy_hosts: Option, } /// Script language identifier (for instance config use). diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 15405800be..169c4526f9 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -277,6 +277,8 @@ pub struct OtelTracingProxySettings { pub enabled: bool, #[serde(default)] pub enabled_languages: HashSet, + #[serde(default)] + pub no_proxy_hosts: Option, } #[cfg(feature = "prometheus")] @@ -969,14 +971,15 @@ async fn get_otel_tracing_proxy_envs( } }; let proxy_url = format!("http://127.0.0.1:{}", port); + let no_proxy = build_tracing_proxy_no_proxy().await; Ok(vec![ ("HTTP_PROXY", proxy_url.clone()), ("HTTPS_PROXY", proxy_url.clone()), // Lowercase variants for Ruby and other runtimes that check lowercase first ("http_proxy", proxy_url.clone()), ("https_proxy", proxy_url), - ("NO_PROXY", "".to_string()), - ("no_proxy", "".to_string()), + ("NO_PROXY", no_proxy.clone()), + ("no_proxy", no_proxy), // CA cert for various runtimes to trust the tracing proxy ("SSL_CERT_FILE", TRACING_PROXY_CA_CERT_PATH.to_string()), ("REQUESTS_CA_BUNDLE", TRACING_PROXY_CA_CERT_PATH.to_string()), @@ -990,6 +993,70 @@ async fn get_otel_tracing_proxy_envs( ]) } +/// NO_PROXY value injected into jobs so their HTTP clients bypass the local MITM proxy for +/// the configured hosts. This is distinct from the worker's own NO_PROXY env, which governs +/// what the MITM proxy bypasses when relaying upstream (e.g. through a corporate proxy) and +/// is honored automatically by the in-process MITM. The configured hosts are tunneled +/// through the proxy without TLS interception, so clients that pin their own CA (kubectl, +/// helm, terraform, etc.) keep working. Empty when unset, matching the prior behavior of +/// intercepting all destinations including loopback. +#[cfg(all(feature = "private", feature = "enterprise"))] +async fn build_tracing_proxy_no_proxy() -> String { + let configured = OTEL_TRACING_PROXY_SETTINGS + .read() + .await + .no_proxy_hosts + .clone(); + normalize_no_proxy_hosts(configured.as_deref()) +} + +/// Split a comma-separated NO_PROXY value, trim whitespace, drop empty entries, and +/// deduplicate while preserving order. `None` returns an empty string. +#[cfg(all(feature = "private", feature = "enterprise"))] +fn normalize_no_proxy_hosts(configured: Option<&str>) -> String { + let Some(configured) = configured else { + return String::new(); + }; + let mut seen = std::collections::HashSet::new(); + let mut out: Vec<&str> = Vec::new(); + for entry in configured.split(',') { + let trimmed = entry.trim(); + if !trimmed.is_empty() && seen.insert(trimmed) { + out.push(trimmed); + } + } + out.join(",") +} + +#[cfg(all(test, feature = "private", feature = "enterprise"))] +mod no_proxy_tests { + use super::normalize_no_proxy_hosts; + + #[test] + fn unset_returns_empty() { + assert_eq!(normalize_no_proxy_hosts(None), ""); + } + + #[test] + fn empty_and_whitespace_only_returns_empty() { + assert_eq!(normalize_no_proxy_hosts(Some("")), ""); + assert_eq!(normalize_no_proxy_hosts(Some(" , ,\t")), ""); + } + + #[test] + fn trims_and_skips_empties() { + assert_eq!( + normalize_no_proxy_hosts(Some(" *.eks.amazonaws.com ,, *.internal ")), + "*.eks.amazonaws.com,*.internal" + ); + } + + #[test] + fn dedupes_preserving_first_occurrence_order() { + assert_eq!(normalize_no_proxy_hosts(Some("a,b,a,c,b,d")), "a,b,c,d"); + } +} + #[cfg(windows)] lazy_static::lazy_static! { pub static ref SYSTEM_ROOT: String = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".to_string()); diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index 4964fb9c1d..703b833a2f 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -699,6 +699,31 @@ {/each} +
+ + +

+ Comma-separated host patterns that job HTTP clients should bypass the tracing + proxy for — those hosts will not be traced. Use this for clients that pin their + own CA (kubectl, helm, terraform providers, aws cli for EKS, etc.) which would + otherwise fail with x509: certificate signed by unknown authority. + Independent of the worker's own NO_PROXY env, which governs the proxy's + upstream relay (e.g. through a corporate proxy). +

+
{/if} {:else if setting.fieldType == 'object_store_config'} From e1819313e15766007c959497a84fae5f5c78a46b Mon Sep 17 00:00:00 2001 From: "windmill-internal-app[bot]" <217088191+windmill-internal-app[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 12:24:29 +0000 Subject: [PATCH 37/40] fix: aggregate wait time should target the true root job, not flow_innermost_root_job (#9177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: aggregate wait time should target the true root job, not flow_innermost_root_job * refactor: reuse get_root_job_id helper for wait-time aggregation Instead of duplicating the root_job → flow_innermost_root_job → parent_job fallback chain inline, call the existing get_root_job_id() helper (the same one used by push_next_flow_job) and filter out the self-id case so standalone scripts still skip aggregate insertion. Behaviorally identical, single source of truth. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: windmill-internal-app[bot] <1429786+windmill-internal-app[bot]@users.noreply.github.com> Co-authored-by: Ruben Fiszel Co-authored-by: Claude Opus 4.7 (1M context) --- backend/windmill-worker/src/worker.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 169c4526f9..f9305961eb 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -132,7 +132,8 @@ use crate::{ bun_executor::handle_bun_job, common::{ build_args_map, cached_result_path, get_cached_resource_value_if_valid, - get_reserved_variables, update_worker_ping_for_failed_init_script, OccupancyMetrics, + get_reserved_variables, get_root_job_id, update_worker_ping_for_failed_init_script, + OccupancyMetrics, }, csharp_executor::handle_csharp_job, deno_executor::handle_deno_job, @@ -1320,8 +1321,6 @@ async fn insert_wait_time( .await?; if let Some(root_id) = root_job_id { - // TODO: queued_job.root_job is not guaranteed to be the true root job (e.g. parallel flow - // subflows). So this is currently incorrect for those cases sqlx::query!( "INSERT INTO outstanding_wait_time(job_id, aggregate_wait_time_ms) VALUES ($1, $2) ON CONFLICT (job_id) DO UPDATE SET aggregate_wait_time_ms = @@ -1353,7 +1352,10 @@ fn add_outstanding_wait_time( } let job_id = queued_job.id; - let root_job_id = queued_job.flow_innermost_root_job; + // Aggregate onto the true top-level root (root_job → flow_innermost_root_job → parent_job). + // `get_root_job_id` falls back to the job's own id when none are set; filter that out so + // standalone scripts (no parent flow) skip the aggregate insertion. + let root_job_id = Some(get_root_job_id(queued_job)).filter(|&id| id != job_id); let conn = conn.clone(); if let Some(db) = conn.as_sql() { From b7bc9b44b4260c044ad08e551e5203fb811e69b4 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 15 May 2026 12:45:00 +0000 Subject: [PATCH 38/40] chore(frontend): update vite to 8.0.13 (#9179) --- frontend/package-lock.json | 311 ++++++++++++++++--------------------- frontend/package.json | 2 +- 2 files changed, 139 insertions(+), 174 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index efb4fd9529..fd6d47790d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -149,7 +149,7 @@ "tar": "^7.5.4", "tslib": "^2.6.1", "typescript": "^5.5.0", - "vite": "^8.0.0", + "vite": "^8.0.13", "vite-plugin-mkcert": "^2.0.0", "vitest": "^4.1.0", "vitest-browser-svelte": "^2.0.1" @@ -841,22 +841,20 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", - "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", - "dev": true, + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", - "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", - "dev": true, + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, "dependencies": { @@ -864,10 +862,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", - "dev": true, + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "license": "MIT", "optional": true, "dependencies": { @@ -1354,20 +1351,21 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", - "dev": true, + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@noble/hashes": { @@ -1421,20 +1419,10 @@ "node": ">= 8" } }, - "node_modules/@oxc-project/runtime": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", - "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@oxc-project/types": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", - "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", "devOptional": true, "license": "MIT", "funding": { @@ -1508,13 +1496,12 @@ "license": "SEE LICENSE IN LICENSE" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1525,13 +1512,12 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1542,13 +1528,12 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1559,13 +1544,12 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1576,13 +1560,12 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", - "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1593,13 +1576,15 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", "cpu": [ "arm64" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1610,13 +1595,15 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", "cpu": [ "arm64" ], - "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1627,13 +1614,15 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", "cpu": [ "ppc64" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1644,13 +1633,15 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", "cpu": [ "s390x" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1661,13 +1652,15 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", "cpu": [ "x64" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1678,13 +1671,15 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", "cpu": [ "x64" ], - "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1695,13 +1690,12 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1712,30 +1706,30 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", - "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1746,13 +1740,12 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1763,9 +1756,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", - "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "devOptional": true, "license": "MIT" }, @@ -2055,10 +2048,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "license": "MIT", "optional": true, "dependencies": { @@ -6834,7 +6826,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -7333,7 +7325,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7354,7 +7345,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7375,7 +7365,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7396,7 +7385,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7417,7 +7405,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7438,7 +7425,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7459,7 +7445,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7480,7 +7465,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7501,7 +7485,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7522,7 +7505,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7543,7 +7525,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9662,9 +9643,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "devOptional": true, "funding": [ { @@ -11052,14 +11033,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", - "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.115.0", - "@rolldown/pluginutils": "1.0.0-rc.9" + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -11068,21 +11049,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-x64": "1.0.0-rc.9", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" } }, "node_modules/run-parallel": { @@ -12112,21 +12093,6 @@ } } }, - "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", @@ -12648,14 +12614,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "devOptional": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -12683,9 +12649,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "devOptional": true, "license": "MIT", "engines": { @@ -12857,7 +12823,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -13131,18 +13097,17 @@ } }, "node_modules/vite": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", - "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", "devOptional": true, "license": "MIT", "dependencies": { - "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.9", - "tinyglobby": "^0.2.15" + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.1", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -13158,8 +13123,8 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.0.0-alpha.31", - "esbuild": "^0.27.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", @@ -13241,9 +13206,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "devOptional": true, "license": "MIT", "engines": { diff --git a/frontend/package.json b/frontend/package.json index 96b2bf4953..5dbb1d4a3c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -70,7 +70,7 @@ "tar": "^7.5.4", "tslib": "^2.6.1", "typescript": "^5.5.0", - "vite": "^8.0.0", + "vite": "^8.0.13", "vite-plugin-mkcert": "^2.0.0", "vitest": "^4.1.0", "vitest-browser-svelte": "^2.0.1" From 69b3141e0370b95f2e13987503480d341608dbdf Mon Sep 17 00:00:00 2001 From: "windmill-internal-app[bot]" <217088191+windmill-internal-app[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 12:46:37 +0000 Subject: [PATCH 39/40] fix: apply pip_local_dependencies filtering to deployed scripts with populated lockfiles (#9178) * fix: apply pip_local_dependencies filtering to deployed scripts with populated lockfiles * refactor: share pip_local_dependencies filtering helper, log ignored deps * test: split pure filter core out for unit testing, cover #-preservation --------- Co-authored-by: windmill-internal-app[bot] <1429786+windmill-internal-app[bot]@users.noreply.github.com> Co-authored-by: Ruben Fiszel --- .../windmill-worker/src/python_executor.rs | 125 ++++++++++++++---- 1 file changed, 98 insertions(+), 27 deletions(-) diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 1f20148841..d3d3de62d6 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -178,6 +178,44 @@ pub fn handle_ephemeral_token(x: String) -> String { x } +/// Removes lockfile/requirements entries matching the worker's `pip_local_dependencies` +/// regexes. Those packages are already provided locally (e.g. via `additional_python_paths`), +/// so installing them again duplicates files and triggers expensive `postinstall` copies on +/// every job. `#`-prefixed comment lines (e.g. the `# py:` lockfile header) are always kept. +/// Returns `(kept_lines, ignored_lines)`. +fn filter_pip_local_dependencies(lines: Vec) -> (Vec, Vec) { + let Some(pip_local_dependencies) = WORKER_CONFIG.load().pip_local_dependencies.clone() else { + return (lines, vec![]); + }; + + let compiled_deps = pip_local_dependencies + .iter() + .filter_map(|dep| match Regex::new(dep) { + Ok(r) => Some(r), + Err(e) => { + tracing::warn!( + "regex compilation failed for Python local dependency: '{}' - it will be ignored", + e + ); + None + } + }) + .collect::>(); + + filter_lines_by_deps(lines, &compiled_deps) +} + +/// Pure core of [`filter_pip_local_dependencies`]: partitions `lines` into +/// `(kept, ignored)`. A line is ignored when it is not a `#` comment and matches any of +/// `compiled_deps`. Kept separate from config/regex loading so it can be unit-tested. +fn filter_lines_by_deps(lines: Vec, compiled_deps: &[Regex]) -> (Vec, Vec) { + let (ignored, kept): (Vec, Vec) = lines + .into_iter() + .partition(|s| !s.starts_with('#') && compiled_deps.iter().any(|dep| dep.is_match(s))); + + (kept, ignored) +} + // This function only invoked during deployment of script or test run. // And never for already deployed scripts, these have their lockfiles in PostgreSQL // thus this function call is skipped. @@ -200,33 +238,13 @@ pub async fn uv_pip_compile( logs.push_str(&format!("\nresolving dependencies...")); logs.push_str(&format!("\ncontent of requirements:\n{}\n", requirements)); - let requirements = if let Some(pip_local_dependencies) = - WORKER_CONFIG.load().pip_local_dependencies.as_ref() - { - let deps = pip_local_dependencies.clone(); - let compiled_deps = deps.iter().map(|dep| { - let compiled_dep = Regex::new(dep); - match compiled_dep { - Ok(compiled_dep) => Some(compiled_dep), - Err(e) => { - tracing::warn!("regex compilation failed for Python local dependency: '{}' - it will be ignored", e); - return None; - } - } - }).filter(|dep_maybe| dep_maybe.is_some()).map(|dep| dep.unwrap()).collect::>(); - requirements - .lines() - .filter(|s| { - if compiled_deps.iter().any(|dep| dep.is_match(s)) { - logs.push_str(&format!("\nignoring local dependency: {}", s)); - return false; - } else { - return true; - } - }) - .join("\n") - } else { - requirements.to_string() + let requirements = { + let (kept, ignored) = + filter_pip_local_dependencies(requirements.lines().map(str::to_owned).collect()); + for line in ignored { + logs.push_str(&format!("\nignoring local dependency: {}", line)); + } + kept.join("\n") }; let uv_index_strategy = UV_INDEX_STRATEGY.read().await.clone(); @@ -1874,6 +1892,24 @@ Returned from server: py_version - {:?}, py_version_v2 - {:?} } }; + // Filter out packages matched by pip_local_dependencies. For preview runs this is also + // handled inside uv_pip_compile, but deployed scripts skip uv_pip_compile entirely and + // would otherwise pass every lockfile entry to handle_python_reqs — causing duplicate + // installs alongside additional_python_paths and triggering expensive postinstall copies. + let resolved_lines = { + let (kept, ignored) = filter_pip_local_dependencies(resolved_lines); + if !ignored.is_empty() { + append_logs( + job_id, + w_id, + format!("\nignoring local dependencies:\n{}\n", ignored.join("\n")), + conn, + ) + .await; + } + kept + }; + if !resolved_lines.is_empty() { let mut venv_path = handle_python_reqs( resolved_lines, @@ -3228,4 +3264,39 @@ mod tests { let pre = cg.pre_spread.as_ref().unwrap(); assert!(pre.contains("pre_args[\"input\"]")); } + + fn lines(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn test_filter_lines_by_deps_no_deps_keeps_everything() { + let (kept, ignored) = filter_lines_by_deps(lines(&["requests==2.0", "numpy==1.0"]), &[]); + assert_eq!(kept, lines(&["requests==2.0", "numpy==1.0"])); + assert!(ignored.is_empty()); + } + + #[test] + fn test_filter_lines_by_deps_matches_and_partitions() { + let deps = vec![Regex::new("^my-local-pkg").unwrap()]; + let (kept, ignored) = filter_lines_by_deps( + lines(&["requests==2.0", "my-local-pkg==1.2.3", "numpy==1.0"]), + &deps, + ); + assert_eq!(kept, lines(&["requests==2.0", "numpy==1.0"])); + assert_eq!(ignored, lines(&["my-local-pkg==1.2.3"])); + } + + #[test] + fn test_filter_lines_by_deps_preserves_comment_lines() { + // `#` lines (e.g. the `# py: 3.11` lockfile header) must survive even when a + // dependency regex would otherwise match them. + let deps = vec![Regex::new("py").unwrap()]; + let (kept, ignored) = filter_lines_by_deps( + lines(&["# py: 3.11", "pyyaml==6.0", "requests==2.0"]), + &deps, + ); + assert_eq!(kept, lines(&["# py: 3.11", "requests==2.0"])); + assert_eq!(ignored, lines(&["pyyaml==6.0"])); + } } From 8f95402850e7f2c2d918bbbcdc94c8da80cad121 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 15 May 2026 13:14:58 +0000 Subject: [PATCH 40/40] use OPENAI_API_KEY for codex workflow --- .github/workflows/codex-pr-review.yml | 24 ++++++++++++++++++------ .github/workflows/pr-review-commands.yml | 1 + 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml index b4916714f2..66cc4b5f10 100644 --- a/.github/workflows/codex-pr-review.yml +++ b/.github/workflows/codex-pr-review.yml @@ -20,6 +20,8 @@ on: type: string default: '' secrets: + OPENAI_API_KEY: + required: false CODEX_AUTH_JSON: required: false WINDMILL_EE_PRIVATE_ACCESS: @@ -60,13 +62,18 @@ jobs: - name: Check Codex configuration id: codex_config env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} run: | - if [ -n "$CODEX_AUTH_JSON" ]; then + if [ -n "$OPENAI_API_KEY" ]; then echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "auth_mode=api_key" >> "$GITHUB_OUTPUT" + elif [ -n "$CODEX_AUTH_JSON" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "auth_mode=oauth_json" >> "$GITHUB_OUTPUT" else echo "enabled=false" >> "$GITHUB_OUTPUT" - echo "CODEX_AUTH_JSON is not configured; skipping Codex review." + echo "Codex auth is not configured; set OPENAI_API_KEY or CODEX_AUTH_JSON to enable Codex review." fi - name: Resolve PR metadata @@ -169,9 +176,10 @@ jobs: if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' run: npm install --global @openai/codex@0.128.0 - - name: Configure file-backed Codex auth + - name: Configure Codex auth if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} run: | CODEX_HOME="$HOME/.codex" @@ -181,9 +189,13 @@ jobs: cat > "$CODEX_HOME/config.toml" <<'EOF' cli_auth_credentials_store = "file" EOF - printf '%s' "$CODEX_AUTH_JSON" > "$CODEX_HOME/auth.json" - chmod 600 "$CODEX_HOME/auth.json" - node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$CODEX_HOME/auth.json" + if [ -n "$OPENAI_API_KEY" ]; then + printf '%s' "$OPENAI_API_KEY" | codex login --with-api-key + else + printf '%s' "$CODEX_AUTH_JSON" > "$CODEX_HOME/auth.json" + chmod 600 "$CODEX_HOME/auth.json" + node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$CODEX_HOME/auth.json" + fi - name: Pre-fetch base and head refs for the PR if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' diff --git a/.github/workflows/pr-review-commands.yml b/.github/workflows/pr-review-commands.yml index 21d5e1decd..ba55bfea2f 100644 --- a/.github/workflows/pr-review-commands.yml +++ b/.github/workflows/pr-review-commands.yml @@ -100,6 +100,7 @@ jobs: extra_prompt: ${{ needs.parse.outputs.extra_prompt }} triggered_by: ${{ github.event.comment.user.login }} secrets: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
{token_prefix}****{scopes?.join(', ') ?? ''}