Merge branch 'main' into free-token-limit

This commit is contained in:
Diego Imbert
2026-07-15 20:32:57 +02:00
committed by GitHub
34 changed files with 971 additions and 200 deletions
+22
View File
@@ -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)
+82 -82
View File
@@ -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",
+2 -2
View File
@@ -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 <ruben@windmill.dev>"]
edition = "2021"
+24 -24
View File
@@ -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",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.759.0"
version = "1.760.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+92 -9
View File
@@ -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<AnthropicMes
for msg in messages {
match msg.role.as_str() {
"system" => {
// 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()
+126 -5
View File
@@ -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<ResponsesApiInputItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<&'a str>,
pub instructions: Option<String>,
pub tools: Vec<ResponsesApiTool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
@@ -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<ResponsesApiTool> = 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());
}
}
+107 -1
View File
@@ -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<String> {
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::<Vec<_>>();
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() {
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.759.0
version: 1.760.0
title: Windmill API
contact:
@@ -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
@@ -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
@@ -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())
+82
View File
@@ -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::<u32>() {
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::*;
+10 -5
View File
@@ -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())
+8
View File
@@ -342,6 +342,14 @@ lazy_static::lazy_static! {
.and_then(|x| x.parse::<bool>().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<String> =
std::env::var("NSJAIL_PY_RLIMIT_AS_MB").ok();
pub static ref NSJAIL_ANSIBLE_RLIMIT_AS_MB: Option<String> =
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()
+1 -1
View File
@@ -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<string> {
return await windmill.UserService.login({
+1 -1
View File
@@ -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";
+2 -2
View File
@@ -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": {
+1 -1
View File
@@ -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",
+20 -2
View File
@@ -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<void>
}
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<string>()
// Live chain promise per key, backing `settled`.
const chains = new Map<string, Promise<void>>()
async function chain(key: string, first: PendingTask): Promise<void> {
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<void>((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<void> {
return chains.get(key) ?? Promise.resolve()
}
return { submit, submitAndWait, cancel, isRunning, settled }
}
+81
View File
@@ -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()
@@ -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 @@
<Select
class="basis-1/2"
inputClass="text-center !rounded-r-none !border-r-0"
disablePortal
disablePortal={!portalSelects}
bind:value={viewMonth}
items={MONTH_NAMES.map((name, i) => ({ label: name, value: i + 1 }))}
/>
<Select
class="basis-1/2"
inputClass="text-center !rounded-l-none !border-l-0"
disablePortal
disablePortal={!portalSelects}
bind:value={viewYear}
onCreateItem={(val) => (viewYear = parseInt(val) || viewYear)}
items={YEAR_LIST.map((year) => ({ label: year.toString(), value: year }))}
@@ -78,6 +78,8 @@
<script lang="ts">
import { CalendarIcon, RefreshCw } from 'lucide-svelte'
import { Button } from '../common'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import Popover from '../meltComponents/Popover.svelte'
import { watch } from 'runed'
import { page } from '$app/state'
@@ -98,6 +100,14 @@
let isOpen = $state(false)
// The two-calendar desktop popover needs ~780px (two min-w-9 grids + presets +
// popover padding); below that it overflows. Under 800px use a single calendar
// with a Start/End toggle, which keeps each bound's date + time inputs.
const TWO_CALENDAR_MIN_WIDTH = 800
let innerWidth = $state<number | undefined>(undefined)
const isSmall = $derived(innerWidth != undefined && innerWidth < TWO_CALENDAR_MIN_WIDTH)
let smallBound = $state<'start' | 'end'>('start')
function onManualInput(input: { minTs?: string | null; maxTs?: string | null }) {
if (value.type !== 'manual')
value = buildManualTimeframe(input.minTs ?? null, input.maxTs ?? null)
@@ -113,6 +123,22 @@
}
</script>
<svelte:window bind:innerWidth />
{#snippet presetButtons()}
{#each items as item (item.label)}
<Button
onClick={() => (value = { ...item })}
variant="subtle"
unifiedSize="md"
selected={value.label === item.label}
btnClasses="justify-start text-nowrap"
>
{item.label}
</Button>
{/each}
{/snippet}
<div class="relative flex {wrapperClasses}">
<Button
unifiedSize="md"
@@ -134,7 +160,12 @@
Reset
</Button>
{/if}
<Popover enableFlyTransition bind:isOpen>
<Popover
enableFlyTransition
placement="bottom-end"
contentClasses={isSmall ? 'overflow-y-auto' : ''}
bind:isOpen
>
{#snippet trigger()}
<Button
unifiedSize="md"
@@ -152,41 +183,63 @@
value.type === 'manual' && value.minTs ? new Date(value.minTs) : undefined
)
}}
<div class="flex divide-x">
<div class="flex flex-col p-2">
{#each items as item}
<Button
onClick={() => (value = { ...item })}
variant="subtle"
unifiedSize="md"
selected={value.label === item.label}
btnClasses="justify-start text-nowrap"
>
{item.label}
</Button>
{/each}
{#if isSmall}
<div class="flex flex-col divide-y max-w-[calc(100vw-2rem)]">
<div class="flex flex-row flex-wrap gap-1 p-2">
{@render presetButtons()}
</div>
<div class="flex flex-col gap-2 p-2">
<ToggleButtonGroup bind:selected={smallBound} class="w-full">
{#snippet children({ item })}
<ToggleButton value="start" label="Start" {item} />
<ToggleButton value="end" label="End" {item} />
{/snippet}
</ToggleButtonGroup>
<InlineCalendarInput
class="w-full"
infiniteRange
portalSelects
mode="range"
onClickBehavior={smallBound === 'end' ? 'set-end' : 'set-start'}
bind:value={
() => range,
(v) =>
onManualInput(
smallBound === 'end'
? { maxTs: fromCalendarDate(v.end)?.toISOString() ?? null }
: { minTs: fromCalendarDate(v.start)?.toISOString() ?? null }
)
}
/>
</div>
</div>
<InlineCalendarInput
class="p-4 max-w-[18rem]"
infiniteRange
mode="range"
onClickBehavior="set-start"
bind:value={
() => range,
(v) => onManualInput({ minTs: fromCalendarDate(v.start)?.toISOString() ?? null })
}
/>
<InlineCalendarInput
class="p-4 max-w-[18rem]"
infiniteRange
mode="range"
onClickBehavior="set-end"
bind:value={
() => range,
(v) => onManualInput({ maxTs: fromCalendarDate(v.end)?.toISOString() ?? null })
}
/>
</div>
{:else}
<div class="flex divide-x">
<div class="flex flex-col p-2">
{@render presetButtons()}
</div>
<InlineCalendarInput
class="p-4 max-w-[18rem]"
infiniteRange
mode="range"
onClickBehavior="set-start"
bind:value={
() => range,
(v) => onManualInput({ minTs: fromCalendarDate(v.start)?.toISOString() ?? null })
}
/>
<InlineCalendarInput
class="p-4 max-w-[18rem]"
infiniteRange
mode="range"
onClickBehavior="set-end"
bind:value={
() => range,
(v) => onManualInput({ maxTs: fromCalendarDate(v.end)?.toISOString() ?? null })
}
/>
</div>
{/if}
{/snippet}
</Popover>
</div>
+15
View File
@@ -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 () => {
+13 -2
View File
@@ -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)
}
/**
+30 -11
View File
@@ -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<UserDraftDbSyncerSaveOpts, 'force'>): Promise<void> {
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
@@ -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<T = void>() {
let resolve!: (v: T) => void
const promise = new Promise<T>((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])
})
})
+1 -1
View File
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.759.0"
wmill = ">=1.760.0"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.759.0
version: 1.760.0
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.759.0'
ModuleVersion = '1.760.0'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -1 +1 @@
1.759.0
1.760.0