mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-26 00:01:37 +00:00
Merge branch 'main' into free-token-limit
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
Generated
+82
-82
@@ -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
@@ -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
@@ -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>"]
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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())
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
Generated
+2
-2
@@ -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,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",
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -4,7 +4,7 @@ verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
wmill = ">=1.759.0"
|
||||
wmill = ">=1.760.0"
|
||||
sendgrid = "*"
|
||||
mysql-connector-python = "*"
|
||||
pymongo = "*"
|
||||
|
||||
@@ -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,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,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,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
@@ -1 +1 @@
|
||||
1.759.0
|
||||
1.760.0
|
||||
|
||||
Reference in New Issue
Block a user