diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ca0d1da5b..0e6a13c300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [1.760.0](https://github.com/windmill-labs/windmill/compare/v1.759.0...v1.760.0) (2026-07-15) + + +### Features + +* **nsjail:** make python/ansible rlimit_as configurable per worker (GIT-921) ([#10138](https://github.com/windmill-labs/windmill/issues/10138)) ([1787201](https://github.com/windmill-labs/windmill/commit/17872018cc037e699b1f6e1589d0ad05e0883cca)) + + +### Bug Fixes + +* **ai:** disable redirects on worker AI provider client (GHSA-5q4v) ([#10122](https://github.com/windmill-labs/windmill/issues/10122)) ([27ead8d](https://github.com/windmill-labs/windmill/commit/27ead8d0848cceacaf0c49fed0e8896472b851e3)) +* **ai:** stop sending the AI agent system prompt twice for OpenAI ([#10126](https://github.com/windmill-labs/windmill/issues/10126)) ([8bfe5c9](https://github.com/windmill-labs/windmill/commit/8bfe5c93404ba3f137394f16d0571a06d891dc3b)) +* **apps:** invalidate cached app policy on change or deletion (GHSA-r5v4-cxh9-7qhq) ([#10121](https://github.com/windmill-labs/windmill/issues/10121)) ([f7eb5c4](https://github.com/windmill-labs/windmill/commit/f7eb5c460d78792c24297e20cb062638522a4f68)) +* **bash:** normalize CRLF line endings before running scripts ([#10131](https://github.com/windmill-labs/windmill/issues/10131)) ([6407d9f](https://github.com/windmill-labs/windmill/commit/6407d9ff5ce51e71ff8b8fc503d89a2bdc2e1761)) +* **cli-image:** patch fixable CRITICAL CVEs in windmill-cli image (GIT-922) ([#10135](https://github.com/windmill-labs/windmill/issues/10135)) ([5626768](https://github.com/windmill-labs/windmill/commit/56267684718944ef4d4ecd3b610bad81132e1990)) +* **frontend:** graceful small-screen timeframe picker on the runs page ([#10073](https://github.com/windmill-labs/windmill/issues/10073)) ([af177ce](https://github.com/windmill-labs/windmill/commit/af177cefe07e6037e33cc90757088a98fb63a49f)) +* **frontend:** keep session-exit URL clean by syncing new_draft strip with the router ([#10101](https://github.com/windmill-labs/windmill/issues/10101)) ([9705d60](https://github.com/windmill-labs/windmill/commit/9705d602848966f850613233d6e23b73a753c259)) +* **frontend:** only carry custom-tag overrides on 'Run again' ([#10137](https://github.com/windmill-labs/windmill/issues/10137)) ([bd3adc9](https://github.com/windmill-labs/windmill/commit/bd3adc9781d8e77928c9feb03d0e05b63a1aaf7c)) +* **frontend:** treat a displaced draft save as superseded, not failed ([#10094](https://github.com/windmill-labs/windmill/issues/10094)) ([2fe999f](https://github.com/windmill-labs/windmill/commit/2fe999f66cd15acd81850f970ada31e9892abff2)) +* reject git URL fragment/query SSRF bypass (GHSA-p5cj-8cfh-mjv6) ([#10120](https://github.com/windmill-labs/windmill/issues/10120)) ([73c8d7f](https://github.com/windmill-labs/windmill/commit/73c8d7f08ad55cd2323d1bb9438f00a8ac30e046)) +* **security:** enforce variables:write scope on resource-delete var cascade (GHSA-xmr2-98m6-cjf7) ([#10123](https://github.com/windmill-labs/windmill/issues/10123)) ([188647a](https://github.com/windmill-labs/windmill/commit/188647a942a0cb496d63c71bf3e0f7a3136ec217)) + ## [1.759.0](https://github.com/windmill-labs/windmill/compare/v1.758.0...v1.759.0) (2026-07-15) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 489390e83a..ea2a6eedde 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -5212,9 +5212,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "globset" -version = "0.4.18" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" dependencies = [ "aho-corasick", "bstr", @@ -10978,9 +10978,9 @@ checksum = "8003eb09806ff2ae4661dd0dca27cbd9f65ba85de06cc0302c364b0d661ba368" [[package]] name = "snap" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" +checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" [[package]] name = "socket2" @@ -13870,7 +13870,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-nats", @@ -13952,7 +13952,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.759.0" +version = "1.760.0" dependencies = [ "async-stream", "async-trait", @@ -13985,7 +13985,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.759.0" +version = "1.760.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13998,7 +13998,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "argon2", @@ -14136,7 +14136,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.759.0" +version = "1.760.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14159,7 +14159,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.759.0" +version = "1.760.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14174,7 +14174,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14200,7 +14200,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.759.0" +version = "1.760.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14210,7 +14210,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.759.0" +version = "1.760.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14227,7 +14227,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.759.0" +version = "1.760.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14249,7 +14249,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14272,7 +14272,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.759.0" +version = "1.760.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14288,7 +14288,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.759.0" +version = "1.760.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14309,7 +14309,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.759.0" +version = "1.760.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14330,7 +14330,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.759.0" +version = "1.760.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14344,7 +14344,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-nats", @@ -14379,7 +14379,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14404,7 +14404,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.759.0" +version = "1.760.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14422,7 +14422,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14444,7 +14444,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.759.0" +version = "1.760.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14464,7 +14464,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.759.0" +version = "1.760.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14501,7 +14501,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14529,7 +14529,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.759.0" +version = "1.760.0" dependencies = [ "lazy_static", "serde", @@ -14541,7 +14541,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.759.0" +version = "1.760.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14566,7 +14566,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.759.0" +version = "1.760.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14580,7 +14580,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.759.0" +version = "1.760.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14615,7 +14615,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.759.0" +version = "1.760.0" dependencies = [ "chrono", "lazy_static", @@ -14629,7 +14629,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14648,7 +14648,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.759.0" +version = "1.760.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14750,7 +14750,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.759.0" +version = "1.760.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14769,7 +14769,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.759.0" +version = "1.760.0" dependencies = [ "regex", "serde", @@ -14784,7 +14784,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14808,7 +14808,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "futures", @@ -14825,7 +14825,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.759.0" +version = "1.760.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14841,7 +14841,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-trait", @@ -14862,7 +14862,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-trait", @@ -14893,7 +14893,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "arc-swap", @@ -14918,7 +14918,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-stream", @@ -14952,7 +14952,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "futures", @@ -14970,7 +14970,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.759.0" +version = "1.760.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14979,7 +14979,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "lazy_static", @@ -14991,7 +14991,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "serde_json", @@ -15003,7 +15003,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "gosyn", @@ -15015,7 +15015,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "lazy_static", @@ -15027,7 +15027,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "serde_json", @@ -15039,7 +15039,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "nu-parser", @@ -15050,7 +15050,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15061,7 +15061,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15073,7 +15073,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15084,7 +15084,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-recursion", @@ -15106,7 +15106,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "serde_json", @@ -15118,7 +15118,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "lazy_static", @@ -15132,7 +15132,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15149,7 +15149,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "lazy_static", @@ -15162,7 +15162,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "serde", @@ -15174,7 +15174,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "lazy_static", @@ -15192,7 +15192,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15208,7 +15208,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15224,7 +15224,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "serde", @@ -15235,7 +15235,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-recursion", @@ -15274,7 +15274,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "const_format", @@ -15314,7 +15314,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.759.0" +version = "1.760.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15325,7 +15325,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-recursion", @@ -15359,7 +15359,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-trait", @@ -15383,7 +15383,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-trait", @@ -15416,7 +15416,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-trait", @@ -15449,7 +15449,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-trait", @@ -15469,7 +15469,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-trait", @@ -15503,7 +15503,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-trait", @@ -15539,7 +15539,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-trait", @@ -15562,7 +15562,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-trait", @@ -15586,7 +15586,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-nats", @@ -15610,7 +15610,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-trait", @@ -15645,7 +15645,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-trait", @@ -15673,7 +15673,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-trait", @@ -15698,7 +15698,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -15717,7 +15717,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-once-cell", @@ -15827,7 +15827,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.759.0" +version = "1.760.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index acaef26b20..d367cde7eb 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.759.0" +version = "1.760.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.759.0" +version = "1.760.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 222292b087..46885ae98d 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.759.0" +version = "1.760.0" dependencies = [ "aho-corasick", "anyhow", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.759.0" +version = "1.760.0" dependencies = [ "proc-macro2", "quote", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.759.0" +version = "1.760.0" dependencies = [ "convert_case", "serde", @@ -6293,7 +6293,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "lazy_static", @@ -6305,7 +6305,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "serde_json", @@ -6317,7 +6317,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "gosyn", @@ -6329,7 +6329,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "lazy_static", @@ -6341,7 +6341,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "serde_json", @@ -6353,7 +6353,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "nu-parser", @@ -6364,7 +6364,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6375,7 +6375,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6387,7 +6387,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6398,7 +6398,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "async-recursion", @@ -6420,7 +6420,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "serde_json", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "lazy_static", @@ -6446,7 +6446,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "convert_case", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "serde", @@ -6488,7 +6488,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6538,7 +6538,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6570,7 +6570,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "serde", @@ -6581,7 +6581,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.759.0" +version = "1.760.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 4a2ad50f4c..90cbce6838 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.759.0" +version = "1.760.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-ai/src/providers/anthropic.rs b/backend/windmill-ai/src/providers/anthropic.rs index d71de2f35f..a255f47a6d 100644 --- a/backend/windmill-ai/src/providers/anthropic.rs +++ b/backend/windmill-ai/src/providers/anthropic.rs @@ -6,7 +6,10 @@ use crate::{ query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink}, sse::{AnthropicSSEParser, SSEParser}, types::*, - utils::{extract_text_content, should_use_structured_output_tool, AI_HTTP_HEADERS}, + utils::{ + collect_system_prompt, extract_text_content, should_use_structured_output_tool, + AI_HTTP_HEADERS, + }, }; use async_trait::async_trait; use http::Method; @@ -209,7 +212,7 @@ fn convert_messages_to_anthropic(messages: &[OpenAIMessage]) -> Vec { - // Skip - handled via args.system_prompt in build_text_request + // Lifted into the request's top-level `system` field by build_text_request } "user" => { // Convert user messages @@ -601,19 +604,17 @@ impl AnthropicQueryBuilder { } } - // Build system content from system_prompt, but None if system_prompt is empty string - let system = match args.system_prompt { - Some(s) if !s.is_empty() => Some(vec![AnthropicSystemContent { + let system = collect_system_prompt(&prepared_messages, args.system_prompt).map(|text| { + vec![AnthropicSystemContent { r#type: "text".to_string(), - text: s.to_string(), + text, cache_control: if self.is_vertex() { None } else { Some(CacheControl::ephemeral()) }, - }]), - _ => None, - }; + }] + }); // Check if we need to force tool usage for structured output let has_output_properties = args @@ -842,6 +843,88 @@ mod tests { } } + const SYSTEM_PROMPT: &str = "You are a helpful assistant"; + + fn authed_client() -> AuthedClient { + AuthedClient::new( + "http://localhost:8000".to_string(), + "test-workspace".to_string(), + "token".to_string(), + None, + ) + } + + fn message(role: &str, text: &str) -> OpenAIMessage { + OpenAIMessage { + role: role.to_string(), + content: Some(OpenAIContent::Text(text.to_string())), + ..Default::default() + } + } + + async fn build_text_body(messages: &[OpenAIMessage], system_prompt: Option<&str>) -> String { + let args = BuildRequestArgs { + messages, + tools: None, + model: "claude-sonnet-4", + temperature: None, + reasoning_effort: None, + max_tokens: None, + output_schema: None, + output_type: &OutputType::Text, + system_prompt, + user_message: "hello", + attachments: None, + has_websearch: false, + }; + + AnthropicQueryBuilder::new(AIProvider::Anthropic, AIPlatform::Standard) + .build_request(&args, &authed_client(), "test-workspace") + .await + .unwrap() + } + + /// The worker prepends the system prompt as a system message *and* passes it as + /// `system_prompt`; the request must still carry it exactly once. + #[tokio::test] + async fn sends_system_prompt_only_in_system_field() { + let messages = vec![message("system", SYSTEM_PROMPT), message("user", "hi")]; + + let body = build_text_body(&messages, Some(SYSTEM_PROMPT)).await; + let request: serde_json::Value = serde_json::from_str(&body).unwrap(); + + assert_eq!(request["system"][0]["text"], SYSTEM_PROMPT); + assert_eq!(body.matches(SYSTEM_PROMPT).count(), 1); + + let sent = request["messages"].as_array().unwrap(); + assert!(sent.iter().all(|message| message["role"] != "system")); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0]["role"], "user"); + } + + /// Manual-memory conversations supply their own system messages without a + /// `system_prompt` arg: those must still reach the model. + #[tokio::test] + async fn lifts_manual_system_messages_into_system_field() { + let messages = vec![message("system", "be terse"), message("user", "hi")]; + + let body = build_text_body(&messages, None).await; + let request: serde_json::Value = serde_json::from_str(&body).unwrap(); + + assert_eq!(request["system"][0]["text"], "be terse"); + assert_eq!(request["messages"].as_array().unwrap().len(), 1); + } + + #[tokio::test] + async fn omits_system_without_a_system_prompt() { + let messages = vec![message("user", "hi")]; + + let body = build_text_body(&messages, None).await; + let request: serde_json::Value = serde_json::from_str(&body).unwrap(); + + assert!(request.get("system").is_none()); + } + fn has_header(headers: &[(String, String)], name: &str, value: &str) -> bool { headers .iter() diff --git a/backend/windmill-ai/src/providers/openai.rs b/backend/windmill-ai/src/providers/openai.rs index bdb882679b..8a90d84658 100644 --- a/backend/windmill-ai/src/providers/openai.rs +++ b/backend/windmill-ai/src/providers/openai.rs @@ -6,7 +6,7 @@ use crate::{ query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink}, sse::{OpenAIResponsesSSEParser, SSEParser}, types::*, - utils::extract_text_content, + utils::{collect_system_prompt, extract_text_content}, }; use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -206,7 +206,7 @@ pub struct ResponsesApiRequest<'a> { pub model: &'a str, pub input: Vec, #[serde(skip_serializing_if = "Option::is_none")] - pub instructions: Option<&'a str>, + pub instructions: Option, pub tools: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub stream: Option, @@ -371,9 +371,19 @@ impl OpenAIQueryBuilder { let prepared_messages = prepare_messages_for_api(args.messages, client, workspace_id).await?; + // Only the system prompt leading the conversation moves to `instructions`; echoing it in + // `input` as well would send it twice. This API accepts system messages anywhere in + // `input`, so any later one stays where the caller put it, position and content intact. + let leading_system = prepared_messages + .iter() + .take_while(|message| message.role == "system") + .count(); + let instructions = + collect_system_prompt(&prepared_messages[..leading_system], args.system_prompt); + // Convert full message history to Responses API input format // (following frontend pattern from openai-responses.ts) - let input_items = convert_messages_to_responses_input(&prepared_messages); + let input_items = convert_messages_to_responses_input(&prepared_messages[leading_system..]); // Build tools array using typed structs let mut tools: Vec = Vec::new(); @@ -416,7 +426,7 @@ impl OpenAIQueryBuilder { let request = ResponsesApiRequest { model: args.model, input: input_items, - instructions: args.system_prompt, // System prompt goes to instructions field + instructions, tools, stream: Some(true), temperature: args.temperature, @@ -474,7 +484,7 @@ impl OpenAIQueryBuilder { let request = ResponsesApiRequest { model: args.model, input: vec![ResponsesApiInputItem::InputMessage { role: "user".to_string(), content }], - instructions: args.system_prompt, + instructions: args.system_prompt.map(str::to_string), tools, stream: None, // Image generation doesn't use streaming temperature: args.temperature, @@ -582,3 +592,114 @@ impl QueryBuilder for OpenAIQueryBuilder { vec![("Authorization", format!("Bearer {}", api_key))] } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::query_builder::QueryBuilder; + + const SYSTEM_PROMPT: &str = "You are a helpful assistant"; + + fn client() -> AuthedClient { + AuthedClient::new( + "http://localhost:8000".to_string(), + "test-workspace".to_string(), + "token".to_string(), + None, + ) + } + + fn message(role: &str, text: &str) -> OpenAIMessage { + OpenAIMessage { + role: role.to_string(), + content: Some(OpenAIContent::Text(text.to_string())), + ..Default::default() + } + } + + async fn build_text_body(messages: &[OpenAIMessage], system_prompt: Option<&str>) -> String { + let args = BuildRequestArgs { + messages, + tools: None, + model: "gpt-5", + temperature: None, + reasoning_effort: None, + max_tokens: None, + output_schema: None, + output_type: &OutputType::Text, + system_prompt, + user_message: "hello", + attachments: None, + has_websearch: false, + }; + + OpenAIQueryBuilder::new(AIProvider::OpenAI) + .build_request(&args, &client(), "test-workspace") + .await + .unwrap() + } + + /// The worker prepends the system prompt as a system message *and* passes it as + /// `system_prompt`; the request must still carry it exactly once. + #[tokio::test] + async fn sends_system_prompt_only_in_instructions() { + let messages = vec![message("system", SYSTEM_PROMPT), message("user", "hi")]; + + let body = build_text_body(&messages, Some(SYSTEM_PROMPT)).await; + let request: serde_json::Value = serde_json::from_str(&body).unwrap(); + + assert_eq!(request["instructions"], SYSTEM_PROMPT); + assert_eq!(body.matches(SYSTEM_PROMPT).count(), 1); + + let input = request["input"].as_array().unwrap(); + assert!(input.iter().all(|item| item["role"] != "system")); + assert_eq!(input.len(), 1); + assert_eq!(input[0]["role"], "user"); + } + + /// This API takes system messages anywhere in `input`, so a late steering message keeps + /// its position instead of being hoisted into `instructions`. + #[tokio::test] + async fn keeps_a_mid_conversation_system_message_in_place() { + let messages = vec![ + message("system", SYSTEM_PROMPT), + message("user", "hi"), + message("system", "answer in one word"), + message("user", "and now?"), + ]; + + let body = build_text_body(&messages, Some(SYSTEM_PROMPT)).await; + let request: serde_json::Value = serde_json::from_str(&body).unwrap(); + + assert_eq!(request["instructions"], SYSTEM_PROMPT); + assert_eq!(body.matches(SYSTEM_PROMPT).count(), 1); + + let input = request["input"].as_array().unwrap(); + assert_eq!(input.len(), 3); + assert_eq!(input[1]["role"], "system"); + assert_eq!(input[1]["content"][0]["text"], "answer in one word"); + } + + /// Manual-memory conversations supply their own system messages without a + /// `system_prompt` arg: those must still reach the model. + #[tokio::test] + async fn lifts_manual_system_messages_into_instructions() { + let messages = vec![message("system", "be terse"), message("user", "hi")]; + + let body = build_text_body(&messages, None).await; + let request: serde_json::Value = serde_json::from_str(&body).unwrap(); + + assert_eq!(request["instructions"], "be terse"); + assert_eq!(request["input"].as_array().unwrap().len(), 1); + } + + #[tokio::test] + async fn omits_instructions_without_a_system_prompt() { + let messages = vec![message("user", "hi")]; + + let body = build_text_body(&messages, None).await; + let request: serde_json::Value = serde_json::from_str(&body).unwrap(); + + assert!(request.get("instructions").is_none()); + } +} diff --git a/backend/windmill-ai/src/utils.rs b/backend/windmill-ai/src/utils.rs index fab1644255..66b68e9b6f 100644 --- a/backend/windmill-ai/src/utils.rs +++ b/backend/windmill-ai/src/utils.rs @@ -1,6 +1,6 @@ use crate::{ ai_providers::AIProvider, - ai_types::{ContentPart, OpenAIContent}, + ai_types::{ContentPart, OpenAIContent, OpenAIMessage}, }; use windmill_common::utils::configure_client; @@ -69,6 +69,37 @@ pub fn should_use_structured_output_tool(provider: &AIProvider, model: &str) -> model.contains("claude") || provider == &AIProvider::AWSBedrock } +/// Collect the system prompt for providers that take it in a dedicated top-level field +/// (Anthropic's `system`, OpenAI's `instructions`) instead of inline in the message list. +/// +/// Every system message in `messages` is joined, since manual-memory conversations can carry +/// system messages of their own alongside the one the caller prepends from `system_prompt`. +/// `system_prompt` is a fallback used only when `messages` holds no system message, for callers +/// that pass it without prepending it. Only text content survives, so pass just the messages the +/// provider cannot render inline: Anthropic's API takes no system role at all and hands over +/// everything, while OpenAI's accepts system messages inside `input` and hands over only the +/// leading ones. Whatever is passed here must be left out of the message list the provider +/// sends, or the same prompt goes over the wire twice. +pub fn collect_system_prompt( + messages: &[OpenAIMessage], + system_prompt: Option<&str>, +) -> Option { + let from_messages = messages + .iter() + .filter(|message| message.role == "system") + .filter_map(|message| message.content.as_ref().map(extract_text_content)) + .filter(|text| !text.is_empty()) + .collect::>(); + + if from_messages.is_empty() { + system_prompt + .filter(|prompt| !prompt.is_empty()) + .map(str::to_string) + } else { + Some(from_messages.join("\n\n")) + } +} + /// Extract text content from OpenAIContent, joining parts with space if multiple pub fn extract_text_content(content: &OpenAIContent) -> String { match content { @@ -93,6 +124,81 @@ mod tests { use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; + fn message(role: &str, text: &str) -> OpenAIMessage { + OpenAIMessage { + role: role.to_string(), + content: Some(OpenAIContent::Text(text.to_string())), + ..Default::default() + } + } + + #[test] + fn joins_every_system_message() { + let messages = vec![ + message("system", "be helpful"), + message("user", "hi"), + message("system", "be terse"), + ]; + + assert_eq!( + collect_system_prompt(&messages, Some("be helpful")), + Some("be helpful\n\nbe terse".to_string()) + ); + } + + /// A dedicated system field is text-only, so non-text parts cannot be carried over. + #[test] + fn keeps_only_text_parts_of_a_system_message() { + let messages = vec![OpenAIMessage { + role: "system".to_string(), + content: Some(OpenAIContent::Parts(vec![ + ContentPart::Text { text: "be terse".to_string() }, + ContentPart::ImageUrl { + image_url: crate::ai_types::ImageUrlData { + url: "data:image/png;base64,x".to_string(), + }, + }, + ])), + ..Default::default() + }]; + + assert_eq!( + collect_system_prompt(&messages, None), + Some("be terse".to_string()) + ); + } + + /// The argument is a fallback, not an extra source: system messages win outright. + #[test] + fn prefers_system_messages_over_the_argument() { + let messages = vec![message("system", "be terse"), message("user", "hi")]; + + assert_eq!( + collect_system_prompt(&messages, Some("unused fallback")), + Some("be terse".to_string()) + ); + } + + #[test] + fn falls_back_to_the_system_prompt_argument() { + let messages = vec![message("user", "hi")]; + + assert_eq!( + collect_system_prompt(&messages, Some("be helpful")), + Some("be helpful".to_string()) + ); + } + + #[test] + fn treats_empty_prompts_as_absent() { + assert_eq!( + collect_system_prompt(&[message("user", "hi")], Some("")), + None + ); + assert_eq!(collect_system_prompt(&[message("user", "hi")], None), None); + assert_eq!(collect_system_prompt(&[message("system", "")], None), None); + } + /// Regression test for GHSA-5q4v-c4v3-v7wr: `AI_HTTP_CLIENT` must not follow redirects. #[tokio::test] async fn ai_http_client_does_not_follow_redirects() { diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index aa086cb02c..a33a2bca61 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.759.0 + version: 1.760.0 title: Windmill API contact: diff --git a/backend/windmill-worker/nsjail/run.ansible.config.proto b/backend/windmill-worker/nsjail/run.ansible.config.proto index 2c731e34f9..afaf066f17 100644 --- a/backend/windmill-worker/nsjail/run.ansible.config.proto +++ b/backend/windmill-worker/nsjail/run.ansible.config.proto @@ -5,7 +5,7 @@ hostname: "ansible" log_level: ERROR time_limit: {TIMEOUT} -rlimit_as: 4096 +{RLIMIT_AS} rlimit_cpu: 1000 rlimit_fsize: 1000 rlimit_nofile: 10000 diff --git a/backend/windmill-worker/nsjail/run.python3.config.proto b/backend/windmill-worker/nsjail/run.python3.config.proto index 53d5a6c64d..269ae1f0f8 100644 --- a/backend/windmill-worker/nsjail/run.python3.config.proto +++ b/backend/windmill-worker/nsjail/run.python3.config.proto @@ -5,7 +5,7 @@ hostname: "python" log_level: ERROR time_limit: {TIMEOUT} -rlimit_as: 4096 +{RLIMIT_AS} rlimit_cpu: 1000 rlimit_fsize: 1000 rlimit_nofile: 10000 diff --git a/backend/windmill-worker/src/ansible_executor.rs b/backend/windmill-worker/src/ansible_executor.rs index 292246c62c..92bffe0cf0 100644 --- a/backend/windmill-worker/src/ansible_executor.rs +++ b/backend/windmill-worker/src/ansible_executor.rs @@ -31,13 +31,14 @@ use crate::{ bash_executor::BIN_BASH, common::{ build_command_with_isolation, check_executor_binary_exists, get_reserved_variables, - read_and_check_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, - start_child_process, transform_json, OccupancyMetrics, + read_and_check_result, render_nsjail_rlimit_as, resolve_nsjail_timeout, + resolve_nsjail_tmp_mount_block, start_child_process, transform_json, OccupancyMetrics, }, handle_child::handle_child, is_sandboxing_enabled, python_executor::{create_dependencies_dir, handle_python_reqs, uv_pip_compile}, - DISABLE_NUSER, GIT_PATH, HOME_ENV, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, PY_INSTALL_DIR, TZ_ENV, + DISABLE_NUSER, GIT_PATH, HOME_ENV, NSJAIL_ANSIBLE_RLIMIT_AS_MB, NSJAIL_PATH, PATH_ENV, + PROXY_ENVS, PY_INSTALL_DIR, TZ_ENV, }; use windmill_common::client::AuthedClient; @@ -1659,6 +1660,10 @@ mount {{ job_dir, "run.config.proto", &NSJAIL_CONFIG_RUN_ANSIBLE_CONTENT + .replace( + "{RLIMIT_AS}", + &render_nsjail_rlimit_as(NSJAIL_ANSIBLE_RLIMIT_AS_MB.as_deref(), 4096), + ) .replace("{PY_INSTALL_DIR}", &*PY_INSTALL_DIR) .replace("{JOB_DIR}", job_dir) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index a88ba67bb7..6618ac7906 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -1124,6 +1124,45 @@ pub async fn resolve_nsjail_timeout( (duration.as_secs() + 15).to_string() } +/// Render the `rlimit_as` line for an nsjail run config, honoring a per-language +/// env-var override. +/// +/// nsjail caps a jailed job's virtual address space at `rlimit_as` MiB. JIT +/// runtimes (Bun/JavaScriptCore, the JVM) reserve large virtual ranges up front, +/// so a subprocess spawned from a jailed Python/Ansible job can crash against this +/// cap even when its physical memory use is modest. Lifting it lets operators run +/// such workloads on a dedicated worker pool (set the env var only there) without +/// giving up the mount/PID/user-namespace isolation that provides the real +/// security boundary. Only the address-space limit is affected; the other rlimits +/// (cpu/fsize/nofile) in the proto are untouched. +/// +/// `env_override` is the raw value of the language's `NSJAIL_*_RLIMIT_AS_MB` env var: +/// - unset/empty -> historical default (`rlimit_as: {default_mb}`) +/// - `unlimited`/`none`/`inf`/`0` -> `rlimit_as_type: INF` (address space uncapped) +/// - a positive integer (MiB) -> `rlimit_as: {n}` +pub fn render_nsjail_rlimit_as(env_override: Option<&str>, default_mb: u32) -> String { + match env_override.map(str::trim) { + None | Some("") => format!("rlimit_as: {default_mb}"), + Some(v) + if v.eq_ignore_ascii_case("unlimited") + || v.eq_ignore_ascii_case("none") + || v.eq_ignore_ascii_case("inf") + || v == "0" => + { + "rlimit_as_type: INF".to_string() + } + Some(v) => match v.parse::() { + Ok(mb) => format!("rlimit_as: {mb}"), + Err(_) => { + tracing::warn!( + "Invalid nsjail rlimit_as override {v:?}, using default {default_mb}MiB" + ); + format!("rlimit_as: {default_mb}") + } + }, + } +} + /// Default size (in bytes) of the `/tmp` tmpfs mount inside nsjail sandboxes, /// used when the `nsjail_tmpfs_size_mb` instance setting is unset. pub const DEFAULT_NSJAIL_TMPFS_SIZE_BYTES: u64 = 800_000_000; @@ -1233,6 +1272,49 @@ pub(crate) async fn resolve_nsjail_tmp_mount_block(job_dir: &str) -> String { bind_mount_block(&jail_tmp) } +#[cfg(test)] +mod nsjail_rlimit_as_tests { + use super::render_nsjail_rlimit_as; + + #[test] + fn unset_uses_default() { + assert_eq!(render_nsjail_rlimit_as(None, 4096), "rlimit_as: 4096"); + assert_eq!(render_nsjail_rlimit_as(Some(" "), 4096), "rlimit_as: 4096"); + } + + #[test] + fn numeric_override_is_used() { + assert_eq!( + render_nsjail_rlimit_as(Some("16384"), 4096), + "rlimit_as: 16384" + ); + assert_eq!( + render_nsjail_rlimit_as(Some(" 8192 "), 4096), + "rlimit_as: 8192" + ); + } + + #[test] + fn unlimited_keywords_emit_inf() { + for v in ["unlimited", "UNLIMITED", "none", "inf", "0"] { + assert_eq!( + render_nsjail_rlimit_as(Some(v), 4096), + "rlimit_as_type: INF", + "value {v:?}" + ); + } + } + + #[test] + fn invalid_falls_back_to_default() { + assert_eq!( + render_nsjail_rlimit_as(Some("abc"), 4096), + "rlimit_as: 4096" + ); + assert_eq!(render_nsjail_rlimit_as(Some("-1"), 4096), "rlimit_as: 4096"); + } +} + #[cfg(test)] mod nsjail_tmp_mount_tests { use super::*; diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 9ca79c6852..a3ae036894 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -155,16 +155,17 @@ use windmill_object_store::OBJECT_STORE_SETTINGS; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, read_file, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, - OccupancyMetrics, StreamNotifier, DEV_CONF_NSJAIL, + read_result, render_nsjail_rlimit_as, resolve_nsjail_timeout, + resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, StreamNotifier, + DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child::handle_child, is_sandboxing_enabled, read_ee_registry_with_workspace_override, worker_utils::ping_job_status, - PyV, DISABLE_NUSER, HOME_ENV, NSJAIL_AVAILABLE, NSJAIL_PATH, PATH_ENV, PIP_EXTRA_INDEX_URL, - PIP_INDEX_URL, PROXY_ENVS, PY_INSTALL_DIR, TRACING_PROXY_CA_CERT_PATH, TZ_ENV, UV_CACHE_DIR, - UV_EXCLUDE_NEWER, UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR, + PyV, DISABLE_NUSER, HOME_ENV, NSJAIL_AVAILABLE, NSJAIL_PATH, NSJAIL_PY_RLIMIT_AS_MB, PATH_ENV, + PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, PROXY_ENVS, PY_INSTALL_DIR, TRACING_PROXY_CA_CERT_PATH, + TZ_ENV, UV_CACHE_DIR, UV_EXCLUDE_NEWER, UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR, }; use windmill_common::client::AuthedClient; @@ -1077,6 +1078,10 @@ mount {{ job_dir, "run.config.proto", &NSJAIL_CONFIG_RUN_PYTHON3_CONTENT + .replace( + "{RLIMIT_AS}", + &render_nsjail_rlimit_as(NSJAIL_PY_RLIMIT_AS_MB.as_deref(), 4096), + ) .replace("{JOB_DIR}", job_dir) .replace("{PY_INSTALL_DIR}", &*PY_INSTALL_DIR) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index a4672145bb..69642cd4ea 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -342,6 +342,14 @@ lazy_static::lazy_static! { .and_then(|x| x.parse::().ok()) .unwrap_or(false); + /// Per-language override for the nsjail `rlimit_as` (virtual address space) cap. + /// Value is in MiB, or `unlimited`/`none`/`inf`/`0` to uncap. Unset keeps the + /// historical default baked into the proto. See `render_nsjail_rlimit_as`. + pub static ref NSJAIL_PY_RLIMIT_AS_MB: Option = + std::env::var("NSJAIL_PY_RLIMIT_AS_MB").ok(); + pub static ref NSJAIL_ANSIBLE_RLIMIT_AS_MB: Option = + std::env::var("NSJAIL_ANSIBLE_RLIMIT_AS_MB").ok(); + // pub static ref DISABLE_NSJAIL: bool = false; pub static ref DISABLE_NSJAIL: bool = std::env::var("DISABLE_NSJAIL") .ok() diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 1cc713ae60..81c57ac9d5 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.759.0"; +export const VERSION = "v1.760.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 6cd9394f19..991c0bd5b7 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.759.0"; +export const VERSION = "1.760.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8e4d5c8cbd..3fbc6b8126 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.759.0", + "version": "1.760.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.759.0", + "version": "1.760.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 0015541e04..8445d86221 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.759.0", + "version": "1.760.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/frontend/src/lib/coalescingRunner.svelte.ts b/frontend/src/lib/coalescingRunner.svelte.ts index 0f687d4829..61849c3e36 100644 --- a/frontend/src/lib/coalescingRunner.svelte.ts +++ b/frontend/src/lib/coalescingRunner.svelte.ts @@ -30,6 +30,9 @@ export type CoalescingKeyedRunner = { cancel(key: string): boolean /** Reactively whether `key`'s chain is running (SvelteSet-backed). */ isRunning(key: string): boolean + /** Resolves once `key`'s chain has drained (nothing running, nothing + * pending), immediately if it's idle. Never rejects. */ + settled(key: string): Promise } type PendingTask = { @@ -51,6 +54,8 @@ export function createCoalescingKeyedRunner(): CoalescingKeyedRunner { // Reactive mirror of keys with a running chain, kept in lock-step with // `state` (SvelteSet for per-key `isRunning` subscriptions). const runningKeys = new SvelteSet() + // Live chain promise per key, backing `settled`. + const chains = new Map>() async function chain(key: string, first: PendingTask): Promise { let current: PendingTask | undefined = first @@ -71,6 +76,7 @@ export function createCoalescingKeyedRunner(): CoalescingKeyedRunner { } state.delete(key) runningKeys.delete(key) + chains.delete(key) } /** Set `task` pending for `key`, displacing (and rejecting) any prior @@ -84,7 +90,15 @@ export function createCoalescingKeyedRunner(): CoalescingKeyedRunner { } state.set(key, { pending: undefined }) runningKeys.add(key) - void chain(key, task) + // Register the chain promise BEFORE the first task runs. `chain` invokes + // the task synchronously, so a task that calls `settled(key)` (or that + // throws synchronously, running cleanup) would otherwise race ahead of a + // `chains.set(key, chain(...))` and leave the map wrong. A separate + // deferred sidesteps that: it's live before the task starts and resolves + // when the chain drains. + let done!: () => void + chains.set(key, new Promise((resolve) => (done = resolve))) + void chain(key, task).finally(done) } function submit(key: string, fn: CoalescingTask): void { @@ -114,5 +128,9 @@ export function createCoalescingKeyedRunner(): CoalescingKeyedRunner { return runningKeys.has(key) } - return { submit, submitAndWait, cancel, isRunning } + function settled(key: string): Promise { + return chains.get(key) ?? Promise.resolve() + } + + return { submit, submitAndWait, cancel, isRunning, settled } } diff --git a/frontend/src/lib/coalescingRunner.test.ts b/frontend/src/lib/coalescingRunner.test.ts index 49cca4688e..1ae97e7db1 100644 --- a/frontend/src/lib/coalescingRunner.test.ts +++ b/frontend/src/lib/coalescingRunner.test.ts @@ -115,6 +115,87 @@ describe('createCoalescingKeyedRunner', () => { expect(runner.cancel('k')).toBe(false) }) + it('settled resolves immediately for an idle key', async () => { + const runner = createCoalescingKeyedRunner() + await expect(runner.settled('k')).resolves.toBeUndefined() + }) + + it('settled resolves once the chain drains, including the displacing task', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + const last = deferred() + const h = vi.fn(() => last.promise) + + runner.submit('k', () => d.promise) // in flight + void runner.submitAndWait('k', () => Promise.resolve()).catch(() => {}) // displaced below + runner.submit('k', h) + + let drained = false + void runner.settled('k').then(() => (drained = true)) + + d.resolve() + await d.promise + await Promise.resolve() + await Promise.resolve() + expect(h).toHaveBeenCalledTimes(1) + expect(drained).toBe(false) // h still running + + last.resolve() + await runner.settled('k') + expect(drained).toBe(true) + expect(runner.isRunning('k')).toBe(false) + }) + + it('settled called synchronously from within the first task does not resolve early', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + let settledEarly = false + let settledResolved = false + runner.submit('k', () => { + // Re-entrant: the task is invoked synchronously as the chain starts. + const p = runner.settled('k') + void p.then(() => (settledResolved = true)) + // Give the microtask a tick to (wrongly) resolve if the entry is missing. + void Promise.resolve().then(() => { + if (settledResolved) settledEarly = true + }) + return d.promise + }) + await Promise.resolve() + await Promise.resolve() + expect(settledEarly).toBe(false) + expect(settledResolved).toBe(false) // still running + + d.resolve() + await runner.settled('k') + expect(settledResolved).toBe(true) + }) + + it('a synchronously-throwing first task leaves no stale chain entry', async () => { + const runner = createCoalescingKeyedRunner() + const err = vi.spyOn(console, 'error').mockImplementation(() => {}) + runner.submit('k', () => { + throw new Error('sync boom') + }) + // Chain drained synchronously; the key must be idle and settled a no-op. + expect(runner.isRunning('k')).toBe(false) + await expect(runner.settled('k')).resolves.toBeUndefined() + // A fresh submit still starts a new chain (map wasn't left stale). + const ran = vi.fn(() => Promise.resolve()) + runner.submit('k', ran) + expect(ran).toHaveBeenCalledTimes(1) + err.mockRestore() + }) + + it('settled ignores a task failure (the chain survives it)', async () => { + const runner = createCoalescingKeyedRunner() + const err = vi.spyOn(console, 'error').mockImplementation(() => {}) + runner.submit('k', () => Promise.reject(new Error('boom'))) + await expect(runner.settled('k')).resolves.toBeUndefined() + expect(runner.isRunning('k')).toBe(false) + err.mockRestore() + }) + it('does not abort the in-flight task on cancel', async () => { const runner = createCoalescingKeyedRunner() const d = deferred() diff --git a/frontend/src/lib/components/common/InlineCalendarInput.svelte b/frontend/src/lib/components/common/InlineCalendarInput.svelte index 2ef2e4bd2f..7c20af8e74 100644 --- a/frontend/src/lib/components/common/InlineCalendarInput.svelte +++ b/frontend/src/lib/components/common/InlineCalendarInput.svelte @@ -63,9 +63,18 @@ type Props = (DateProps | RangeProps) & { class?: string + // Portal the month/year dropdowns to the body so they escape an `overflow` + // ancestor (e.g. a scroll-contained popover). Off by default: in-flow. + portalSelects?: boolean } - let { mode = 'date', value = $bindable(), class: className, ...rest }: Props = $props() + let { + mode = 'date', + value = $bindable(), + class: className, + portalSelects = false, + ...rest + }: Props = $props() const onClickBehavior = $derived( mode === 'range' ? ((rest as RangeProps).onClickBehavior ?? 'set-range') : 'set-range' @@ -425,14 +434,14 @@ (viewYear = parseInt(val) || viewYear)} items={YEAR_LIST.map((year) => ({ label: year.toString(), value: year }))} diff --git a/frontend/src/lib/components/runs/TimeframeSelect.svelte b/frontend/src/lib/components/runs/TimeframeSelect.svelte index cc5a99f70c..f8d2b0df71 100644 --- a/frontend/src/lib/components/runs/TimeframeSelect.svelte +++ b/frontend/src/lib/components/runs/TimeframeSelect.svelte @@ -78,6 +78,8 @@ + + +{#snippet presetButtons()} + {#each items as item (item.label)} + + {/each} +{/snippet} +
- {/each} + {#if isSmall} +
+
+ {@render presetButtons()} +
+
+ + {#snippet children({ item })} + + + {/snippet} + + range, + (v) => + onManualInput( + smallBound === 'end' + ? { maxTs: fromCalendarDate(v.end)?.toISOString() ?? null } + : { minTs: fromCalendarDate(v.start)?.toISOString() ?? null } + ) + } + /> +
- range, - (v) => onManualInput({ minTs: fromCalendarDate(v.start)?.toISOString() ?? null }) - } - /> - range, - (v) => onManualInput({ maxTs: fromCalendarDate(v.end)?.toISOString() ?? null }) - } - /> -
+ {:else} +
+
+ {@render presetButtons()} +
+ range, + (v) => onManualInput({ minTs: fromCalendarDate(v.start)?.toISOString() ?? null }) + } + /> + range, + (v) => onManualInput({ maxTs: fromCalendarDate(v.end)?.toISOString() ?? null }) + } + /> +
+ {/if} {/snippet} diff --git a/frontend/src/lib/newDraftFlag.test.ts b/frontend/src/lib/newDraftFlag.test.ts index 95063aa34c..4a2141817e 100644 --- a/frontend/src/lib/newDraftFlag.test.ts +++ b/frontend/src/lib/newDraftFlag.test.ts @@ -17,6 +17,18 @@ vi.mock('./localDraftHints.svelte', () => ({ setLocalDraftHint: vi.fn(), getLocalDraftHint: () => hints.value })) +// `stripNewDraftFlag` rewrites the URL through SvelteKit's `replaceState` and +// refreshes the session-switch's remembered nav route. Mock those so the strip +// is observable via `window.location.href` (mirroring jsdom) and the remembered +// route can be asserted. +const rememberNavRoute = vi.hoisted(() => vi.fn()) +vi.mock('$app/navigation', () => ({ + replaceState: (url: URL | string, _state: unknown) => { + window.location.href = new URL(url, window.location.href).toString() + } +})) +vi.mock('$app/state', () => ({ page: { state: {} } })) +vi.mock('$lib/components/sessions/sessionSwitch.svelte', () => ({ rememberNavRoute })) import { UserDraftDbSyncer } from './userDraftDbSyncer.svelte' import { stripNewDraftFlagOnSave, shouldSeedNewDraft } from './newDraftFlag' @@ -108,6 +120,9 @@ describe('stripNewDraftFlagOnSave', () => { expect(window.location.href).not.toContain('new_draft') // Sibling seeding params are preserved. expect(window.location.href).toContain('template=foo') + // The remembered nav route is refreshed to the stripped URL so exiting an + // AI session returns here without re-adding ?new_draft. + expect(rememberNavRoute).toHaveBeenCalledWith('/scripts/edit/u/me/draft_d?template=foo') }) it('does not strip on a delete save', async () => { diff --git a/frontend/src/lib/newDraftFlag.ts b/frontend/src/lib/newDraftFlag.ts index 20f45cf0e9..88e58f030a 100644 --- a/frontend/src/lib/newDraftFlag.ts +++ b/frontend/src/lib/newDraftFlag.ts @@ -1,16 +1,27 @@ +import { page } from '$app/state' +import { replaceState } from '$app/navigation' +import { rememberNavRoute } from '$lib/components/sessions/sessionSwitch.svelte' import { UserDraftDbSyncer, type UserDraftLastSyncQuery } from '$lib/userDraftDbSyncer.svelte' import { getLocalDraftHint } from '$lib/localDraftHints.svelte' import type { UserDraftItemKind } from '$lib/gen' /** Drop `?new_draft=true` from the current URL (preserving every other param), * mutating the address bar without a navigation. No-op when the flag is absent - * or `window` is unavailable (SSR). */ + * or `window` is unavailable (SSR). + * + * Uses SvelteKit's `replaceState` (not raw `history.replaceState`, which the + * router warns conflicts with it) so the history entry keeps the router's + * bookkeeping and `page.state`. Also refreshes the remembered nav route: + * `afterNavigate` never observes this in-place rewrite, so without it + * `exitSessionMode` would restore the pre-strip URL — still carrying + * `?new_draft=true` — and re-enter the seed-empty branch. */ export function stripNewDraftFlag(): void { if (typeof window === 'undefined') return const url = new URL(window.location.href) if (url.searchParams.get('new_draft') !== 'true') return url.searchParams.delete('new_draft') - window.history.replaceState(window.history.state, '', url.toString()) + replaceState(url, page.state) + rememberNavRoute(url.pathname + url.search) } /** diff --git a/frontend/src/lib/userDraftDbSyncer.svelte.ts b/frontend/src/lib/userDraftDbSyncer.svelte.ts index a75f6b6837..77e2eecc88 100644 --- a/frontend/src/lib/userDraftDbSyncer.svelte.ts +++ b/frontend/src/lib/userDraftDbSyncer.svelte.ts @@ -1,7 +1,7 @@ import { SvelteMap } from 'svelte/reactivity' import { DraftService, type UserDraftItemKind } from './gen' import { OpenAPI } from './gen/core/OpenAPI' -import { createCoalescingKeyedRunner } from './coalescingRunner.svelte' +import { createCoalescingKeyedRunner, CoalescingDisplacedError } from './coalescingRunner.svelte' import { createDebouncerByKey } from './debouncerByKey.svelte' import { setLocalDraftHint } from './localDraftHints.svelte' @@ -92,10 +92,16 @@ export type UserDraftDbSyncerSaveOpts = { value: unknown | null /** Bypass the debouncer: cancel any pending autosave for this key (it * would otherwise overwrite what we send), route through the coalescing - * runner to preserve ordering against an in-flight POST, and resolve - * the returned promise only once the POST lands. Use for + * runner to preserve ordering against an in-flight POST, and resolve only + * once the key's save chain has drained. Use for * `await save(...); read-the-server` flows where a fire-and-forget save - * would race the next read. */ + * would race the next read. + * + * Resolving means "the key is settled", NOT "your payload won": a newer + * save can displace this one (it then carries the later state), and — as + * with every other `save` — `postSave` routes a rejected or failed POST to + * `conflicts` / `failures` rather than throwing. Read those to know what + * actually landed. */ immediate?: boolean /** Skip the optimistic-concurrency check and overwrite the server row. * Used by the conflict-resolution UI ("Overwrite the remote"). Default @@ -441,10 +447,19 @@ export const UserDraftDbSyncer = { pendingSaveOpts.set(key, opts) if (opts.immediate) { // Drop the queued autosave — firing it after our POST would - // re-save the pre-delete value. + // re-save the pre-delete value. `submitAndWait` displaces the + // runner's own pending task, so no `runner.cancel` needed. debouncer.cancel(key) - runner.cancel(key) - await runner.submitAndWait(key, () => postSave(opts)) + try { + await runner.submitAndWait(key, () => postSave(opts)) + } catch (e) { + // Displacement is not a failure: a newer save took our slot, so + // re-POSTing ours would undo it. Wait for the chain instead — + // callers await this to know the key is settled, not to know + // their own payload won. + if (!(e instanceof CoalescingDisplacedError)) throw e + await runner.settled(key) + } return } // Auto-save off: opts stay parked (above) for an explicit flush but @@ -563,8 +578,10 @@ export const UserDraftDbSyncer = { /** * Force-save: bypass the `last_sync` check and overwrite the server row - * (conflict modal's "Overwrite the remote"). Resolves only after the - * POST lands so the caller can `await` before navigating / refetching. + * (conflict modal's "Overwrite the remote"). Resolves once the key's save + * chain drains — see `immediate`; resolution means the chain settled, not + * that this force payload won (a later save can displace it). Callers + * `await` before navigating / refetching. */ async overwrite(opts: Omit): Promise { await this.save({ ...opts, immediate: true, force: true }) @@ -572,8 +589,10 @@ export const UserDraftDbSyncer = { /** * Flush the draft's queued autosave NOW (explicit Ctrl/Cmd+S). Re-submits - * the parked opts with `immediate: true` and resolves only after the POST - * lands, so callers can `await flush(...); show "Saved"`. + * the parked opts with `immediate: true` and resolves once the key's save + * chain drains (see `immediate` — the parked payload may be displaced by a + * later save carrying newer state), so callers can `await flush(...); show + * "Saved"`. * * No-op when nothing is pending. "No pending" does NOT mean "nothing to * save" — Monaco may hold unmaterialized text; flush the editor diff --git a/frontend/src/lib/userDraftDisplacedSave.test.ts b/frontend/src/lib/userDraftDisplacedSave.test.ts new file mode 100644 index 0000000000..50e24ba5c6 --- /dev/null +++ b/frontend/src/lib/userDraftDisplacedSave.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' + +// Mocked so a test can hold a POST in flight — that window is what makes a +// queued save displaceable. +const updateDraft = vi.fn(async (..._args: any[]) => ({ + status: 'saved' as const, + current_timestamp: '2020-01-01T00:00:00Z' +})) + +vi.mock('./gen', () => ({ + DraftService: { updateDraft: (...a: unknown[]) => updateDraft(...(a as [])) } +})) +vi.mock('./gen/core/OpenAPI', () => ({ OpenAPI: { BASE: '' } })) +vi.mock('./localDraftHints.svelte', () => ({ setLocalDraftHint: vi.fn() })) + +import { UserDraftDbSyncer } from './userDraftDbSyncer.svelte' + +function deferred() { + let resolve!: (v: T) => void + const promise = new Promise((res) => (resolve = res)) + return { promise, resolve } +} + +afterEach(() => { + vi.clearAllMocks() + updateDraft.mockResolvedValue({ status: 'saved', current_timestamp: '2020-01-01T00:00:00Z' }) +}) + +/** + * Deploying queues several saves for one draft key back-to-back (mirror write, + * post-deploy delete, unmount flush), so the runner displaces one of them. A + * displaced save must read as "superseded", never as a failure. + */ +describe('UserDraftDbSyncer immediate save displacement', () => { + it('resolves a displaced immediate save once the superseding save lands', async () => { + const q = { workspace: 'w', itemKind: 'script' as const, path: 'u/me/displaced_a' } + const inFlight = deferred() + updateDraft.mockImplementationOnce(async () => { + await inFlight.promise + return { status: 'saved', current_timestamp: '2020-01-01T00:00:00Z' } + }) + + const first = UserDraftDbSyncer.save({ ...q, value: { content: '1' }, immediate: true }) + // Queues behind `first`, then gets displaced by the delete below. + const displaced = UserDraftDbSyncer.save({ ...q, value: { content: '2' }, immediate: true }) + const deleting = UserDraftDbSyncer.save({ ...q, value: null, immediate: true }) + + inFlight.resolve() + await expect(displaced).resolves.toBeUndefined() + await Promise.all([first, deleting]) + + // The displaced task never POSTed — the delete carries the later state. + expect(updateDraft).toHaveBeenCalledTimes(2) + expect(updateDraft.mock.calls.map((c: any[]) => c[0].requestBody.value)).toEqual([ + { content: '1' }, + null + ]) + }) + + it('does not resolve a displaced save before the superseding POST lands', async () => { + const q = { workspace: 'w', itemKind: 'script' as const, path: 'u/me/displaced_b' } + const inFlight = deferred() + const deletePost = deferred() + updateDraft + .mockImplementationOnce(async () => { + await inFlight.promise + return { status: 'saved', current_timestamp: '2020-01-01T00:00:00Z' } + }) + .mockImplementationOnce(async () => { + await deletePost.promise + return { status: 'saved', current_timestamp: '2020-01-01T00:00:01Z' } + }) + + const first = UserDraftDbSyncer.save({ ...q, value: { content: '1' }, immediate: true }) + const displaced = UserDraftDbSyncer.save({ ...q, value: { content: '2' }, immediate: true }) + const deleting = UserDraftDbSyncer.save({ ...q, value: null, immediate: true }) + + let displacedSettled = false + void displaced.then(() => (displacedSettled = true)) + + inFlight.resolve() + await vi.waitFor(() => expect(updateDraft).toHaveBeenCalledTimes(2)) + // Delete still in flight: callers that `await save()` before invalidating + // must not read the server yet. + expect(displacedSettled).toBe(false) + + deletePost.resolve() + await Promise.all([first, displaced, deleting]) + expect(displacedSettled).toBe(true) + }) + + it('resolves a pending save dropped by lockSync without POSTing it', async () => { + const q = { workspace: 'w', itemKind: 'script' as const, path: 'u/me/displaced_lock' } + const inFlight = deferred() + updateDraft.mockImplementationOnce(async () => { + await inFlight.promise + return { status: 'saved', current_timestamp: '2020-01-01T00:00:00Z' } + }) + + const first = UserDraftDbSyncer.save({ ...q, value: { content: '1' }, immediate: true }) + const dropped = UserDraftDbSyncer.save({ ...q, value: { content: '2' }, immediate: true }) + // Another user's draft was loaded: this value must never reach the server. + UserDraftDbSyncer.lockSync(q) + + inFlight.resolve() + // Resolves like every other save on a locked key — the lock's whole point + // is that the write is dropped, so the caller has nothing to wait for. + await expect(dropped).resolves.toBeUndefined() + await first + expect(updateDraft).toHaveBeenCalledTimes(1) + expect(updateDraft.mock.calls[0][0].requestBody.value).toEqual({ content: '1' }) + UserDraftDbSyncer.unlockSync(q) + }) + + it('resolves a flush displaced by a later immediate save', async () => { + const q = { workspace: 'w', itemKind: 'script' as const, path: 'u/me/displaced_c' } + const inFlight = deferred() + updateDraft.mockImplementationOnce(async () => { + await inFlight.promise + return { status: 'saved', current_timestamp: '2020-01-01T00:00:00Z' } + }) + + // Park opts (the reactive mirror's autosave) so `flush` has something to send. + void UserDraftDbSyncer.save({ ...q, value: { content: 'typed' }, auto: true }) + const first = UserDraftDbSyncer.save({ ...q, value: { content: 'x' }, immediate: true }) + const flushed = UserDraftDbSyncer.flush(q) // pending behind `first` + const deleting = UserDraftDbSyncer.save({ ...q, value: null, immediate: true }) // displaces it + + inFlight.resolve() + await expect(flushed).resolves.toBeUndefined() + await Promise.all([first, deleting]) + }) +}) diff --git a/lsp/Pipfile b/lsp/Pipfile index 62e6aea3c0..6ddaff60b0 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.759.0" +wmill = ">=1.760.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 1b5d938026..9973653550 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.759.0 + version: 1.760.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index ba13ab2a42..d137711c36 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.759.0' + ModuleVersion = '1.760.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 7a6a4ccf02..1ba4a31515 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.759.0" +version = "1.760.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 6839da6bba..f454d94cc6 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.759.0", + "version": "1.760.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 aa2e068397..a90b8241ac 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.759.0", + "version": "1.760.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index 67fbbd4999..920e44d956 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.759.0 +1.760.0