Merge branch 'main' into ruben/win-1974-apparently-the-last-update-from-git-sync-28231-to-28320

# Conflicts:
#	backend/windmill-common/src/workspaces.rs
This commit is contained in:
Ruben Fiszel
2026-05-22 07:48:09 +00:00
86 changed files with 2944 additions and 564 deletions
+19
View File
@@ -1,5 +1,24 @@
# Changelog
## [1.706.0](https://github.com/windmill-labs/windmill/compare/v1.705.0...v1.706.0) (2026-05-21)
### Features
* add userdraft listing primitives ([#9268](https://github.com/windmill-labs/windmill/issues/9268)) ([d0ee697](https://github.com/windmill-labs/windmill/commit/d0ee697e8b8de58085ea0b2ecde1af2b2441428d))
* add UV_PYTHON_INSTALL_MIRROR env and instance setting ([#9271](https://github.com/windmill-labs/windmill/issues/9271)) ([1169371](https://github.com/windmill-labs/windmill/commit/1169371d4885bdc18c76d03c6caae71f0e440235))
* add yolo mode for ai chat tools ([#9258](https://github.com/windmill-labs/windmill/issues/9258)) ([ac26aa4](https://github.com/windmill-labs/windmill/commit/ac26aa4e4c7cc2d493f136b59738c0708803cc6d))
* CLI datatable serve / psql ([#9267](https://github.com/windmill-labs/windmill/issues/9267)) ([28c8b5c](https://github.com/windmill-labs/windmill/commit/28c8b5c60fd46f961ae11b363b9be834fad6ee68))
* **cli:** add `wmill init prompts` and custom override slot ([#9266](https://github.com/windmill-labs/windmill/issues/9266)) ([1ba8ed8](https://github.com/windmill-labs/windmill/commit/1ba8ed8abd827313ce0f7728d9f84357417206ee))
* **nsjail:** optional disk-backed /tmp via instance setting ([#9272](https://github.com/windmill-labs/windmill/issues/9272)) ([b656dc6](https://github.com/windmill-labs/windmill/commit/b656dc6cdc8c50ef9740240447f119cceed18547))
### Bug Fixes
* **ai:** enforce RLS and scope check on user-supplied X-Resource-Path ([#9276](https://github.com/windmill-labs/windmill/issues/9276)) ([0692b97](https://github.com/windmill-labs/windmill/commit/0692b97c8a3818549d7050ea3e057e9cbf1ddb44))
* **debugger:** add non-root user support to Dockerfile ([#9277](https://github.com/windmill-labs/windmill/issues/9277)) ([0bdb6a9](https://github.com/windmill-labs/windmill/commit/0bdb6a9d5d5fb28a27af1b6eda9fde7172308faf))
* **indexer:** tell admins when ingress routes search to wrong pod ([#9274](https://github.com/windmill-labs/windmill/issues/9274)) ([d29a561](https://github.com/windmill-labs/windmill/commit/d29a5612fcd17eb4197468289e955a1209127cc1))
## [1.705.0](https://github.com/windmill-labs/windmill/compare/v1.704.1...v1.705.0) (2026-05-20)
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT last_locked_at FROM concurrency_locks WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "last_locked_at",
"type_info": "Timestamp"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "9b88e522ecbe9fa67ef83e79ec5eb5c9c87999a877fcb7f23be75d991bba6e49"
}
+80 -80
View File
@@ -4315,9 +4315,9 @@ dependencies = [
[[package]]
name = "either"
version = "1.15.0"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
dependencies = [
"serde",
]
@@ -13788,7 +13788,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-nats",
@@ -13869,7 +13869,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"async-stream",
"async-trait",
@@ -13901,7 +13901,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -13914,7 +13914,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"argon2",
@@ -14057,7 +14057,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14080,7 +14080,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14093,7 +14093,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14119,7 +14119,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -14129,7 +14129,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14146,7 +14146,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -14168,7 +14168,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14191,7 +14191,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14207,7 +14207,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14228,7 +14228,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14249,7 +14249,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14263,7 +14263,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-nats",
@@ -14295,7 +14295,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14320,7 +14320,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"axum 0.8.9",
"flate2",
@@ -14338,7 +14338,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14360,7 +14360,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14380,7 +14380,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14410,7 +14410,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14438,7 +14438,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"lazy_static",
"serde",
@@ -14450,7 +14450,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -14475,7 +14475,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14489,7 +14489,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14522,7 +14522,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"chrono",
"lazy_static",
@@ -14536,7 +14536,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14555,7 +14555,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -14656,7 +14656,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -14675,7 +14675,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"regex",
"serde",
@@ -14690,7 +14690,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -14714,7 +14714,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"futures",
@@ -14731,7 +14731,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -14747,7 +14747,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-trait",
@@ -14768,7 +14768,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-trait",
@@ -14799,7 +14799,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -14824,7 +14824,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-stream",
@@ -14858,7 +14858,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"futures",
@@ -14876,7 +14876,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -14885,7 +14885,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14897,7 +14897,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14909,7 +14909,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"gosyn",
@@ -14921,7 +14921,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14933,7 +14933,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14945,7 +14945,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -14956,7 +14956,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14967,7 +14967,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14979,7 +14979,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -14990,7 +14990,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15012,7 +15012,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15024,7 +15024,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15038,7 +15038,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -15055,7 +15055,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15068,7 +15068,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"serde",
@@ -15080,7 +15080,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15098,7 +15098,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -15114,7 +15114,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -15130,7 +15130,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"serde",
@@ -15141,7 +15141,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15178,7 +15178,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"const_format",
@@ -15216,7 +15216,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -15227,7 +15227,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15257,7 +15257,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15281,7 +15281,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15314,7 +15314,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15347,7 +15347,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15367,7 +15367,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15401,7 +15401,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15437,7 +15437,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15460,7 +15460,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15484,7 +15484,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15508,7 +15508,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15543,7 +15543,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15571,7 +15571,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15594,7 +15594,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"bitflags 2.11.1",
@@ -15613,7 +15613,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -15723,7 +15723,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"bytes",
"futures",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.705.0"
version = "1.706.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.705.0"
version = "1.706.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
daffe7bb81cfcaca666c61de1ee838a44d60ebc2
eb18d7b4c0e37fea3f6e1e2cc44e0fddd74ff817
+24 -24
View File
@@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6263,7 +6263,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6275,7 +6275,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"convert_case",
"serde",
@@ -6284,7 +6284,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6296,7 +6296,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6308,7 +6308,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6320,7 +6320,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6332,7 +6332,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6344,7 +6344,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6378,7 +6378,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6411,7 +6411,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6423,7 +6423,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6437,7 +6437,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6454,7 +6454,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6467,7 +6467,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"serde",
@@ -6479,7 +6479,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6497,7 +6497,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6513,7 +6513,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6529,7 +6529,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6561,7 +6561,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"serde",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.705.0"
version = "1.706.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.705.0"
version = "1.706.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+14 -12
View File
@@ -51,14 +51,15 @@ use windmill_common::{
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING,
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING,
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING,
NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NUGET_CONFIG_SETTING,
OAUTH_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING,
POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING,
REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING,
RESTART_COORDINATION_SETTING, RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING,
SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING,
TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING,
UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_REGISTRIES_SETTING,
NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING,
NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING,
PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING,
PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING,
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RESTART_COORDINATION_SETTING,
RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, SAML_METADATA_SETTING,
SCIM_TOKEN_SETTING, SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING,
TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING,
UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_REGISTRIES_SETTING,
},
scripts::ScriptLang,
stats_oss::schedule_stats,
@@ -127,10 +128,10 @@ use crate::monitor::{
reload_http_route_workspaced_route_setting, reload_hub_api_secret_setting,
reload_hub_base_url_setting, reload_instance_events_webhook_setting,
reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting,
reload_license_key, reload_npm_config_registry_setting, reload_nsjail_tmpfs_size_setting,
reload_otel_tracing_proxy_setting, reload_pip_index_url_setting,
reload_retention_period_setting, reload_scim_token_setting, reload_smtp_config,
reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting,
reload_license_key, reload_npm_config_registry_setting, reload_nsjail_tmp_backing_setting,
reload_nsjail_tmpfs_size_setting, reload_otel_tracing_proxy_setting,
reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting,
reload_smtp_config, reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting,
reload_uv_index_strategy_setting, reload_uv_python_install_mirror_setting,
reload_worker_config, MonitorIteration,
};
@@ -1785,6 +1786,7 @@ async fn process_notify_event(
JOB_DEFAULT_TIMEOUT_SECS_SETTING => reload_job_default_timeout_setting(conn).await,
JOB_ISOLATION_SETTING => reload_job_isolation_setting(conn).await,
NSJAIL_TMPFS_SIZE_MB_SETTING => reload_nsjail_tmpfs_size_setting(conn).await,
NSJAIL_TMP_BACKING_SETTING => reload_nsjail_tmp_backing_setting(conn).await,
#[cfg(feature = "parquet")]
OBJECT_STORE_CONFIG_SETTING => {
if !disable_s3_store {
+21 -10
View File
@@ -62,13 +62,13 @@ use windmill_common::{
HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING,
JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING,
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING,
NSJAIL_TMPFS_SIZE_MB_SETTING, NUGET_CONFIG_SETTING, OTEL_SETTING,
OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING,
POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING,
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING,
SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, STORE_AUDIT_LOGS_S3_SETTING,
TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING,
UV_PYTHON_INSTALL_MIRROR_SETTING,
NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, NUGET_CONFIG_SETTING,
OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING,
POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING,
REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING,
RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING,
STORE_AUDIT_LOGS_S3_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING,
UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING,
},
indexer::load_indexer_config,
jwt::JWT_SECRET,
@@ -108,9 +108,9 @@ use windmill_worker::{
BUN_INSTALL_MIN_RELEASE_AGE, CARGO_REGISTRIES, INSTANCE_PYTHON_VERSION, JAVA_HOME_DIR,
JOB_DEFAULT_TIMEOUT, JOB_ISOLATION, KEEP_JOB_DIR, MAVEN_REPOS, MAVEN_SETTINGS_XML,
NO_DEFAULT_MAVEN, NPMRC, NPM_CONFIG_REGISTRY, NSJAIL_AVAILABLE, NSJAIL_TMPFS_SIZE_MB,
NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL,
POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, UNSHARE_PATH, UV_EXCLUDE_NEWER, UV_INDEX_STRATEGY,
UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES,
NSJAIL_TMP_BACKING, NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL,
PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, UNSHARE_PATH, UV_EXCLUDE_NEWER,
UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES,
};
#[cfg(feature = "parquet")]
@@ -387,6 +387,7 @@ pub async fn initial_load(
reload_job_default_timeout_setting(&conn).await;
reload_job_isolation_setting(&conn).await;
reload_nsjail_tmpfs_size_setting(&conn).await;
reload_nsjail_tmp_backing_setting(&conn).await;
reload_extra_pip_index_url_setting(&conn).await;
reload_pip_index_url_setting(&conn).await;
reload_uv_index_strategy_setting(&conn).await;
@@ -1909,6 +1910,16 @@ pub async fn reload_nsjail_tmpfs_size_setting(conn: &Connection) {
.await;
}
pub async fn reload_nsjail_tmp_backing_setting(conn: &Connection) {
reload_option_setting_with_tracing(
conn,
NSJAIL_TMP_BACKING_SETTING,
"NSJAIL_TMP_BACKING",
NSJAIL_TMP_BACKING.clone(),
)
.await;
}
pub async fn reload_job_isolation_setting(conn: &Connection) {
let value =
match load_value_from_global_settings_with_conn(conn, JOB_ISOLATION_SETTING, true).await {
@@ -10,6 +10,10 @@ fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn authed_with(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {token}"))
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
@@ -106,3 +110,125 @@ async fn test_ai_proxy_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
Ok(())
}
/// Regression test for WIN-1971: the AI proxy's X-Resource-Path header must
/// honour resource RLS so that a low-privilege user cannot point the proxy
/// at a resource they are not allowed to read.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_ai_proxy_x_resource_path_enforces_rls(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
std::env::set_var("ALLOW_PRIVATE_AI_BASE_URLS", "true");
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let mock_port = start_mock_ai_api().await;
let mock_url = format!("http://127.0.0.1:{mock_port}/v1");
// Resource owned by test-user (admin). With default extra_perms {} the
// RLS `see_own` policy restricts SELECT to user `test-user`.
sqlx::query(
"INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) \
VALUES ('test-workspace', 'u/test-user/restricted_openai', $1::jsonb, 'openai', '{}', 'test-user')",
)
.bind(json!({
"api_key": "sk-secret-restricted",
"base_url": mock_url,
}))
.execute(&db)
.await?;
// Sanity-check: normal resource API rejects test-user-3 (non-admin) for the restricted path.
let resp = authed_with(
client().get(format!(
"http://localhost:{port}/api/w/test-workspace/resources/get/u/test-user/restricted_openai"
)),
"SECRET_TOKEN_3",
)
.send()
.await?;
assert!(
resp.status().as_u16() >= 400,
"normal resource API should deny test-user-3 reading restricted resource, got {}",
resp.status()
);
// The vulnerability: as a non-admin user, point X-Resource-Path at the
// restricted resource. Must be rejected before the proxy fetches/uses it.
let resp = authed_with(
client()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/ai/proxy/chat/completions"
))
.header("X-Provider", "openai")
.header("X-Resource-Path", "u/test-user/restricted_openai")
.json(&json!({
"model": "gpt-4",
"messages": [{"role": "user", "content": "hi"}]
})),
"SECRET_TOKEN_3",
)
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert!(
status >= 400,
"non-admin user should be rejected when X-Resource-Path points at a resource they cannot read, got {status}: {body}",
);
// A resource the non-admin owns must still work through X-Resource-Path.
sqlx::query(
"INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) \
VALUES ('test-workspace', 'u/test-user-3/own_openai', $1::jsonb, 'openai', '{}', 'test-user-3')",
)
.bind(json!({
"api_key": "sk-self",
"base_url": mock_url,
}))
.execute(&db)
.await?;
let resp = authed_with(
client()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/ai/proxy/chat/completions"
))
.header("X-Provider", "openai")
.header("X-Resource-Path", "u/test-user-3/own_openai")
.json(&json!({
"model": "gpt-4",
"messages": [{"role": "user", "content": "hi"}]
})),
"SECRET_TOKEN_3",
)
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"non-admin with X-Resource-Path on owned resource",
);
// Admin must still be able to use X-Resource-Path on any resource.
let resp = authed(
client()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/ai/proxy/chat/completions"
))
.header("X-Provider", "openai")
.header("X-Resource-Path", "u/test-user/restricted_openai")
.json(&json!({
"model": "gpt-4",
"messages": [{"role": "user", "content": "hi"}]
})),
)
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"admin with X-Resource-Path on restricted resource",
);
Ok(())
}
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.705.0
version: 1.706.0
title: Windmill API
contact:
+38 -7
View File
@@ -1,6 +1,7 @@
#[cfg(feature = "bedrock")]
use crate::bedrock;
use crate::db::{ApiAuthed, DB};
use crate::utils::check_scopes;
#[cfg(feature = "bedrock")]
use axum::routing::get;
@@ -669,6 +670,7 @@ async fn global_proxy(
async fn proxy(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, mut ai_path)): Path<(String, String)>,
method: Method,
headers: HeaderMap,
@@ -689,6 +691,16 @@ async fn proxy(
.get("X-Resource-Path")
.map(|v| v.to_str().unwrap_or("").to_string());
let is_user_specified_resource = forced_resource_path.is_some();
// When the caller supplies X-Resource-Path, the resource is treated as if it
// were being read through the normal resource API: scope and RLS checks must
// apply so that a low-privilege user cannot point the proxy at a restricted
// AI resource (e.g. one in a folder they cannot read) to exfiltrate the
// resource's provider credentials or use them via the proxy.
if let Some(resource_path) = forced_resource_path.as_ref() {
check_scopes(&authed, || format!("resources:read:{}", resource_path))?;
}
let request_config = match workspace_cache {
Some(request_cache) if !request_cache.is_expired() && forced_resource_path.is_none() => {
request_cache.config
@@ -759,13 +771,32 @@ async fn proxy(
)
};
let resource = sqlx::query_scalar::<_, Option<sqlx::types::Json<Box<RawValue>>>>(
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
)
.bind(&resource_path)
.bind(&resource_workspace)
.fetch_optional(&db)
.await?
// For user-specified resources, fetch through an RLS-scoped
// connection so PostgreSQL row-level security enforces the same
// folder/group boundaries as the regular resource API. For the
// workspace/instance ai_config path, the resource_path was already
// validated by an admin/devops user when configuring the workspace,
// so the raw pool is used.
let resource = if is_user_specified_resource {
let mut tx = user_db.clone().begin(&authed).await?;
let res = sqlx::query_scalar::<_, Option<sqlx::types::Json<Box<RawValue>>>>(
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
)
.bind(&resource_path)
.bind(&resource_workspace)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
res
} else {
sqlx::query_scalar::<_, Option<sqlx::types::Json<Box<RawValue>>>>(
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
)
.bind(&resource_path)
.bind(&resource_workspace)
.fetch_optional(&db)
.await?
}
.ok_or_else(|| Error::NotFound(format!("Could not find the resource {}, update the resource path in the workspace settings", resource_path)))?
.ok_or_else(|| Error::BadRequest(format!("Empty resource value for {}", resource_path)))?;
@@ -55,6 +55,9 @@ pub const KEEP_JOB_DIR_SETTING: &str = "keep_job_dir";
pub const REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING: &str = "require_preexisting_user_for_oauth";
pub const JOB_ISOLATION_SETTING: &str = "job_isolation";
pub const NSJAIL_TMPFS_SIZE_MB_SETTING: &str = "nsjail_tmpfs_size_mb";
pub const NSJAIL_TMP_BACKING_SETTING: &str = "nsjail_tmp_backing";
pub const NSJAIL_TMP_BACKING_DISK: &str = "disk";
pub const NSJAIL_TMP_BACKING_TMPFS: &str = "tmpfs";
pub const OBJECT_STORE_CONFIG_SETTING: &str = "object_store_cache_config";
pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret";
@@ -223,6 +223,8 @@ pub struct GlobalSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub nsjail_tmpfs_size_mb: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nsjail_tmp_backing: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bun_install_min_release_age: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uv_exclude_newer: Option<i64>,
@@ -86,12 +86,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
@@ -86,12 +86,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
src: "{TARGET}"
@@ -49,12 +49,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
src: "/etc"
@@ -66,12 +66,7 @@ mount {
is_bind: false
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
src: "{JOB_DIR}/main.yml"
@@ -68,12 +68,7 @@ mount {
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
src: "{JOB_DIR}/main.sh"
@@ -60,12 +60,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
src: "{JOB_DIR}/package.json"
@@ -57,12 +57,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
@@ -50,12 +50,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
@@ -51,12 +51,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
@@ -51,12 +51,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
src: "{NU_PATH}"
@@ -51,12 +51,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
src: "{JOB_DIR}/main.php"
@@ -64,12 +64,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
src: "{JOB_DIR}/main.ps1"
@@ -54,12 +54,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
src: "{JOB_DIR}/{MAIN}.py"
@@ -51,12 +51,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
@@ -51,12 +51,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
@@ -50,12 +50,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
@@ -30,7 +30,7 @@ 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_tmpfs_size_bytes,
read_and_check_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block,
start_child_process, transform_json, OccupancyMetrics,
},
handle_child::handle_child,
@@ -1457,8 +1457,8 @@ mount {{
additional_python_paths_folders.as_str(),
)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
+3 -3
View File
@@ -41,7 +41,7 @@ use crate::handle_child::run_future_with_polling_update_job_poller;
use crate::{
common::{
build_args_map, build_command_with_isolation, get_reserved_variables, read_file,
read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process,
read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process,
OccupancyMetrics, DEV_CONF_NSJAIL,
},
get_proxy_envs_for_lang,
@@ -216,8 +216,8 @@ exit $exit_status
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
+3 -3
View File
@@ -16,7 +16,7 @@ use crate::{
common::{
build_command_with_isolation, create_args_and_out_file, get_reserved_variables,
parse_npm_config, read_file, read_file_content, read_result, resolve_nsjail_timeout,
resolve_nsjail_tmpfs_size_bytes, start_child_process, write_file_binary, MaybeLock,
resolve_nsjail_tmp_mount_block, start_child_process, write_file_binary, MaybeLock,
OccupancyMetrics, StreamNotifier, DEV_CONF_NSJAIL,
},
get_proxy_envs_for_lang,
@@ -2186,8 +2186,8 @@ try {{
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
+276 -1
View File
@@ -15,6 +15,7 @@ use tokio::process::Command;
use tokio::{fs::File, io::AsyncReadExt};
use windmill_common::flows::Step;
use windmill_common::global_settings::NSJAIL_TMP_BACKING_DISK;
use windmill_common::variables::{build_crypt_with_key_suffix, decrypt};
use windmill_common::worker::{
to_raw_value, update_ping_for_failed_init_script_query, write_file, Connection, Ping, PingType,
@@ -48,7 +49,8 @@ use tokio::{io::AsyncWriteExt, time::Instant};
use crate::agent_workers::UPDATE_PING_URL;
use crate::{
JOB_DEFAULT_TIMEOUT, MAX_RESULT_SIZE, MAX_TIMEOUT_DURATION, NSJAIL_TMPFS_SIZE_MB, PATH_ENV,
JOB_DEFAULT_TIMEOUT, MAX_RESULT_SIZE, MAX_TIMEOUT_DURATION, NSJAIL_TMPFS_SIZE_MB,
NSJAIL_TMP_BACKING, PATH_ENV,
};
use windmill_common::client::AuthedClient;
@@ -1023,6 +1025,279 @@ pub async fn resolve_nsjail_tmpfs_size_bytes() -> String {
}
}
/// Sub-directory inside each job dir used as the disk-backed `/tmp` when
/// `nsjail_tmp_disk_backed` is enabled. Kept under `{JOB_DIR}` so existing
/// job-dir cleanup removes it for free.
const NSJAIL_TMP_BIND_SUBDIR: &str = "jail_tmp";
fn tmpfs_mount_block(size_bytes: &str) -> String {
format!(
"mount {{\n dst: \"/tmp\"\n fstype: \"tmpfs\"\n rw: true\n options: \"size={size_bytes}\"\n}}"
)
}
fn bind_mount_block(jail_tmp: &str) -> String {
format!(
"mount {{\n src: \"{jail_tmp}\"\n dst: \"/tmp\"\n is_bind: true\n rw: true\n}}"
)
}
/// Build the nsjail `mount { ... }` block that backs `/tmp` inside the
/// sandbox.
///
/// **Caller contract**: `job_dir` must be a trusted, worker-allocated job
/// directory (typically `{worker_dir}/{job_id}`). In disk-backed mode this
/// function creates `{job_dir}/jail_tmp` and bind-mounts it as `/tmp` with
/// `rw: true`. Callers must not pass user-controlled paths.
///
/// Some executors (e.g. the bun codebase path) extract user-supplied archives
/// into `job_dir` before this resolver runs, so the resolver actively refuses
/// any pre-existing entry at `{job_dir}/jail_tmp` (including symlinks) to
/// avoid bind-mounting an attacker-controlled host directory as `/tmp`.
///
/// When the `nsjail_tmp_backing` instance setting is `"disk"`, returns a
/// disk-backed bind mount of `{job_dir}/jail_tmp` after creating the
/// directory. If creation or the pre-existence check fails, logs an error and
/// falls back to the historical tmpfs block so the job can still start. For
/// any other value (including unset, `"tmpfs"`, or unrecognized), returns the
/// historical RAM-backed tmpfs mount sized via `nsjail_tmpfs_size_mb`.
pub(crate) async fn resolve_nsjail_tmp_mount_block(job_dir: &str) -> String {
let disk_backed = NSJAIL_TMP_BACKING
.read()
.await
.as_deref()
.map(|v| v.eq_ignore_ascii_case(NSJAIL_TMP_BACKING_DISK))
.unwrap_or(false);
let size_bytes = resolve_nsjail_tmpfs_size_bytes().await;
if !disk_backed {
return tmpfs_mount_block(&size_bytes);
}
let jail_tmp = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}");
// SECURITY: never bind-mount a symlinked (or otherwise non-directory)
// entry at jail_tmp. `symlink_metadata` returns the link's own metadata
// without following it, so `is_dir()` is true only for a real directory.
// User-controlled archives extracted into job_dir could otherwise plant
// `jail_tmp` as a symlink to an arbitrary host directory, which nsjail
// would then expose as a writable /tmp.
//
// A pre-existing real directory at this path is legitimate: several
// executors (python_executor, ruby_executor, rust_executor) invoke nsjail
// more than once per job_dir (e.g. dep install, then run), and the first
// invocation will have created it via the `create_dir` below.
match tokio::fs::symlink_metadata(&jail_tmp).await {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if let Err(e) = tokio::fs::create_dir(&jail_tmp).await {
tracing::error!(
"Failed to create nsjail disk-backed /tmp at {jail_tmp}: {e:?}; \
falling back to tmpfs for this job."
);
return tmpfs_mount_block(&size_bytes);
}
}
Ok(meta) if meta.is_dir() => {
// Real directory left over from an earlier nsjail invocation in
// this same job_dir — safe to reuse.
}
Ok(_) => {
tracing::error!(
"Refusing to bind-mount nsjail disk-backed /tmp: {jail_tmp} \
exists but is not a regular directory (possibly a symlink \
planted by a user-controlled archive). Falling back to \
RAM-backed tmpfs for this job."
);
return tmpfs_mount_block(&size_bytes);
}
Err(e) => {
tracing::error!(
"Failed to stat nsjail disk-backed /tmp at {jail_tmp}: {e:?}; \
falling back to tmpfs for this job."
);
return tmpfs_mount_block(&size_bytes);
}
}
bind_mount_block(&jail_tmp)
}
#[cfg(test)]
mod nsjail_tmp_mount_tests {
use super::*;
#[test]
fn tmpfs_block_renders_size() {
let block = tmpfs_mount_block("800000000");
assert!(block.contains("dst: \"/tmp\""));
assert!(block.contains("fstype: \"tmpfs\""));
assert!(block.contains("options: \"size=800000000\""));
assert!(!block.contains("is_bind"));
}
#[test]
fn bind_block_renders_source_path() {
let block = bind_mount_block("/var/lib/windmill/jobs/abc/jail_tmp");
assert!(block.contains("src: \"/var/lib/windmill/jobs/abc/jail_tmp\""));
assert!(block.contains("dst: \"/tmp\""));
assert!(block.contains("is_bind: true"));
assert!(block.contains("rw: true"));
assert!(!block.contains("fstype"));
}
/// Serializes tests that mutate the process-global `NSJAIL_TMP_BACKING`
/// so they don't race when cargo runs them in parallel.
static SETTING_GUARD: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
async fn with_tmp_backing<F, Fut, T>(value: Option<String>, f: F) -> T
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = T>,
{
let _serial = SETTING_GUARD.lock().await;
let prev = NSJAIL_TMP_BACKING.read().await.clone();
*NSJAIL_TMP_BACKING.write().await = value;
let res = f().await;
*NSJAIL_TMP_BACKING.write().await = prev;
res
}
#[tokio::test]
async fn tmpfs_mode_returns_tmpfs_block_for_any_job_dir() {
let block = with_tmp_backing(Some("tmpfs".to_string()), || async {
resolve_nsjail_tmp_mount_block("/anything").await
})
.await;
assert!(block.contains("fstype: \"tmpfs\""));
assert!(block.contains("options: \"size="));
assert!(!block.contains("is_bind"));
}
#[tokio::test]
async fn unset_defaults_to_tmpfs() {
let block = with_tmp_backing(None, || async {
resolve_nsjail_tmp_mount_block("/anything").await
})
.await;
assert!(block.contains("fstype: \"tmpfs\""));
assert!(!block.contains("is_bind"));
}
/// Disk-backed branch: the resolver must create `{job_dir}/jail_tmp` and
/// emit a bind block pointing at it.
#[tokio::test]
async fn disk_backed_creates_jail_tmp_and_returns_bind_block() {
let tmp = tempfile::tempdir().expect("tempdir");
let job_dir = tmp.path().to_str().expect("utf8 path").to_string();
let block = with_tmp_backing(Some("disk".to_string()), || async {
resolve_nsjail_tmp_mount_block(&job_dir).await
})
.await;
let expected_dir = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}");
assert!(
std::path::Path::new(&expected_dir).is_dir(),
"jail_tmp dir should have been created at {expected_dir}"
);
assert!(block.contains("is_bind: true"));
assert!(block.contains(&format!("src: \"{expected_dir}\"")));
}
/// Disk-backed branch fallback: if `create_dir_all` fails, we must emit
/// the tmpfs block instead of returning an invalid bind config.
#[tokio::test]
async fn disk_backed_falls_back_to_tmpfs_on_mkdir_error() {
// /proc is a kernel filesystem that disallows directory creation,
// so create_dir_all on a subpath returns EPERM/EACCES.
let job_dir = "/proc/win1967_should_not_exist";
let block = with_tmp_backing(Some("disk".to_string()), || async {
resolve_nsjail_tmp_mount_block(job_dir).await
})
.await;
assert!(block.contains("fstype: \"tmpfs\""));
assert!(!block.contains("is_bind"));
}
/// Unknown values fall through to the tmpfs branch instead of crashing.
#[tokio::test]
async fn unknown_value_defaults_to_tmpfs() {
let block = with_tmp_backing(Some("bogus".to_string()), || async {
resolve_nsjail_tmp_mount_block("/anything").await
})
.await;
assert!(block.contains("fstype: \"tmpfs\""));
assert!(!block.contains("is_bind"));
}
/// Security regression: if a pre-existing symlink sits at the jail_tmp
/// path (e.g. planted by a user-controlled tarball extracted into
/// `job_dir` before the resolver runs), the resolver must refuse the
/// bind mount and fall back to tmpfs — never bind-mount the symlink
/// target into the sandbox as /tmp.
#[cfg(unix)]
#[tokio::test]
async fn disk_backed_refuses_preexisting_symlink_at_jail_tmp() {
let tmp = tempfile::tempdir().expect("tempdir");
let job_dir = tmp.path().to_str().expect("utf8 path").to_string();
// Plant a symlink at {job_dir}/jail_tmp pointing at an arbitrary host
// path. Target doesn't have to exist — what matters is that the
// resolver doesn't follow it.
let jail_tmp_path = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}");
std::os::unix::fs::symlink("/etc", &jail_tmp_path).expect("plant symlink");
assert!(std::path::Path::new(&jail_tmp_path).is_symlink());
let block = with_tmp_backing(Some("disk".to_string()), || async {
resolve_nsjail_tmp_mount_block(&job_dir).await
})
.await;
// Fell back to tmpfs — no bind-mount of the attacker-controlled path.
assert!(
block.contains("fstype: \"tmpfs\""),
"expected tmpfs fallback, got: {block}"
);
assert!(
!block.contains("is_bind"),
"must not emit bind block, got: {block}"
);
assert!(
!block.contains("/etc"),
"must not leak the symlink target into the proto, got: {block}"
);
}
/// Sequential resolver calls in the same `job_dir` (e.g. Python uv install
/// → Python run, Ruby install → run, Rust build → run) must keep using
/// the bind mount instead of silently falling back to tmpfs on the
/// second call. The first call creates `jail_tmp`; subsequent calls see
/// it as a pre-existing real directory and must accept it.
#[tokio::test]
async fn disk_backed_reuses_jail_tmp_across_sequential_calls() {
let tmp = tempfile::tempdir().expect("tempdir");
let job_dir = tmp.path().to_str().expect("utf8 path").to_string();
let expected_dir = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}");
let (first, second) = with_tmp_backing(Some("disk".to_string()), || async {
let first = resolve_nsjail_tmp_mount_block(&job_dir).await;
// Simulate an executor that completes its first nsjail invocation
// (e.g. uv install) leaving jail_tmp on disk, then invokes nsjail
// again for the main run.
assert!(std::path::Path::new(&expected_dir).is_dir());
let second = resolve_nsjail_tmp_mount_block(&job_dir).await;
(first, second)
})
.await;
assert!(first.contains("is_bind: true"), "first call: {first}");
assert!(
second.contains("is_bind: true"),
"second call regressed to tmpfs: {second}"
);
assert!(second.contains(&format!("src: \"{expected_dir}\"")));
}
}
async fn hash_args(
#[allow(unused)] db: &DB,
#[allow(unused)] client: &AuthedClient,
@@ -27,7 +27,7 @@ use windmill_queue::CanceledBy;
use crate::{
common::{
build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file,
get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes,
get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block,
start_child_process, DEV_CONF_NSJAIL,
},
get_proxy_envs_for_lang,
@@ -604,8 +604,8 @@ pub async fn handle_csharp_job(
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
+3 -3
View File
@@ -22,7 +22,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
use crate::{
common::{
build_command_with_isolation, capitalize, create_args_and_out_file, get_reserved_variables,
read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process,
read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process,
OccupancyMetrics, DEV_CONF_NSJAIL,
},
handle_child::handle_child,
@@ -352,8 +352,8 @@ func Run(req Req) (interface{{}}, error){{
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
+3 -3
View File
@@ -23,7 +23,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
use crate::{
common::{
build_command_with_isolation, create_args_and_out_file, get_reserved_variables,
read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process,
read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process,
OccupancyMetrics,
},
handle_child, is_sandboxing_enabled, read_ee_registry_bool_with_workspace_override,
@@ -671,8 +671,8 @@ async fn run<'a>(
// .replace("{CACHED_TARGET}", &shared_mount)
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string())
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
+3 -3
View File
@@ -14,7 +14,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
use crate::{
common::{
build_command_with_isolation, create_args_and_out_file, get_reserved_variables,
read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process,
read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process,
OccupancyMetrics, DEV_CONF_NSJAIL,
},
get_proxy_envs_for_lang, handle_child, is_sandboxing_enabled, DISABLE_NUSER, NSJAIL_PATH,
@@ -259,8 +259,8 @@ async fn run<'a>(
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
+3 -3
View File
@@ -20,7 +20,7 @@ use windmill_queue::{append_logs, CanceledBy};
use crate::{
common::{
build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file,
get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes,
get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block,
start_child_process, MaybeLock, OccupancyMetrics,
},
handle_child::handle_child,
@@ -426,8 +426,8 @@ try {{
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string())
.replace("{SHARED_MOUNT}", shared_mount)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
+3 -3
View File
@@ -26,7 +26,7 @@ lazy_static::lazy_static! {
use crate::{
common::{
build_args_map, build_command_with_isolation, get_reserved_variables, read_file,
read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process,
read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process,
MaybeLock, OccupancyMetrics,
},
handle_child::handle_child,
@@ -683,8 +683,8 @@ $env:PSModulePath = \"{};$PSModulePathBackup\"",
.replace("{SHARED_MOUNT}", shared_mount)
.replace("{CACHE_DIR}", &*POWERSHELL_CACHE_DIR)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
@@ -146,7 +146,7 @@ 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_tmpfs_size_bytes, start_child_process,
read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process,
OccupancyMetrics, StreamNotifier, DEV_CONF_NSJAIL,
},
get_proxy_envs_for_lang,
@@ -1028,8 +1028,8 @@ mount {{
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
@@ -2056,8 +2056,8 @@ async fn spawn_uv_install(
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.as_str(),
)?;
+3 -3
View File
@@ -20,7 +20,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
use crate::{
common::{
build_command_with_isolation, create_args_and_out_file, get_reserved_variables,
read_result, resolve_nsjail_tmpfs_size_bytes, start_child_process, OccupancyMetrics,
read_result, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics,
DEV_CONF_NSJAIL,
},
get_proxy_envs_for_lang,
@@ -582,8 +582,8 @@ async fn run<'a>(
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()),
)?;
+7 -4
View File
@@ -23,7 +23,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
use crate::{
common::{
build_command_with_isolation, create_args_and_out_file, get_reserved_variables,
read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process,
read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process,
OccupancyMetrics, DEV_CONF_NSJAIL,
},
get_proxy_envs_for_lang,
@@ -619,7 +619,7 @@ async fn install<'a>(
envs.clone(),
get_reserved_variables(job, &client.token, conn, parent_runnable_path.clone()).await?,
);
let nsjail_tmpfs_size = resolve_nsjail_tmpfs_size_bytes().await;
let nsjail_tmp_mount_block = resolve_nsjail_tmp_mount_block(&job_dir).await;
par_install_language_dependencies_seq(
InstallDeps::Flat(deps.clone()),
"ruby",
@@ -639,7 +639,7 @@ async fn install<'a>(
.replace("{TARGET}", &dependency.path)
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string())
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("{NSJAIL_TMPFS_SIZE}", &nsjail_tmpfs_size)
.replace("{TMP_MOUNT_BLOCK}", &nsjail_tmp_mount_block)
.replace("#{DEV}", DEV_CONF_NSJAIL), // .replace("{BUILD}", &build_dir),
)?;
let mut cmd = Command::new(NSJAIL_PATH.as_str());
@@ -812,7 +812,10 @@ mount {{
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string())
.replace("{NSJAIL_TMPFS_SIZE}", &resolve_nsjail_tmpfs_size_bytes().await)
.replace(
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
let mut cmd = Command::new(NSJAIL_PATH.as_str());
+5 -5
View File
@@ -23,7 +23,7 @@ use windmill_queue::{append_logs, CanceledBy};
use crate::{
common::{
build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file,
get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes,
get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block,
start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL,
},
get_proxy_envs_for_lang,
@@ -481,8 +481,8 @@ pub async fn build_rust_crate(
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{BUILD}", &build_dir),
)?;
@@ -706,8 +706,8 @@ pub async fn handle_rust_job(
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace("{SHARED_MOUNT}", shared_mount)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
+6
View File
@@ -687,6 +687,12 @@ lazy_static::lazy_static! {
/// `DEFAULT_NSJAIL_TMPFS_SIZE_BYTES` (800MB).
pub static ref NSJAIL_TMPFS_SIZE_MB: Arc<RwLock<Option<i64>>> = Arc::new(RwLock::new(None));
/// Selects how `/tmp` is backed inside nsjail sandboxes. `Some("disk")`
/// switches to a bind mount on `{JOB_DIR}/jail_tmp` (disk-backed); any
/// other value (including `None` or `Some("tmpfs")`) keeps the historical
/// RAM-backed tmpfs sized by `nsjail_tmpfs_size_mb`.
pub static ref NSJAIL_TMP_BACKING: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
/// Optional mirror URL for `uv python install`. Wires to the `UV_PYTHON_INSTALL_MIRROR`
/// env var when forwarded to uv. Can be set via the `UV_PYTHON_INSTALL_MIRROR` env var
/// or the `uv_python_install_mirror` instance setting.
+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.705.0";
export const VERSION = "v1.706.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+2 -45
View File
@@ -16,13 +16,8 @@ import {
type Workspace,
} from "../workspace/workspace.ts";
import { generateRTNamespace } from "../resource-type/resource-type.ts";
import {
WMILL_INIT_AI_AGENTS_SOURCE_ENV,
WMILL_INIT_AI_CLAUDE_SOURCE_ENV,
WMILL_INIT_AI_SKILLS_SOURCE_ENV,
writeAiGuidanceFiles,
} from "../../guidance/writer.ts";
import { generateCommentedTemplate } from "./template.ts";
import { refreshPrompts } from "../refresh/prompts.ts";
export interface InitOptions {
useDefault?: boolean;
@@ -241,45 +236,7 @@ async function initAction(opts: InitOptions) {
}
}
// Read nonDottedPaths from config
let nonDottedPaths = true; // default for new inits
try {
const { readConfigFile } = await import("../../core/conf.ts");
const config = await readConfigFile();
nonDottedPaths = config.nonDottedPaths ?? true;
} catch {
// If config can't be read, use defaults
}
// Create guidance files (AGENTS.md, CLAUDE.md, and agent skills)
try {
const guidanceResult = await writeAiGuidanceFiles({
targetDir: ".",
nonDottedPaths,
overwriteProjectGuidance: false,
skillsSourcePath: process.env[WMILL_INIT_AI_SKILLS_SOURCE_ENV],
agentsSourcePath: process.env[WMILL_INIT_AI_AGENTS_SOURCE_ENV],
claudeSourcePath: process.env[WMILL_INIT_AI_CLAUDE_SOURCE_ENV],
});
if (guidanceResult.agentsWritten) {
log.info(colors.green("Created AGENTS.md"));
}
if (guidanceResult.claudeWritten) {
log.info(colors.green("Created CLAUDE.md"));
}
log.info(
colors.green(
`Created .claude/skills/ and .agents/skills/ with ${guidanceResult.skillCount} skills`
)
);
} catch (error) {
if (error instanceof Error) {
log.warn(`Could not create guidance files: ${error.message}`);
} else {
log.warn(`Could not create guidance files: ${error}`);
}
}
await refreshPrompts({ yes: opts.useDefault === true });
// Generate resource type namespace (only if a workspace was bound)
if (didBindWorkspace && boundProfile) {
+182
View File
@@ -0,0 +1,182 @@
import { colors } from "@cliffy/ansi/colors";
import { Command } from "@cliffy/command";
import { Select } from "@cliffy/prompt/select";
import * as log from "../../core/log.ts";
import {
type AgentsMdMigration,
type ReconcileOutcome,
WMILL_INIT_AI_AGENTS_SOURCE_ENV,
WMILL_INIT_AI_CLAUDE_SOURCE_ENV,
WMILL_INIT_AI_SKILLS_SOURCE_ENV,
writeAiGuidanceFiles,
} from "../../guidance/writer.ts";
/**
* Programmatic entry point reused by `wmill init`. The init flow doesn't
* register the cliffy command itself it imports and calls this directly so
* that prompt regeneration is part of every init.
*/
export async function refreshPrompts(opts: {
yes?: boolean;
}): Promise<void> {
// Match `core/conf.ts`'s missing-key default (`?? false`) so legacy
// wmill.yaml files without the key don't drift from how sync renders
// paths. New projects get `true` via the wmill.yaml template, not via
// this fallback.
let nonDottedPaths = false;
try {
const { readConfigFile } = await import("../../core/conf.ts");
const config = await readConfigFile();
nonDottedPaths = config.nonDottedPaths ?? false;
} catch {
// If config can't be read, use the conservative default above.
}
const interactive = process.stdin.isTTY && !opts.yes;
try {
const result = await writeAiGuidanceFiles({
targetDir: ".",
nonDottedPaths,
skillsSourcePath: process.env[WMILL_INIT_AI_SKILLS_SOURCE_ENV],
agentsSourcePath: process.env[WMILL_INIT_AI_AGENTS_SOURCE_ENV],
claudeSourcePath: process.env[WMILL_INIT_AI_CLAUDE_SOURCE_ENV],
resolveAgentsMdMigration: async () => {
if (!interactive) return "append";
return await promptMigration();
},
});
log.info(colors.green("Refreshed AGENTS.cli.md"));
reportReconciliation({
file: "AGENTS.md",
includeLine: "@AGENTS.cli.md",
created: result.agentsCreated,
migration: result.agentsMigration,
});
reportReconciliation({
file: "CLAUDE.md",
includeLine: "@AGENTS.md",
created: result.claudeCreated,
migration: result.claudeMigration,
});
log.info(
colors.green(
`Refreshed .claude/skills/ and .agents/skills/ with ${result.skillCount} skills`
)
);
log.info(
colors.gray(
"Project-specific instructions live in AGENTS.md (never overwritten unless you opt in)."
)
);
} catch (error) {
// Log first so the user sees what happened, then rethrow so `wmill
// refresh prompts` (and `wmill init`, which delegates here) exits
// non-zero. Silent swallowing would hide a broken refresh from CI.
if (error instanceof Error) {
log.error(`Could not refresh guidance files: ${error.message}`);
} else {
log.error(`Could not refresh guidance files: ${error}`);
}
throw error;
}
}
function reportReconciliation(opts: {
file: string;
includeLine: string;
created: boolean;
migration: ReconcileOutcome;
}): void {
if (opts.created) {
log.info(colors.green(`Created ${opts.file} (user-owned)`));
return;
}
switch (opts.migration) {
case "already-linked":
log.info(
colors.gray(
`${opts.file} already references ${opts.includeLine} — left as-is`
)
);
break;
case "append":
log.info(
colors.green(`Appended ${opts.includeLine} include to existing ${opts.file}`)
);
break;
case "overwrite":
log.info(colors.yellow(`Overwrote ${opts.file} with managed skeleton`));
break;
case "skip":
log.info(
colors.gray(
`${opts.file} left unchanged — wire \`${opts.includeLine}\` in manually when ready`
)
);
break;
case "not-applicable":
// unreachable when created is false, but keep exhaustive
break;
}
}
async function promptMigration(): Promise<AgentsMdMigration> {
log.info("");
log.info(
colors.yellow(
"An existing AGENTS.md or CLAUDE.md was found that does not reference Windmill's managed guidance."
)
);
log.info(
colors.gray(
"Choose how to link the managed files in (we'll apply the same choice to AGENTS.md and CLAUDE.md):"
)
);
const choice = await Select.prompt({
message: "How should we handle the existing file(s)?",
options: [
{
name:
"Append the include line " +
"(preserves your content — recommended if you have custom instructions)",
value: "append",
},
{
name:
"Overwrite with the managed skeleton " +
"(replaces your content — pick if the file only had the default template)",
value: "overwrite",
},
{
name: "Skip — leave the file alone; I'll wire it up manually",
value: "skip",
},
],
});
return choice as AgentsMdMigration;
}
interface CommandOptions {
yes?: boolean;
}
async function promptsAction(opts: CommandOptions): Promise<void> {
await refreshPrompts({ yes: opts.yes === true });
}
const command = new Command()
.description("Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.")
.option(
"--yes",
"Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include."
)
.action(promptsAction as any);
export default command;
+8
View File
@@ -0,0 +1,8 @@
import { Command } from "@cliffy/command";
import promptsCommand from "./prompts.ts";
const command = new Command()
.description("Refresh wmill-managed project files (AGENTS.cli.md and skills)")
.command("prompts", promptsCommand);
export default command;
+91 -9
View File
@@ -1,17 +1,55 @@
/**
* Core guidance content for AGENTS.md
* Core guidance content for the AGENTS files Windmill writes during init.
*
* This module exports the template for the AGENTS.md file that provides
* AI agent instructions for working with Windmill projects.
* `wmill` writes two files:
*
* - `AGENTS.cli.md` managed CLI / workspace guidance, refreshed by
* `wmill refresh prompts` (and the implicit refresh inside `wmill init`).
* - `AGENTS.md` user-owned project entry point. The default skeleton
* references `AGENTS.cli.md` via an `@`-include so the managed content is
* pulled in automatically.
*/
export const AGENTS_CLI_INCLUDE_LINE = "@AGENTS.cli.md";
/**
* Generate the AGENTS.md content with the given skills reference.
* @param skillsReference - A formatted list of skills to include in the document
* @returns The complete AGENTS.md content
* Lightweight, user-owned AGENTS.md skeleton. Written only when no AGENTS.md
* exists in the project. Everything below the `@AGENTS.cli.md` include is for
* the user to edit; nothing in this file is refreshed by `wmill`.
*/
export function generateAgentsMdContent(skillsReference: string): string {
return `# Windmill AI Agent Instructions
export function generateAgentsMdSkeleton(): string {
return `# Project AI Agent Instructions
This file is the entry point for AI agents working in this repository. It is
**user-owned** \`wmill\` never overwrites it. Add your project-specific
guidance below the include line.
The line below pulls in Windmill's managed CLI guidance (skills, deploy flow,
debugging jobs, etc.). Refresh it with \`wmill refresh prompts\`. Remove the
include line if you don't want the managed guidance in this project.
${AGENTS_CLI_INCLUDE_LINE}
## Project-specific instructions
<!-- Add anything specific to this repo here. Examples:
- Deploy commands or environments unique to this project.
- Domain glossary, naming conventions, or "ask before X" rules.
- Overrides for the managed guidance above (be explicit that they
supersede the managed rule). -->
`;
}
/**
* Managed AGENTS.cli.md content. Rewritten by `wmill init` and
* `wmill refresh prompts` every time.
*/
export function generateAgentsCliMdContent(skillsReference: string): string {
return `# Windmill CLI Agent Instructions
> Managed by \`wmill\`. This file is regenerated on \`wmill init\` and
> \`wmill refresh prompts\` — edit AGENTS.md (user-owned) for project-specific
> instructions instead.
You are a helpful assistant that can help with Windmill scripts, flows, apps, and resources management.
@@ -55,6 +93,50 @@ You MUST use the \`preview\` skill any time the user wants to see/open/visualize
You MUST use the \`cli-commands\` skill to use the CLI.
## Running and previewing local changes
Local previews exist for every entity type and don't deploy:
- \`wmill script preview <path> -d '<args>'\` — run a local script.
- \`wmill flow preview <flow_path> -d '<args>'\` — run a local flow.yaml.
- \`wmill app dev\` — live-reload dev server for raw apps.
Argument shapes and per-language details live in the \`write-script-<lang>\`, \`write-flow\`, and \`raw-app\` skills.
## Deploying
There are two ways local changes reach the workspace. Pick based on how the repo is wired, not habit.
### Detecting the setup
Before deploying, check whether this repo has a **GitHub Actions (or other CI) workflow that runs \`wmill sync push\` on push**. That workflow is the signal that pushing a branch will deploy:
- Look for \`.github/workflows/*.yml\` (or other CI configs) that invoke \`wmill sync push\`, \`wmill\` deployment commands, or similar.
- Cache the result for the rest of the session don't re-scan on every deploy.
If such a workflow exists **use \`git push\`** (Option A). Otherwise → **use \`wmill sync push\`** directly (Option B).
### Option A \`git push\` (CI is wired to sync)
The CI workflow will pick up the commit and run \`wmill sync push\` on the backend, which is how deployments are intended to happen in this repo. Don't bypass it.
1. \`git add\` + \`git commit\` the local changes.
2. \`git push\` to the branch the CI runs on.
3. The workflow deploys to the workspace.
Only fall back to Option B if the user explicitly asks to bypass CI for this change (e.g. CI is broken, urgent hotfix), or if the workflow doesn't cover the current branch.
### Option B \`wmill sync push\` (no CI wiring)
No CI workflow runs \`wmill sync push\` automatically, so deploy directly from the CLI:
- \`wmill sync push --dry-run\` to preview.
- \`wmill sync push\` to apply.
### In both cases
Only deploy when the user explicitly asks to deploy, publish, push, or ship not when they say "run", "try", or "test". For testing local edits use the per-entity \`preview\` commands (\`wmill script preview\`, \`wmill flow preview\`) — they don't deploy.
## Debugging Jobs
When the user reports a script or flow failure, is investigating unexpected output, or asks why something ran the way it did, use the CLI to fetch job details before speculating. See the \`cli-commands\` skill for all flags.
@@ -70,7 +152,7 @@ For flow failures, start with \`wmill job get <id>\` to identify the failing ste
## Skills
For specific guidance, ALWAYS use the skills listed below.
For specific guidance, ALWAYS use the skills listed below. Paths point at \`.agents/skills/\` — Claude Code reads identical copies under \`.claude/skills/\`.
${skillsReference}
`;
+169
View File
@@ -0,0 +1,169 @@
/**
* Versioning + freshness check for the managed AGENTS.cli.md bundle.
*
* We embed a short hash of "what this CLI would write" into AGENTS.cli.md as
* an HTML comment. On every `wmill` command (with a few exceptions), we read
* the stored hash and compare against the current CLI's hash. Mismatch =>
* one-line warning telling the user to `wmill refresh prompts`.
*
* The hash covers all inputs that affect the rendered bundle: the
* AGENTS.cli.md template, every skill body, schemas and schema mappings, and
* the nonDottedPaths setting. It is *not* tied to the CLI's package version,
* so non-prompt CLI releases don't produce false positives.
*/
import { createHash } from "node:crypto";
import { stat } from "node:fs/promises";
import { colors } from "@cliffy/ansi/colors";
import { readTextFile } from "../utils/utils.ts";
import { generateAgentsCliMdContent } from "./core.ts";
import {
SCHEMAS,
SCHEMA_MAPPINGS,
SKILLS,
SKILL_CONTENT,
} from "./skills.gen.ts";
// Re-export from the gate module so existing callers (and tests) keep working.
// `shouldRunFreshnessCheck` lives there to avoid pulling skills.gen.ts (~360 KB)
// into main.ts's static import graph; main.ts now imports the gate directly
// and only `await import`s this file lazily.
import { shouldRunFreshnessCheck } from "./freshness_gate.ts";
export { shouldRunFreshnessCheck };
export const PROMPTS_HASH_MARKER_PREFIX = "<!-- wmill-prompts-hash: ";
const PROMPTS_HASH_REGEX = /<!-- wmill-prompts-hash: ([0-9a-f]{12}) -->/;
export function buildPromptsHashMarker(hash: string): string {
return `${PROMPTS_HASH_MARKER_PREFIX}${hash} -->`;
}
export function extractPromptsHash(content: string): string | null {
const match = content.match(PROMPTS_HASH_REGEX);
return match ? match[1] : null;
}
/**
* Insert the hash marker into rendered AGENTS.cli.md content. The marker
* goes on the line right after the title so it's easy to find and doesn't
* break the rendered Markdown structure.
*/
export function injectPromptsHashMarker(
content: string,
hash: string
): string {
const lines = content.split("\n");
const marker = buildPromptsHashMarker(hash);
// Insert right after the first line if it's an H1 title; otherwise
// prepend so the marker is always near the top.
const insertAt = lines[0].startsWith("# ") ? 1 : 0;
lines.splice(insertAt, 0, marker);
return lines.join("\n");
}
/**
* Compute the hash for the rendered bundle. The hash is deterministic for a
* given (CLI bundle, nonDottedPaths) pair.
*/
export function currentPromptsHash(nonDottedPaths: boolean): string {
const hasher = createHash("sha256");
// Template structure (without the skills reference — that's hashed
// separately from the SKILLS metadata).
hasher.update("template:");
hasher.update(generateAgentsCliMdContent("__PLACEHOLDER__"));
// Skill metadata (names + descriptions) — fed into the skills reference
// line in AGENTS.cli.md and the wrapper frontmatter.
hasher.update("\nskills:");
hasher.update(JSON.stringify(SKILLS));
// Skill bodies — what actually lands in .agents/skills/<name>/SKILL.md.
// Sort entries for stable ordering.
hasher.update("\nbodies:");
for (const [name, content] of Object.entries(SKILL_CONTENT).sort()) {
hasher.update("\n");
hasher.update(name);
hasher.update("\n");
hasher.update(content);
}
// Schemas + their mappings — embedded inside specific skills.
hasher.update("\nschemas:");
hasher.update(JSON.stringify(SCHEMAS));
hasher.update("\nmappings:");
hasher.update(JSON.stringify(SCHEMA_MAPPINGS));
// Path-style setting — controls __flow vs .flow rendering in skill bodies.
hasher.update("\nnonDotted:");
hasher.update(String(nonDottedPaths));
return hasher.digest("hex").slice(0, 12);
}
/**
* Read AGENTS.cli.md in the current working directory, compare its embedded
* hash to the current CLI's hash, and print a one-line warning if they
* differ. Silent on every other code path (no AGENTS.cli.md, no marker,
* matching hash, IO error, ) so it never gets in the user's way.
*/
export async function warnIfPromptsStale(opts?: {
cwd?: string;
nonDottedPaths?: boolean;
argv?: readonly string[];
}): Promise<void> {
if (opts?.argv && !shouldRunFreshnessCheck(opts.argv)) return;
const cwd = opts?.cwd ?? process.cwd();
const path = `${cwd}/AGENTS.cli.md`;
if (!(await stat(path).catch(() => null))) return;
let content: string;
try {
content = await readTextFile(path);
} catch {
return;
}
const stored = extractPromptsHash(content);
if (!stored) {
// Older AGENTS.cli.md without a marker. Warn so the user re-runs
// refresh and picks up the new format.
emitWarning(
"Your AGENTS.cli.md predates prompt versioning. Run `wmill refresh prompts` to refresh and add a version marker."
);
return;
}
let nonDottedPaths = opts?.nonDottedPaths;
if (nonDottedPaths === undefined) {
try {
const { readConfigFile } = await import("../core/conf.ts");
const config = await readConfigFile();
// Match `core/conf.ts`'s missing-key default (`?? false`); otherwise
// legacy wmill.yaml files without the key trip a permanent freshness
// warning even though the prompts are objectively up to date.
nonDottedPaths = config.nonDottedPaths ?? false;
} catch {
nonDottedPaths = false;
}
}
const current = currentPromptsHash(nonDottedPaths);
if (stored !== current) {
emitWarning(
"Your AGENTS.cli.md is out of date. Run `wmill refresh prompts` to refresh."
);
}
}
/**
* Send the freshness warning to **stderr** so it never contaminates a
* downstream pipe (e.g. `wmill job result <id> | jq`). The rest of the CLI
* uses `log.warn` which writes to stdout that's wrong for an always-on
* notification like this one, but we don't want to fix `log.warn` globally
* in this PR.
*/
function emitWarning(message: string): void {
process.stderr.write(`${colors.yellow(message)}\n`);
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Argv-only gate for the prompts freshness check. Kept in its own module so
* `main.ts` can import it without pulling in the heavy `skills.gen.ts`
* bundle (~360 KB) on every `wmill` invocation. The full check (which does
* touch the bundle) lives in `./freshness.ts` and is loaded lazily after
* this gate returns `true`.
*/
/**
* Subcommands where a freshness warning is noise (the user is either fixing
* it, asking for help, or doing something orthogonal).
*/
const SKIP_FRESHNESS_FOR_SUBCOMMANDS = new Set([
"init",
"refresh",
"completions",
"upgrade",
]);
/**
* Cliffy global options that consume the *next* argv element as their value.
* Must be kept in sync with the option declarations on the top-level
* `command` in `cli/src/main.ts`.
*/
const VALUE_GLOBAL_OPTS = new Set([
"--workspace",
"--token",
"--base-url",
"--config-dir",
]);
/**
* Returns `true` if the freshness check should run for this invocation.
*
* Bypasses:
* - bare `wmill` (no subcommand shows help)
* - `--help`, `-h`, `--version`, `-V` anywhere in the args
* - subcommands in {init, refresh, completions, upgrade}
*
* Handles cliffy global options that take a value (`--workspace foo`,
* `--token tok`, `--base-url https://…`, `--config-dir /etc/wmill`) by
* skipping their value when scanning for the first positional argument.
* Without that, `wmill --workspace prod refresh prompts` would misread
* `"prod"` as the subcommand and fire the warning during the very command
* meant to fix it.
*/
export function shouldRunFreshnessCheck(argv: readonly string[]): boolean {
const args = argv.slice(2); // strip node + script
if (args.length === 0) return false;
if (args.includes("--help") || args.includes("-h")) return false;
if (args.includes("--version") || args.includes("-V")) return false;
let i = 0;
while (i < args.length) {
const arg = args[i];
if (VALUE_GLOBAL_OPTS.has(arg)) {
i += 2; // skip flag + its value
continue;
}
if (arg.startsWith("-")) {
i += 1; // flag with no value
continue;
}
return !SKIP_FRESHNESS_FOR_SUBCOMMANDS.has(arg);
}
return false;
}
+9
View File
@@ -7001,6 +7001,15 @@ List all queues with their metrics
- \`--instance [instance]\` - Name of the instance to push to, override the active instance
- \`--base-url [baseUrl]\` - If used with --token, will be used as the base url for the instance
### refresh
Refresh wmill-managed project files (AGENTS.cli.md and skills)
**Subcommands:**
- \`refresh prompts\` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.
- \`--yes\` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include.
### resource
resource related commands
+197 -43
View File
@@ -1,7 +1,15 @@
import { cp, mkdir, readdir, stat, writeFile } from "node:fs/promises";
import { readTextFile } from "../utils/utils.ts";
import { join } from "node:path";
import { generateAgentsMdContent } from "./core.ts";
import {
AGENTS_CLI_INCLUDE_LINE,
generateAgentsCliMdContent,
generateAgentsMdSkeleton,
} from "./core.ts";
import {
currentPromptsHash,
injectPromptsHashMarker,
} from "./freshness.ts";
import {
SCHEMAS,
SCHEMA_MAPPINGS,
@@ -14,18 +22,47 @@ type ResolvedSkillMetadata = SkillMetadata & {
directoryName: string;
};
/**
* How to reconcile an existing user-owned guidance file (AGENTS.md or
* CLAUDE.md) that doesn't reference the managed file below it
* (`@AGENTS.cli.md` for AGENTS.md, `@AGENTS.md` for CLAUDE.md).
*
* - `append`: leave the file as-is and append the include line.
* - `overwrite`: replace the file with the managed skeleton.
* - `skip`: leave the file alone. The managed downstream file is still
* written/refreshed, but no link to it the user is expected to wire it
* manually later.
*/
export type AgentsMdMigration = "append" | "overwrite" | "skip";
export type ReconcileOutcome =
| AgentsMdMigration
| "already-linked"
| "not-applicable";
export interface WriteAiGuidanceOptions {
targetDir: string;
nonDottedPaths?: boolean;
overwriteProjectGuidance?: boolean;
/** Skill source override (testing / source-of-truth bundling). */
skillsSourcePath?: string;
/** AGENTS.cli.md source override (testing). */
agentsSourcePath?: string;
/** CLAUDE.md source override (testing). */
claudeSourcePath?: string;
/**
* Optional resolver invoked when an existing AGENTS.md lacks an
* `@AGENTS.cli.md` reference. Callers are expected to prompt the user; if
* omitted, the writer defaults to `append` (non-destructive).
*/
resolveAgentsMdMigration?: () => Promise<AgentsMdMigration>;
}
export interface WriteAiGuidanceResult {
agentsWritten: boolean;
claudeWritten: boolean;
agentsCliWritten: boolean;
agentsCreated: boolean;
agentsMigration: ReconcileOutcome;
claudeCreated: boolean;
claudeMigration: ReconcileOutcome;
skillCount: number;
}
@@ -34,32 +71,73 @@ export const WMILL_INIT_AI_AGENTS_SOURCE_ENV = "WMILL_INIT_AI_AGENTS_SOURCE";
export const WMILL_INIT_AI_CLAUDE_SOURCE_ENV = "WMILL_INIT_AI_CLAUDE_SOURCE";
const CLAUDE_MD_DEFAULT = "Instructions are in @AGENTS.md\n";
const SKILL_TARGET_ROOTS = [".claude", ".agents"] as const;
const CLAUDE_MD_INCLUDE_LINE = "@AGENTS.md";
/**
* Both `.agents/skills/` (read by Codex, Pi) and `.claude/skills/` (read by
* Claude Code) receive the full skill content. We can't use `@<path>` to
* deduplicate because Claude's skill loader reads SKILL.md as-is it does
* not expand `@` references inside skill bodies (those work only in
* AGENTS.md / CLAUDE.md).
*/
const SKILL_TARGET_ROOTS = [".agents", ".claude"] as const;
export async function writeAiGuidanceFiles(
options: WriteAiGuidanceOptions
): Promise<WriteAiGuidanceResult> {
const nonDottedPaths = options.nonDottedPaths ?? true;
// Match `core/conf.ts`'s missing-key default — if a legacy wmill.yaml
// omits `nonDottedPaths`, sync treats it as `false`, so we must too or
// the freshness hash will be permanently out of sync with the rest of
// the CLI's view of the project.
const nonDottedPaths = options.nonDottedPaths ?? false;
const skillMetadata = options.skillsSourcePath
? await readSkillMetadataFromDirectory(options.skillsSourcePath)
: getGeneratedSkillMetadata();
const agentsWritten = await writeProjectGuidanceFile({
targetPath: join(options.targetDir, "AGENTS.md"),
overwrite: options.overwriteProjectGuidance ?? false,
content:
options.agentsSourcePath != null
? await readTextFile(options.agentsSourcePath)
: generateAgentsMdContent(buildSkillsReference(skillMetadata)),
// AGENTS.cli.md — always (re)written, this is the managed file.
// We embed a content-hash marker so other `wmill` commands can detect a
// stale bundle and prompt the user to `wmill refresh prompts`.
const rawAgentsCliContent =
options.agentsSourcePath != null
? await readTextFile(options.agentsSourcePath)
: generateAgentsCliMdContent(buildSkillsReference(skillMetadata));
const agentsCliContent = injectPromptsHashMarker(
rawAgentsCliContent,
currentPromptsHash(nonDottedPaths)
);
const agentsCliPath = join(options.targetDir, "AGENTS.cli.md");
await writeFile(agentsCliPath, agentsCliContent, "utf8");
const agentsCliWritten = true;
// Cache the user's first migration answer and reuse it for every file
// that needs reconciling in this run — there's never a good reason to ask
// the same question twice in a row.
const resolveMigration = cacheOnce(options.resolveAgentsMdMigration);
// AGENTS.md — user-owned. Three paths:
// 1. doesn't exist → create skeleton (which already includes @AGENTS.cli.md).
// 2. exists and already references @AGENTS.cli.md → leave alone.
// 3. exists but doesn't reference @AGENTS.cli.md → ask caller via
// resolveMigration (defaults to append).
const agentsMdResult = await reconcileIncludingFile({
path: join(options.targetDir, "AGENTS.md"),
includeLine: AGENTS_CLI_INCLUDE_LINE,
skeleton: generateAgentsMdSkeleton(),
resolveMigration,
});
const claudeWritten = await writeProjectGuidanceFile({
targetPath: join(options.targetDir, "CLAUDE.md"),
overwrite: options.overwriteProjectGuidance ?? false,
content:
options.claudeSourcePath != null
? await readTextFile(options.claudeSourcePath)
: CLAUDE_MD_DEFAULT,
// CLAUDE.md — user-owned wrapper that points at @AGENTS.md. Same three-way
// reconciliation: create if missing, leave alone if it already references
// AGENTS.md, otherwise ask via resolveMigration.
const claudeSkeleton =
options.claudeSourcePath != null
? await readTextFile(options.claudeSourcePath)
: CLAUDE_MD_DEFAULT;
const claudeMdResult = await reconcileIncludingFile({
path: join(options.targetDir, "CLAUDE.md"),
includeLine: CLAUDE_MD_INCLUDE_LINE,
skeleton: claudeSkeleton,
resolveMigration,
});
if (options.skillsSourcePath) {
@@ -69,17 +147,87 @@ export async function writeAiGuidanceFiles(
}
return {
agentsWritten,
claudeWritten,
agentsCliWritten,
agentsCreated: agentsMdResult.created,
agentsMigration: agentsMdResult.migration,
claudeCreated: claudeMdResult.created,
claudeMigration: claudeMdResult.migration,
skillCount: skillMetadata.length,
};
}
function cacheOnce(
resolver: (() => Promise<AgentsMdMigration>) | undefined
): (() => Promise<AgentsMdMigration>) | undefined {
if (!resolver) return undefined;
let cached: AgentsMdMigration | null = null;
return async () => {
if (cached !== null) return cached;
cached = await resolver();
return cached;
};
}
async function reconcileIncludingFile(options: {
path: string;
includeLine: string;
skeleton: string;
resolveMigration?: () => Promise<AgentsMdMigration>;
}): Promise<{ created: boolean; migration: ReconcileOutcome }> {
const exists = (await stat(options.path).catch(() => null)) != null;
if (!exists) {
await writeFile(options.path, options.skeleton, "utf8");
return { created: true, migration: "not-applicable" };
}
const existing = await readTextFile(options.path);
if (referencesIncludeLine(existing, options.includeLine)) {
return { created: false, migration: "already-linked" };
}
const choice = options.resolveMigration
? await options.resolveMigration()
: "append";
if (choice === "skip") {
return { created: false, migration: "skip" };
}
if (choice === "overwrite") {
await writeFile(options.path, options.skeleton, "utf8");
return { created: false, migration: "overwrite" };
}
// append — add the include at the end, leaving existing content untouched.
const appended = existing.endsWith("\n")
? `${existing}\n${options.includeLine}\n`
: `${existing}\n\n${options.includeLine}\n`;
await writeFile(options.path, appended, "utf8");
return { created: false, migration: "append" };
}
function referencesIncludeLine(content: string, includeLine: string): boolean {
// Match only when the include sits on a line by itself (allowing leading
// and trailing whitespace). Earlier we split on `\s+`, but that
// false-positives on commented-out includes like `<!-- @AGENTS.cli.md -->`
// where the middle token equals the include. CRLF is handled by the
// `\r?\n` split.
for (const line of content.split(/\r?\n/)) {
if (line.trim() === includeLine) {
return true;
}
}
return false;
}
function buildSkillsReference(
skills: Pick<ResolvedSkillMetadata, "directoryName" | "description">[]
): string {
return skills
.map((skill) => `- \`.claude/skills/${skill.directoryName}/SKILL.md\` - ${skill.description}`)
.map(
(skill) =>
`- \`.agents/skills/${skill.directoryName}/SKILL.md\` - ${skill.description}`
)
.join("\n");
}
@@ -89,7 +237,9 @@ async function copySkillsFromSource(
): Promise<ResolvedSkillMetadata[]> {
const skillsDirs = await ensureSkillsDirectories(targetDir);
await Promise.all(
skillsDirs.map((skillsDir) => copyDirectoryContents(skillsSourcePath, skillsDir))
skillsDirs.map((skillsDir) =>
copyDirectoryContents(skillsSourcePath, skillsDir)
)
);
return await readSkillMetadataFromDirectory(skillsDirs[0]);
}
@@ -137,7 +287,10 @@ async function ensureSkillsDirectories(targetDir: string): Promise<string[]> {
return skillsDirs;
}
async function copyDirectoryContents(sourceDir: string, targetDir: string): Promise<void> {
async function copyDirectoryContents(
sourceDir: string,
targetDir: string
): Promise<void> {
const entries = await readdir(sourceDir, { withFileTypes: true });
await Promise.all(
@@ -150,7 +303,10 @@ async function copyDirectoryContents(sourceDir: string, targetDir: string): Prom
);
}
function renderGeneratedSkillContent(skillName: string, nonDottedPaths: boolean): string {
function renderGeneratedSkillContent(
skillName: string,
nonDottedPaths: boolean
): string {
let skillContent = SKILL_CONTENT[skillName];
if (!skillContent) {
throw new Error(`Missing generated skill content for ${skillName}`);
@@ -187,7 +343,11 @@ function renderGeneratedSkillContent(skillName: string, nonDottedPaths: boolean)
if (!schemaYaml) {
return null;
}
return formatSchemaForMarkdown(schemaYaml, mapping.name, mapping.filePattern);
return formatSchemaForMarkdown(
schemaYaml,
mapping.name,
mapping.filePattern
);
})
.filter((entry): entry is string => entry !== null);
@@ -198,11 +358,15 @@ function renderGeneratedSkillContent(skillName: string, nonDottedPaths: boolean)
return `${skillContent}\n\n${schemaDocs.join("\n\n")}`;
}
async function readSkillMetadataFromDirectory(skillsDir: string): Promise<ResolvedSkillMetadata[]> {
async function readSkillMetadataFromDirectory(
skillsDir: string
): Promise<ResolvedSkillMetadata[]> {
const entries = await readdir(skillsDir, { withFileTypes: true });
const skills: ResolvedSkillMetadata[] = [];
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
for (const entry of entries.sort((left, right) =>
left.name.localeCompare(right.name)
)) {
if (!entry.isDirectory()) {
continue;
}
@@ -219,7 +383,10 @@ async function readSkillMetadataFromDirectory(skillsDir: string): Promise<Resolv
return skills;
}
function parseSkillMetadata(content: string, fallbackName: string): ResolvedSkillMetadata {
function parseSkillMetadata(
content: string,
fallbackName: string
): ResolvedSkillMetadata {
const frontMatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!frontMatterMatch) {
return {
@@ -251,19 +418,6 @@ function parseSkillMetadata(content: string, fallbackName: string): ResolvedSkil
return { name, description, directoryName: fallbackName };
}
async function writeProjectGuidanceFile(options: {
targetPath: string;
content: string;
overwrite: boolean;
}): Promise<boolean> {
if (!options.overwrite && (await stat(options.targetPath).catch(() => null))) {
return false;
}
await writeFile(options.targetPath, options.content, "utf8");
return true;
}
function formatSchemaForMarkdown(
schemaYaml: string,
schemaName: string,
+13 -1
View File
@@ -40,6 +40,8 @@ import workers from "./commands/workers/workers.ts";
import queues from "./commands/queues/queues.ts";
import dependencies from "./commands/dependencies/dependencies.ts";
import init from "./commands/init/init.ts";
import refresh from "./commands/refresh/refresh.ts";
import { shouldRunFreshnessCheck } from "./guidance/freshness_gate.ts";
import jobs from "./commands/jobs/jobs.ts";
import job from "./commands/job/job.ts";
import group from "./commands/group/group.ts";
@@ -85,7 +87,7 @@ export {
token,
};
export const VERSION = "1.705.0";
export const VERSION = "1.706.0";
// Re-exported from constants.ts to maintain backwards compatibility
export { WM_FORK_PREFIX } from "./core/constants.ts";
@@ -175,6 +177,7 @@ const command = new Command()
},
})
.command("init", init)
.command("refresh", refresh)
.command("app", app)
.command("flow", flow)
.command("script", script)
@@ -291,6 +294,15 @@ async function main() {
await detectAuthGatewayChallenge(response);
return response;
});
// Warn (one line) if AGENTS.cli.md predates this CLI's prompts bundle.
// The check is gated on argv parsing (cheap) so the ~360 KB skills.gen.ts
// bundle stays out of the import graph for help/version/init/refresh/etc.
if (shouldRunFreshnessCheck(process.argv)) {
const { warnIfPromptsStale } = await import("./guidance/freshness.ts");
await warnIfPromptsStale({ argv: process.argv }).catch(() => {});
}
await command.parse(args);
} catch (e) {
if (e && typeof e === "object" && "name" in e && e.name === "ApiError") {
+403 -27
View File
@@ -3,8 +3,15 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { writeAiGuidanceFiles } from "../src/guidance/writer.ts";
import {
currentPromptsHash,
extractPromptsHash,
injectPromptsHashMarker,
shouldRunFreshnessCheck,
warnIfPromptsStale,
} from "../src/guidance/freshness.ts";
const SKILL_TARGET_ROOTS = [".claude", ".agents"] as const;
const SKILL_TARGET_ROOTS = [".agents", ".claude"] as const;
async function withTempDir(fn: (tempDir: string) => Promise<void>): Promise<void> {
const tempDir = await mkdtemp(join(tmpdir(), "wmill_guidance_writer_"));
@@ -26,7 +33,7 @@ async function writeSkill(
return skillPath;
}
describe("writeAiGuidanceFiles", () => {
describe("writeAiGuidanceFiles — skills", () => {
test("preserves custom skills when refreshing generated guidance", async () => {
await withTempDir(async (tempDir) => {
const skillsDirs = SKILL_TARGET_ROOTS.map((root) =>
@@ -52,19 +59,21 @@ Preserve me.
)
);
await writeAiGuidanceFiles({
targetDir: tempDir,
overwriteProjectGuidance: false,
});
await writeAiGuidanceFiles({ targetDir: tempDir });
// Custom skills survive on every side, untouched.
for (const customSkillPath of customSkillPaths) {
expect(await readFile(customSkillPath, "utf8")).toBe(customSkillContent);
}
// Both `.agents/skills/` and `.claude/skills/` hold the same full
// canonical content. (Claude's skill loader doesn't expand `@`
// references inside SKILL.md, so we can't dedupe via `@`-include.)
for (const generatedSkillPath of generatedSkillPaths) {
const generatedSkillContent = await readFile(generatedSkillPath, "utf8");
expect(generatedSkillContent).not.toBe(staleGeneratedContent);
expect(generatedSkillContent).toContain("name: write-flow");
expect(generatedSkillContent).not.toContain("@../../../");
}
});
});
@@ -101,7 +110,7 @@ Copied from source bundle.
writeSkill(skillsDir, "custom-skill", customSkillContent)
)
);
const existingGeneratedSkillPaths = await Promise.all(
await Promise.all(
skillsDirs.map((skillsDir) =>
writeSkill(skillsDir, "write-flow", "old content")
)
@@ -113,25 +122,28 @@ Copied from source bundle.
await writeAiGuidanceFiles({
targetDir: tempDir,
overwriteProjectGuidance: false,
skillsSourcePath: sourceSkillsDir,
});
// Custom skills survive untouched on every side.
for (const customSkillPath of customSkillPaths) {
expect(await readFile(customSkillPath, "utf8")).toBe(customSkillContent);
}
for (const existingGeneratedSkillPath of existingGeneratedSkillPaths) {
expect(await readFile(existingGeneratedSkillPath, "utf8")).toBe(sourceSkillContent);
}
for (const skillsDir of skillsDirs) {
expect(await readFile(join(skillsDir, "bundle-only", "SKILL.md"), "utf8")).toBe(
bundleOnlySkillContent
);
// Source bundle is copied verbatim into both `.agents/skills/` and
// `.claude/skills/`. No `@`-include wrapping.
for (const root of SKILL_TARGET_ROOTS) {
expect(
await readFile(join(tempDir, root, "skills/write-flow/SKILL.md"), "utf8")
).toBe(sourceSkillContent);
expect(
await readFile(join(tempDir, root, "skills/bundle-only/SKILL.md"), "utf8")
).toBe(bundleOnlySkillContent);
}
});
});
test("builds AGENTS skill references from copied directory names", async () => {
test("AGENTS.cli.md gets the skills reference from copied directory names", async () => {
await withTempDir(async (tempDir) => {
const sourceSkillsDir = join(tempDir, "source-skills");
await writeSkill(
@@ -148,29 +160,393 @@ Copied from source bundle.
await writeAiGuidanceFiles({
targetDir: tempDir,
overwriteProjectGuidance: false,
skillsSourcePath: sourceSkillsDir,
});
const agentsMd = await readFile(join(tempDir, "AGENTS.md"), "utf8");
expect(agentsMd).toContain(".claude/skills/custom-folder/SKILL.md");
expect(agentsMd).not.toContain(".claude/skills/write-flow/SKILL.md");
const agentsCli = await readFile(join(tempDir, "AGENTS.cli.md"), "utf8");
expect(agentsCli).toContain(".agents/skills/custom-folder/SKILL.md");
expect(agentsCli).not.toContain(".agents/skills/write-flow/SKILL.md");
// The skill reference points at the .agents/ tree — not .claude/ —
// so the path is meaningful to Codex/Pi as well as Claude.
expect(agentsCli).not.toContain(".claude/skills/custom-folder/SKILL.md");
});
});
test("writes AGENTS.md and CLAUDE.md even if skills creation fails", async () => {
test("AGENTS.cli.md and CLAUDE.md are written even if skills creation fails", async () => {
await withTempDir(async (tempDir) => {
// Create a file at .claude so mkdir of .claude/skills throws.
await writeFile(join(tempDir, ".claude"), "not a directory\n", "utf8");
await expect(
writeAiGuidanceFiles({
targetDir: tempDir,
overwriteProjectGuidance: false,
})
writeAiGuidanceFiles({ targetDir: tempDir })
).rejects.toThrow();
expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toContain(".claude/skills/");
expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toContain("@AGENTS.md");
expect(await readFile(join(tempDir, "AGENTS.cli.md"), "utf8")).toContain(
".agents/skills/"
);
expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toContain(
"@AGENTS.cli.md"
);
expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toContain(
"@AGENTS.md"
);
});
});
});
describe("writeAiGuidanceFiles — AGENTS.md reconciliation", () => {
test("creates a skeleton AGENTS.md (with @AGENTS.cli.md include) when none exists", async () => {
await withTempDir(async (tempDir) => {
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
expect(result.agentsCreated).toBe(true);
expect(result.agentsMigration).toBe("not-applicable");
const agentsMd = await readFile(join(tempDir, "AGENTS.md"), "utf8");
expect(agentsMd).toContain("@AGENTS.cli.md");
});
});
test("leaves an existing AGENTS.md alone when it already references @AGENTS.cli.md", async () => {
await withTempDir(async (tempDir) => {
const original = "# My AGENTS.md\n\nlocal stuff\n\n@AGENTS.cli.md\n";
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
expect(result.agentsCreated).toBe(false);
expect(result.agentsMigration).toBe("already-linked");
expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toBe(original);
});
});
test("appends @AGENTS.cli.md when the resolver returns 'append'", async () => {
await withTempDir(async (tempDir) => {
const original = "# Existing custom AGENTS.md\n\nproject rules here.\n";
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
const result = await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => "append",
});
expect(result.agentsCreated).toBe(false);
expect(result.agentsMigration).toBe("append");
const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8");
expect(updated).toStartWith(original);
expect(updated).toContain("@AGENTS.cli.md");
});
});
test("overwrites AGENTS.md with the managed skeleton when the resolver returns 'overwrite'", async () => {
await withTempDir(async (tempDir) => {
const original = "# Some old AGENTS.md to be replaced\n";
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
const result = await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => "overwrite",
});
expect(result.agentsCreated).toBe(false);
expect(result.agentsMigration).toBe("overwrite");
const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8");
expect(updated).not.toBe(original);
expect(updated).toContain("@AGENTS.cli.md");
});
});
test("leaves AGENTS.md untouched when the resolver returns 'skip'", async () => {
await withTempDir(async (tempDir) => {
const original = "# Hand-managed AGENTS.md\n";
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
const result = await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => "skip",
});
expect(result.agentsCreated).toBe(false);
expect(result.agentsMigration).toBe("skip");
expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toBe(original);
});
});
test("defaults to 'append' when the resolver is not provided", async () => {
await withTempDir(async (tempDir) => {
const original = "# AGENTS.md\n";
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
expect(result.agentsMigration).toBe("append");
const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8");
expect(updated).toStartWith(original);
expect(updated).toContain("@AGENTS.cli.md");
});
});
});
describe("writeAiGuidanceFiles — CLAUDE.md reconciliation", () => {
test("creates a skeleton CLAUDE.md (with @AGENTS.md include) when none exists", async () => {
await withTempDir(async (tempDir) => {
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
expect(result.claudeCreated).toBe(true);
expect(result.claudeMigration).toBe("not-applicable");
const claudeMd = await readFile(join(tempDir, "CLAUDE.md"), "utf8");
expect(claudeMd).toContain("@AGENTS.md");
});
});
test("leaves an existing CLAUDE.md alone when it already references @AGENTS.md", async () => {
await withTempDir(async (tempDir) => {
const original = "# My CLAUDE.md\n\nlocal stuff\n\n@AGENTS.md\n";
await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8");
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
expect(result.claudeCreated).toBe(false);
expect(result.claudeMigration).toBe("already-linked");
expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toBe(original);
});
});
test("appends @AGENTS.md when the resolver returns 'append'", async () => {
await withTempDir(async (tempDir) => {
const original = "# Existing custom CLAUDE.md\n\nBe helpful.\n";
await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8");
const result = await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => "append",
});
expect(result.claudeCreated).toBe(false);
expect(result.claudeMigration).toBe("append");
const updated = await readFile(join(tempDir, "CLAUDE.md"), "utf8");
expect(updated).toStartWith(original);
expect(updated).toContain("@AGENTS.md");
});
});
test("overwrites CLAUDE.md with the managed skeleton when the resolver returns 'overwrite'", async () => {
await withTempDir(async (tempDir) => {
const original = "# Some old CLAUDE.md\n";
await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8");
const result = await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => "overwrite",
});
expect(result.claudeCreated).toBe(false);
expect(result.claudeMigration).toBe("overwrite");
expect(
await readFile(join(tempDir, "CLAUDE.md"), "utf8")
).not.toBe(original);
expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toContain(
"@AGENTS.md"
);
});
});
test("leaves CLAUDE.md untouched when the resolver returns 'skip'", async () => {
await withTempDir(async (tempDir) => {
const original = "# Hand-managed CLAUDE.md\n";
await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8");
const result = await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => "skip",
});
expect(result.claudeCreated).toBe(false);
expect(result.claudeMigration).toBe("skip");
expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toBe(original);
});
});
test("resolver is invoked at most once even if both files need it", async () => {
await withTempDir(async (tempDir) => {
const original = "# old\n";
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8");
let resolverCalls = 0;
await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => {
resolverCalls += 1;
return "append";
},
});
expect(resolverCalls).toBe(1);
});
});
});
describe("writeAiGuidanceFiles — referencesAgentsCli (via reconciliation)", () => {
test.each([
["bare line", "@AGENTS.cli.md"],
["between blank lines", "before\n\n@AGENTS.cli.md\n\nafter"],
["leading whitespace then include", " @AGENTS.cli.md\n"],
["CRLF line endings", "line one\r\n@AGENTS.cli.md\r\nline three"],
])("treats %s as a reference (no append)", async (_label, content) => {
await withTempDir(async (tempDir) => {
await writeFile(join(tempDir, "AGENTS.md"), content, "utf8");
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
expect(result.agentsMigration).toBe("already-linked");
expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toBe(content);
});
});
test.each([
["@AGENTS.cli.md.backup", "@AGENTS.cli.md.backup"],
["@AGENTS.cli.mdx", "@AGENTS.cli.mdx"],
["@AGENTS-cli-md (lookalike)", "@AGENTS-cli-md"],
["@AGENTS.cli.md without surrounding whitespace", "foo@AGENTS.cli.md"],
["commented-out include", "<!-- @AGENTS.cli.md -->"],
["blockquoted include", "> @AGENTS.cli.md"],
])("does not treat %s as a reference (append happens)", async (_label, content) => {
await withTempDir(async (tempDir) => {
await writeFile(join(tempDir, "AGENTS.md"), content, "utf8");
const result = await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => "append",
});
expect(result.agentsMigration).toBe("append");
});
});
});
describe("prompts freshness — hash marker", () => {
test("AGENTS.cli.md written by writeAiGuidanceFiles carries a hash marker", async () => {
await withTempDir(async (tempDir) => {
await writeAiGuidanceFiles({ targetDir: tempDir });
const agentsCli = await readFile(join(tempDir, "AGENTS.cli.md"), "utf8");
const hash = extractPromptsHash(agentsCli);
expect(hash).not.toBeNull();
expect(hash).toMatch(/^[0-9a-f]{12}$/);
});
});
test("the stored hash matches currentPromptsHash for the same nonDottedPaths", async () => {
await withTempDir(async (tempDir) => {
// writeAiGuidanceFiles defaults nonDottedPaths to `false` (matching
// core/conf.ts's missing-key default).
await writeAiGuidanceFiles({ targetDir: tempDir });
const agentsCli = await readFile(join(tempDir, "AGENTS.cli.md"), "utf8");
expect(extractPromptsHash(agentsCli)).toBe(currentPromptsHash(false));
});
});
test("nonDottedPaths setting changes the hash", () => {
expect(currentPromptsHash(true)).not.toBe(currentPromptsHash(false));
});
test("injectPromptsHashMarker places the marker after the H1 title", () => {
const input = "# Title\n\nbody line\n";
const out = injectPromptsHashMarker(input, "abc123def456");
const lines = out.split("\n");
expect(lines[0]).toBe("# Title");
expect(lines[1]).toBe("<!-- wmill-prompts-hash: abc123def456 -->");
expect(lines[2]).toBe("");
expect(lines[3]).toBe("body line");
});
test("injectPromptsHashMarker prepends when there's no H1", () => {
const input = "no heading\nrest\n";
const out = injectPromptsHashMarker(input, "abc123def456");
expect(out).toStartWith("<!-- wmill-prompts-hash: abc123def456 -->");
});
test("extractPromptsHash returns null when no marker is present", () => {
expect(extractPromptsHash("# Title\n\nno marker here\n")).toBeNull();
expect(extractPromptsHash("<!-- wmill-prompts-hash: tooshort -->")).toBeNull();
});
});
describe("prompts freshness — shouldRunFreshnessCheck", () => {
// Each input matches process.argv shape: [node, script, ...args].
test.each<[string, string[], boolean]>([
["empty argv", ["node", "wmill"], false],
["wmill --help", ["node", "wmill", "--help"], false],
["wmill -h on a subcommand", ["node", "wmill", "init", "-h"], false],
["wmill --version", ["node", "wmill", "--version"], false],
["wmill init", ["node", "wmill", "init"], false],
["wmill init prompts", ["node", "wmill", "init", "prompts"], false],
["wmill refresh prompts", ["node", "wmill", "refresh", "prompts"], false],
["wmill completions zsh", ["node", "wmill", "completions", "zsh"], false],
["wmill upgrade", ["node", "wmill", "upgrade"], false],
["wmill sync push", ["node", "wmill", "sync", "push"], true],
["wmill flow run", ["node", "wmill", "flow", "run"], true],
["wmill --verbose sync push", ["node", "wmill", "--verbose", "sync", "push"], true],
// Value-taking global options must skip their value when locating the
// first subcommand. Otherwise `wmill --workspace prod refresh prompts`
// would misread `"prod"` as the subcommand and trip the warning during
// the very command that's meant to fix it.
["wmill --workspace prod refresh prompts",
["node", "wmill", "--workspace", "prod", "refresh", "prompts"], false],
["wmill --token tok sync push",
["node", "wmill", "--token", "tok", "sync", "push"], true],
["wmill --base-url u --workspace w init",
["node", "wmill", "--base-url", "u", "--workspace", "w", "init"], false],
["wmill --config-dir /etc/wmill init prompts",
["node", "wmill", "--config-dir", "/etc/wmill", "init", "prompts"], false],
])("%s → %s", (_label, argv, expected) => {
expect(shouldRunFreshnessCheck(argv)).toBe(expected);
});
});
describe("prompts freshness — additional invariants", () => {
test("currentPromptsHash is deterministic across invocations in the same process", () => {
const h1 = currentPromptsHash(true);
const h2 = currentPromptsHash(true);
const h3 = currentPromptsHash(false);
const h4 = currentPromptsHash(false);
expect(h1).toBe(h2);
expect(h3).toBe(h4);
});
test("warnIfPromptsStale writes to stderr (never stdout)", async () => {
await withTempDir(async (tempDir) => {
// Write a tampered AGENTS.cli.md so the freshness check trips.
await writeFile(
join(tempDir, "AGENTS.cli.md"),
"# Windmill CLI Agent Instructions\n<!-- wmill-prompts-hash: 000000000000 -->\nbody\n",
"utf8"
);
const stdoutWrites: string[] = [];
const stderrWrites: string[] = [];
const originalStdout = process.stdout.write.bind(process.stdout);
const originalStderr = process.stderr.write.bind(process.stderr);
// @ts-expect-error — overriding write for the test
process.stdout.write = (chunk: any) => {
stdoutWrites.push(String(chunk));
return true;
};
// @ts-expect-error — overriding write for the test
process.stderr.write = (chunk: any) => {
stderrWrites.push(String(chunk));
return true;
};
try {
await warnIfPromptsStale({
cwd: tempDir,
nonDottedPaths: false,
argv: ["node", "wmill", "sync", "push"],
});
} finally {
process.stdout.write = originalStdout;
process.stderr.write = originalStderr;
}
const stderrJoined = stderrWrites.join("");
const stdoutJoined = stdoutWrites.join("");
expect(stderrJoined).toContain("out of date");
expect(stdoutJoined).not.toContain("out of date");
});
});
});
+8
View File
@@ -51,6 +51,14 @@ COPY dap_websocket_server.py .
# Expose the default port
EXPOSE 5679
# Create a non-root user 'windmill' with UID and GID 1000 (mirrors main Windmill image)
RUN addgroup --gid 1000 windmill && \
adduser --disabled-password --gecos "" --uid 1000 --gid 1000 windmill
# Ensure cache and work directories are writable by any UID
RUN mkdir -p /tmp/windmill/cache /tmp/windmill/cache_nomount /tmp/.cache && \
chmod -R 777 /tmp/windmill /tmp/.cache /app
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:5679/health || exit 1
+9 -4
View File
@@ -5,8 +5,9 @@ FROM debian:bookworm-slim AS nsjail
WORKDIR /nsjail
RUN apt-get -y update \
&& apt-get install -y \
&& apt-get install -y --no-install-recommends \
bison=2:3.8.* \
ca-certificates \
flex=2.6.* \
g++=4:12.2.* \
gcc=4:12.2.* \
@@ -15,7 +16,9 @@ RUN apt-get -y update \
libnl-route-3-dev=3.7.* \
make=4.3-4.1 \
pkg-config=1.8.* \
protobuf-compiler=3.21.*
protobuf-compiler=3.21.* \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800
RUN make
@@ -36,7 +39,8 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
# Install system dependencies
RUN apt-get update \
&& apt-get install -y ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Install latest PostgreSQL client (pg_dump) from official PostgreSQL apt repository
@@ -78,7 +82,8 @@ RUN curl -fsSL https://claude.ai/install.sh | bash \
COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/
# nsjail runtime deps and binary
RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \
RUN apt-get update \
&& apt-get install -y --no-install-recommends libprotobuf-dev libnl-route-3-dev \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
+9 -4
View File
@@ -5,8 +5,9 @@ FROM debian:bookworm-slim AS nsjail
WORKDIR /nsjail
RUN apt-get -y update \
&& apt-get install -y \
&& apt-get install -y --no-install-recommends \
bison=2:3.8.* \
ca-certificates \
flex=2.6.* \
g++=4:12.2.* \
gcc=4:12.2.* \
@@ -15,7 +16,9 @@ RUN apt-get -y update \
libnl-route-3-dev=3.7.* \
make=4.3-4.1 \
pkg-config=1.8.* \
protobuf-compiler=3.21.*
protobuf-compiler=3.21.* \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800
RUN make
@@ -36,7 +39,8 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
# Install system dependencies
RUN apt-get update \
&& apt-get install -y ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Install latest PostgreSQL client (pg_dump) from official PostgreSQL apt repository
@@ -78,7 +82,8 @@ RUN curl -fsSL https://claude.ai/install.sh | bash \
COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/
# nsjail runtime deps and binary
RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \
RUN apt-get update \
&& apt-get install -y --no-install-recommends libprotobuf-dev libnl-route-3-dev \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.705.0",
"version": "1.706.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.705.0",
"version": "1.706.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.705.0",
"version": "1.706.0",
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -72,6 +72,20 @@
return false
}
}
// Hide the nsjail-only settings only when isolation is *explicitly* a
// non-nsjail mode. When `job_isolation` is unset, nsjail may still be
// enabled via the legacy env-driven path (`DISABLE_NSJAIL=false`), so
// keep the controls reachable.
if (setting == 'nsjail_tmp_backing' || setting == 'nsjail_tmpfs_size_mb') {
const isolation = values['job_isolation']
if (isolation === 'none' || isolation === 'unshare') {
return false
}
}
// The tmpfs size knob is meaningless when /tmp is disk-backed.
if (setting == 'nsjail_tmpfs_size_mb' && values['nsjail_tmp_backing'] === 'disk') {
return false
}
return true
}
@@ -4,7 +4,10 @@
import AvailableContextList from './AvailableContextList.svelte'
import { type Snippet } from 'svelte'
import {
AlertTriangle,
ArrowDown,
ChevronDown,
ChevronsRight,
CheckIcon,
HistoryIcon,
Hourglass,
@@ -23,16 +26,43 @@
import ProviderModelSelector from './ProviderModelSelector.svelte'
import ChatMode from './ChatMode.svelte'
import DatatableCreationPolicy from './DatatableCreationPolicy.svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import Markdown from 'svelte-exmarkdown'
import { twMerge } from 'tailwind-merge'
import { AIMode } from './AIChatManager.svelte'
import { AIAutonomyMode, AIMode } from './AIChatManager.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import ChatTypingIndicator from './ChatTypingIndicator.svelte'
import AIChatInput from './AIChatInput.svelte'
import { getModifierKey } from '$lib/utils'
import type { SelectedContext } from './app/core'
const MAX_YOLO_TOOLTIP_TOOLS = 8
const aiChatManager = getAiChatManager()
type AutonomyModeOption = { label: string; mode: AIAutonomyMode }
const autonomyModeOptions: AutonomyModeOption[] = [
{ label: 'auto accept off', mode: AIAutonomyMode.DEFAULT },
{ label: 'auto accept on', mode: AIAutonomyMode.ACCEPT_EDIT },
{ label: 'yolo on', mode: AIAutonomyMode.YOLO }
]
const autonomyModeLabel = (
mode: AIAutonomyMode,
options: AutonomyModeOption[] = autonomyModeOptions
) => options.find((option) => option.mode === mode)?.label ?? autonomyModeOptions[0].label
const isAutonomyModeAvailable = (
mode: AIAutonomyMode,
autoAcceptEditsAvailable: boolean,
autoAcceptToolConfirmationsAvailable: boolean
) => {
switch (mode) {
case AIAutonomyMode.DEFAULT:
return true
case AIAutonomyMode.ACCEPT_EDIT:
return autoAcceptEditsAvailable
case AIAutonomyMode.YOLO:
return autoAcceptToolConfirmationsAvailable
}
return false
}
let {
messages,
@@ -179,6 +209,37 @@
aiChatManager.mode === AIMode.GLOBAL ||
aiChatManager.mode === AIMode.APP
)
const availableAutonomyModeOptions = $derived.by(() =>
autonomyModeOptions.filter((option) =>
isAutonomyModeAvailable(
option.mode,
aiChatManager.autoAcceptEditsAvailable,
aiChatManager.autoAcceptToolConfirmationsAvailable
)
)
)
const effectiveAutonomyMode = $derived(
availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode)
? aiChatManager.autonomyMode
: AIAutonomyMode.DEFAULT
)
const showAutonomyModeSelector = $derived(!disabled && availableAutonomyModeOptions.length > 1)
const autonomyModeTooltip = $derived.by(() => {
switch (effectiveAutonomyMode) {
case AIAutonomyMode.ACCEPT_EDIT:
return 'Automatically accepts script and flow edits. Tool calls still ask for confirmation.'
case AIAutonomyMode.YOLO:
if (!aiChatManager.autoAcceptEditsAvailable) {
return 'Automatically accepts tool confirmations.'
}
return 'Automatically accepts script and flow edits plus tool confirmations.'
default:
if (!aiChatManager.autoAcceptEditsAvailable) {
return 'Requires confirmation for tool calls.'
}
return 'Requires confirmation for edits and tool calls.'
}
})
// "Waiting for user" detection — when the latest tool message is staged
// for confirmation or has an unanswered askUserQuestion, the AI loop is
@@ -209,6 +270,29 @@
}
return aiChatManager.appAiChatHelpers.getSelectedContext()
})
const yoloBypassedTools = $derived.by(() => {
return aiChatManager.tools
.filter((tool) => tool.requiresConfirmation === true)
.map((tool) => ({
name: tool.def.function.name,
label: tool.confirmationMessage ?? tool.def.function.name
}))
})
const visibleYoloBypassedTools = $derived(yoloBypassedTools.slice(0, MAX_YOLO_TOOLTIP_TOOLS))
const hiddenYoloBypassedToolCount = $derived(
Math.max(0, yoloBypassedTools.length - visibleYoloBypassedTools.length)
)
const showFlowPendingActionControls = $derived(
(aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false) &&
!aiChatManager.autoAcceptEditsActive
)
const showFooterLeftControls = $derived(
!disabled &&
(showContextPicker ||
showAutonomyModeSelector ||
(aiChatManager.mode === AIMode.SCRIPT && hasDiff))
)
</script>
<div class="flex flex-col h-full">
@@ -322,7 +406,7 @@
<div
class={twMerge(
'sticky z-10 mt-2 ml-2 self-start pointer-events-none',
aiChatManager.flowAiChatHelpers?.hasPendingChanges() ? 'bottom-14' : 'bottom-2'
showFlowPendingActionControls ? 'bottom-14' : 'bottom-2'
)}
>
{#if waitingForUserAction}
@@ -345,7 +429,7 @@
transition:fade={{ duration: 120 }}
class={twMerge(
'absolute left-1/2 -translate-x-1/2 z-10 rounded-md bg-surface shadow-md',
aiChatManager.flowAiChatHelpers?.hasPendingChanges() ? 'bottom-12' : 'bottom-2'
showFlowPendingActionControls ? 'bottom-12' : 'bottom-2'
)}
>
<Button
@@ -370,7 +454,7 @@
? 'relative w-full max-w-3xl mx-auto px-6 pb-2'
: 'relative w-full max-w-2xl mx-auto px-2 pb-2'}
>
{#if aiChatManager.flowAiChatHelpers?.hasPendingChanges()}
{#if showFlowPendingActionControls}
<div class="absolute -top-10 w-full flex flex-row justify-center gap-2">
<Button
startIcon={{ icon: CheckIcon }}
@@ -409,49 +493,136 @@
{disabled}
isFirstMessage={messages.length === 0}
/>
<div class="flex flex-row justify-between items-center gap-x-1.5">
<div class="flex flex-row items-center gap-x-1.5">
{#if showContextPicker && !disabled}
<Popover>
{#snippet trigger()}
<div
class="text-primary text-xs flex flex-row items-center font-normal border px-1 rounded-lg hover:bg-surface-hover bg-surface"
title="Add context"
>
@
</div>
{/snippet}
{#snippet content({ close })}
{#if aiChatManager.mode === AIMode.APP}
<AppAvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
void aiChatInput?.addContextToSelection(element)
close()
}}
/>
{:else}
<AvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
void aiChatInput?.addContextToSelection(element)
close()
}}
onSelectWorkspaceItem={(element) => {
void aiChatInput?.addContextToSelection(element)
close()
}}
/>
{/if}
{/snippet}
</Popover>
{/if}
{#if aiChatManager.mode === 'script' && hasDiff}
<ChatQuickActions {askAi} {diffMode} />
{/if}
</div>
<div
class="flex flex-row items-center gap-x-1.5"
class:justify-between={showFooterLeftControls}
class:justify-end={!showFooterLeftControls}
>
{#if showFooterLeftControls}
<div class="flex flex-row items-center gap-x-1.5 min-w-0 flex-wrap">
{#if showContextPicker && !disabled}
<Popover>
{#snippet trigger()}
<div
class="text-primary text-xs flex flex-row items-center font-normal border px-1 rounded-lg hover:bg-surface-hover bg-surface"
title="Add context"
>
@
</div>
{/snippet}
{#snippet content({ close })}
{#if aiChatManager.mode === AIMode.APP}
<AppAvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
void aiChatInput?.addContextToSelection(element)
close()
}}
/>
{:else}
<AvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
void aiChatInput?.addContextToSelection(element)
close()
}}
onSelectWorkspaceItem={(element) => {
void aiChatInput?.addContextToSelection(element)
close()
}}
/>
{/if}
{/snippet}
</Popover>
{/if}
{#if showAutonomyModeSelector}
<div class="min-w-0">
<Popover class="max-w-full">
{#snippet trigger()}
<div
class="text-primary text-xs flex flex-row items-center font-normal gap-0.5 border px-1 rounded-lg"
title={autonomyModeTooltip}
>
<ChevronsRight
size={13}
class={twMerge(
'shrink-0',
effectiveAutonomyMode === AIAutonomyMode.YOLO
? 'text-red-500'
: 'text-accent'
)}
/>
<span class="truncate"
>{autonomyModeLabel(
effectiveAutonomyMode,
availableAutonomyModeOptions
)}</span
>
<div class="shrink-0">
<ChevronDown size={16} />
</div>
</div>
{/snippet}
{#snippet content({ close })}
<div class="flex flex-col gap-1 p-1 min-w-32">
{#each availableAutonomyModeOptions as option (option.mode)}
<button
class={twMerge(
'text-left text-xs hover:bg-surface-hover rounded-md p-1 font-normal',
effectiveAutonomyMode === option.mode && 'bg-surface-hover'
)}
onclick={() => {
aiChatManager.setAutonomyMode(option.mode)
close()
}}
>
{option.label}
</button>
{/each}
</div>
{/snippet}
</Popover>
</div>
{/if}
{#if effectiveAutonomyMode === AIAutonomyMode.YOLO && aiChatManager.autoAcceptToolConfirmationsAvailable}
<Tooltip small placement="top">
<AlertTriangle class="w-3 h-3 text-red-500" />
{#snippet text()}
<div class="max-w-64 text-xs">
<p class="font-semibold">
{aiChatManager.autoAcceptEditsAvailable
? 'Yolo auto-accepts edits and tool usage.'
: 'Yolo auto-accepts tool usage.'}
</p>
<p class="mt-1">
{aiChatManager.autoAcceptEditsAvailable
? 'This can result in edits being applied or tools being called without user confirmation.'
: 'This can result in tools being called without user confirmation.'}
</p>
{#if yoloBypassedTools.length > 0}
<p class="mt-2 font-semibold">Bypassed in current mode:</p>
<ul class="mt-1 list-disc pl-4 space-y-0.5">
{#each visibleYoloBypassedTools as tool (tool.name)}
<li class="break-words">{tool.label}</li>
{/each}
</ul>
{#if hiddenYoloBypassedToolCount > 0}
<p class="mt-1">+ {hiddenYoloBypassedToolCount} more</p>
{/if}
{:else}
<p class="mt-2">No tools in the current mode require confirmation.</p>
{/if}
</div>
{/snippet}
</Tooltip>
{/if}
{#if aiChatManager.mode === AIMode.SCRIPT && hasDiff}
<ChatQuickActions {askAi} {diffMode} />
{/if}
</div>
{/if}
{#if disabled}
<div class="text-primary text-xs my-2 px-2">
<Markdown md={disabledMessage} />
@@ -213,7 +213,7 @@
try {
const reply = await aiChatManager.sendInlineRequest(instructions, selectedCode, selection)
if (reply) {
aiChatManager.scriptEditorApplyCode?.(reply)
await aiChatManager.applyScriptEditorCode(reply)
}
} catch (error) {
console.error('Inline AI request failed:', error)
@@ -43,6 +43,7 @@ import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState
import type { CurrentEditor, ExtendedOpenFlow } from '$lib/components/flows/types'
import { untrack } from 'svelte'
import { get } from 'svelte/store'
import { BROWSER } from 'esm-env'
import { workspaceStore, type DBSchemas } from '$lib/stores'
import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core'
import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte'
@@ -66,6 +67,8 @@ import { isGlobalAiEnabled } from './global/gate'
// If the estimated token usage is greater than the model context window - the threshold, we delete the oldest message
const MAX_TOKENS_THRESHOLD_PERCENTAGE = 0.05
const MAX_TOKENS_HARD_LIMIT = 5000
const AI_AUTONOMY_MODE_STORAGE_KEY = 'ai-chat-autonomy-mode'
const LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY = 'ai-chat-yolo-mode'
export enum AIMode {
SCRIPT = 'script',
@@ -77,12 +80,38 @@ export enum AIMode {
ASK = 'ask'
}
export enum AIAutonomyMode {
DEFAULT = 'default',
ACCEPT_EDIT = 'acceptedit',
YOLO = 'yolo'
}
const ALL_AI_MODES = Object.values(AIMode)
const ALL_AI_AUTONOMY_MODES = Object.values(AIAutonomyMode)
const AUTO_ACCEPT_EDIT_MODES = new Set<AIMode>([AIMode.SCRIPT, AIMode.FLOW])
const AUTO_ACCEPT_TOOL_CONFIRMATION_MODES = new Set<AIMode>([
AIMode.SCRIPT,
AIMode.FLOW,
AIMode.APP,
AIMode.GLOBAL
])
export function isAIMode(mode: unknown): mode is AIMode {
return ALL_AI_MODES.includes(mode as AIMode)
}
export function isAIAutonomyMode(mode: unknown): mode is AIAutonomyMode {
return ALL_AI_AUTONOMY_MODES.includes(mode as AIAutonomyMode)
}
export function supportsAutoAcceptEdits(mode: AIMode): boolean {
return AUTO_ACCEPT_EDIT_MODES.has(mode)
}
export function supportsAutoAcceptToolConfirmations(mode: AIMode): boolean {
return AUTO_ACCEPT_TOOL_CONFIRMATION_MODES.has(mode)
}
export function isAIModeVisible(mode: AIMode): boolean {
return mode !== AIMode.GLOBAL || isGlobalAiEnabled()
}
@@ -95,6 +124,26 @@ function isWorkspacePath(path: string | undefined): path is string {
return path?.startsWith('f/') === true || path?.startsWith('u/') === true
}
function getPersistedAutonomyMode(): AIAutonomyMode {
if (!BROWSER || typeof localStorage === 'undefined') {
return AIAutonomyMode.DEFAULT
}
const persistedMode = localStorage.getItem(AI_AUTONOMY_MODE_STORAGE_KEY)
if (isAIAutonomyMode(persistedMode)) {
return persistedMode
}
return localStorage.getItem(LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY) === 'true'
? AIAutonomyMode.YOLO
: AIAutonomyMode.DEFAULT
}
function persistAutonomyMode(mode: AIAutonomyMode) {
if (!BROWSER || typeof localStorage === 'undefined') {
return
}
localStorage.setItem(AI_AUTONOMY_MODE_STORAGE_KEY, mode)
}
export class AIChatManager {
contextManager = new ContextManager()
historyManager = new HistoryManager()
@@ -112,6 +161,17 @@ export class AIChatManager {
currentReply = $state<string>('')
displayMessages = $state<DisplayMessage[]>([])
messages = $state<ChatCompletionMessageParam[]>([])
autonomyMode = $state<AIAutonomyMode>(getPersistedAutonomyMode())
autoAcceptEditsAvailable = $derived(supportsAutoAcceptEdits(this.mode))
autoAcceptEditsActive = $derived(
this.autoAcceptEditsAvailable &&
(this.autonomyMode === AIAutonomyMode.ACCEPT_EDIT ||
this.autonomyMode === AIAutonomyMode.YOLO)
)
autoAcceptToolConfirmationsAvailable = $derived(supportsAutoAcceptToolConfirmations(this.mode))
autoAcceptToolConfirmationsActive = $derived(
this.autonomyMode === AIAutonomyMode.YOLO && this.autoAcceptToolConfirmationsAvailable
)
#automaticScroll = $state<boolean>(true)
systemMessage = $state<ChatCompletionSystemMessageParam>({
role: 'system',
@@ -122,9 +182,9 @@ export class AIChatManager {
scriptEditorOptions = $state<ScriptOptions | undefined>(undefined)
flowOptions = $state<FlowOptions | undefined>(undefined)
scriptEditorApplyCode = $state<((code: string, opts?: ReviewChangesOpts) => void) | undefined>(
undefined
)
scriptEditorApplyCode = $state<
((code: string, opts?: ReviewChangesOpts) => void | Promise<void>) | undefined
>(undefined)
scriptEditorShowDiffMode = $state<(() => void) | undefined>(undefined)
scriptEditorGetLintErrors = $state<(() => ScriptLintResult) | undefined>(undefined)
flowAiChatHelpers = $state<FlowAIChatHelpers | undefined>(undefined)
@@ -141,7 +201,7 @@ export class AIChatManager {
/** Cached datatables for app context (fetched asynchronously) */
cachedDatatables = $state<AppDatatableElement[]>([])
private confirmationCallback = $state<((value: boolean) => void) | undefined>(undefined)
private confirmationCallbacks = new Map<string, (value: boolean) => void>()
private userQuestionCallbacks = new Map<string, (choice: string | undefined) => void>()
private appDatatablesRefreshTimeout: ReturnType<typeof setTimeout> | undefined = undefined
@@ -215,20 +275,65 @@ export class AIChatManager {
// Request confirmation from user for a tool call
requestConfirmation = (toolId: string): Promise<boolean> => {
if (this.autoAcceptToolConfirmationsActive) {
return Promise.resolve(true)
}
return new Promise((resolve) => {
// Store the callback for this specific tool
this.confirmationCallback = resolve
this.confirmationCallbacks.set(toolId, resolve)
})
}
// Handle confirmation response for a specific tool
handleToolConfirmation = (toolId: string, confirmed: boolean) => {
if (this.confirmationCallback) {
this.confirmationCallback(confirmed)
this.confirmationCallback = undefined
const confirmationCallback = this.confirmationCallbacks.get(toolId)
if (confirmationCallback) {
confirmationCallback(confirmed)
this.confirmationCallbacks.delete(toolId)
}
}
private acceptPendingToolConfirmations = () => {
for (const confirmationCallback of this.confirmationCallbacks.values()) {
confirmationCallback(true)
}
this.confirmationCallbacks.clear()
}
private acceptPendingFlowEdits = (flowHelpers = this.flowAiChatHelpers) => {
if (flowHelpers?.hasPendingChanges()) {
flowHelpers.acceptAllModuleActions()
}
}
setAutonomyMode = (mode: AIAutonomyMode) => {
this.autonomyMode = mode
persistAutonomyMode(mode)
if (this.autoAcceptToolConfirmationsActive) {
this.acceptPendingToolConfirmations()
}
if (this.autoAcceptEditsActive) {
this.acceptPendingFlowEdits()
}
}
setAutoAcceptToolConfirmations = (enabled: boolean) => {
this.setAutonomyMode(enabled ? AIAutonomyMode.YOLO : AIAutonomyMode.DEFAULT)
}
applyScriptEditorCode = async (code: string, opts?: ReviewChangesOpts) => {
if (this.autoAcceptEditsActive && opts?.mode === 'revert') {
return
}
const effectiveOpts =
this.autoAcceptEditsActive && (opts?.mode ?? 'apply') === 'apply'
? ({ ...opts, mode: 'apply', applyAll: true } satisfies ReviewChangesOpts)
: opts
await this.scriptEditorApplyCode?.(code, effectiveOpts)
}
requestUserQuestion = (
toolId: string,
_question: { question: string; choices: string[] }
@@ -346,7 +451,7 @@ export class AIChatManager {
},
getWorkspaceMutationTarget: this.getScriptWorkspaceMutationTarget,
applyCode: (code: string, opts?: ReviewChangesOpts) => {
this.scriptEditorApplyCode?.(code, opts)
return this.applyScriptEditorCode(code, opts)
},
getLintErrors: () => {
if (this.scriptEditorGetLintErrors) {
@@ -874,6 +979,7 @@ export class AIChatManager {
}
},
requestConfirmation: this.requestConfirmation,
shouldAutoAcceptToolConfirmations: () => this.autoAcceptToolConfirmationsActive,
requestUserQuestion: this.requestUserQuestion
}
}
@@ -886,6 +992,9 @@ export class AIChatManager {
...params
})
this.messages = [...this.messages, ...(addedMessages ?? [])]
if (this.autoAcceptEditsActive) {
this.acceptPendingFlowEdits()
}
await this.historyManager.saveChat(this.displayMessages, this.messages)
} catch (err) {
console.error(err)
@@ -901,10 +1010,10 @@ export class AIChatManager {
}
cancel = (reason?: string) => {
if (this.confirmationCallback) {
this.confirmationCallback(false)
this.confirmationCallback = undefined
for (const confirmationCallback of this.confirmationCallbacks.values()) {
confirmationCallback(false)
}
this.confirmationCallbacks.clear()
for (const resolveQuestion of this.userQuestionCallbacks.values()) {
resolveQuestion(undefined)
}
@@ -1060,10 +1169,10 @@ export class AIChatManager {
listenForCurrentEditorChanges = (currentEditor: CurrentEditor) => {
if (currentEditor && currentEditor.type === 'script') {
this.scriptEditorApplyCode = (code) => {
this.scriptEditorApplyCode = async (code, opts) => {
if (currentEditor && currentEditor.type === 'script') {
currentEditor.hideDiffMode()
currentEditor.editor.reviewAndApplyCode(code)
await currentEditor.editor.reviewAndApplyCode(code, opts)
}
}
this.scriptEditorShowDiffMode = () => {
@@ -1164,6 +1273,11 @@ export class AIChatManager {
setFlowHelpers = (flowHelpers: FlowAIChatHelpers) => {
this.flowAiChatHelpers = flowHelpers
untrack(() => {
if (this.autoAcceptEditsActive) {
this.acceptPendingFlowEdits(flowHelpers)
}
})
return () => {
this.flowAiChatHelpers = undefined
@@ -0,0 +1,155 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FlowAIChatHelpers } from './flow/core'
import type { CurrentEditor } from '$lib/components/flows/types'
import type { ReviewChangesOpts } from './monaco-adapter'
import { AIChatManager, AIMode, AIAutonomyMode } from './AIChatManager.svelte'
vi.mock('monaco-editor', () => ({
Selection: class Selection {}
}))
vi.mock('$lib/gen', () => ({
WorkspaceService: {},
ScriptService: {},
FlowService: {},
JobService: {}
}))
vi.mock('$lib/stores', () => ({
workspaceStore: { subscribe: () => () => undefined }
}))
vi.mock('$lib/toast', () => ({
sendUserToast: vi.fn()
}))
vi.mock('$lib/aiStore', () => ({
getCurrentModel: () => undefined,
tryGetCurrentModel: () => undefined,
getCombinedCustomPrompt: () => ''
}))
vi.mock('../lib', () => ({
getModelContextWindow: () => 128000,
workspaceAIClients: { subscribe: () => () => undefined }
}))
vi.mock('./api/apiTools', () => ({
loadApiTools: vi.fn()
}))
vi.mock('./chatLoop', () => ({
runChatLoop: vi.fn()
}))
vi.mock('./global/gate', () => ({
isGlobalAiEnabled: () => true
}))
function createFlowHelpers({
hasPendingChanges,
acceptAllModuleActions
}: {
hasPendingChanges: () => boolean
acceptAllModuleActions: () => void
}): FlowAIChatHelpers {
return {
getFlowAndSelectedId: vi.fn(),
getRootModules: vi.fn(),
inlineScriptSession: { get: vi.fn(), set: vi.fn(), clear: vi.fn() },
setSnapshot: vi.fn(),
revertToSnapshot: vi.fn(),
setCode: vi.fn(),
setFlowJson: vi.fn(),
getFlowInputsSchema: vi.fn(),
updateExprsToSet: vi.fn(),
acceptAllModuleActions,
rejectAllModuleActions: vi.fn(),
hasPendingChanges,
selectStep: vi.fn(),
testFlow: vi.fn(),
getLintErrors: vi.fn()
} as unknown as FlowAIChatHelpers
}
describe('AIChatManager autonomy mode', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
})
it('accepts pending flow edits when auto-accept is enabled from script mode', async () => {
const manager = new AIChatManager()
const acceptAllModuleActions = vi.fn()
manager.mode = AIMode.SCRIPT
manager.setFlowHelpers(
createFlowHelpers({
hasPendingChanges: () => true,
acceptAllModuleActions
})
)
manager.setAutonomyMode(AIAutonomyMode.ACCEPT_EDIT)
expect(acceptAllModuleActions).toHaveBeenCalledTimes(1)
})
it('accepts pending flow edits when helpers register while auto-accept is already enabled', async () => {
const manager = new AIChatManager()
const acceptAllModuleActions = vi.fn()
manager.mode = AIMode.SCRIPT
manager.setAutonomyMode(AIAutonomyMode.ACCEPT_EDIT)
manager.setFlowHelpers(
createFlowHelpers({
hasPendingChanges: () => true,
acceptAllModuleActions
})
)
expect(acceptAllModuleActions).toHaveBeenCalledTimes(1)
})
it('waits for flow step editor review before resolving applyScriptEditorCode', async () => {
const manager = new AIChatManager()
let finishReview: (() => void) | undefined
const reviewPromise = new Promise<void>((resolve) => {
finishReview = resolve
})
const hideDiffMode = vi.fn()
const reviewAndApplyCode = vi.fn(() => reviewPromise)
const opts = { mode: 'apply' } satisfies ReviewChangesOpts
manager.listenForCurrentEditorChanges({
type: 'script',
stepId: 'step-a',
editor: {
reviewAndApplyCode,
getLintErrors: vi.fn()
},
showDiffMode: vi.fn(),
hideDiffMode,
diffMode: false,
lastDeployedCode: undefined
} as unknown as CurrentEditor)
let applied = false
const applyPromise = manager
.applyScriptEditorCode('export async function main() {}', opts)
.then(() => {
applied = true
})
await Promise.resolve()
expect(hideDiffMode).toHaveBeenCalledTimes(1)
expect(reviewAndApplyCode).toHaveBeenCalledWith('export async function main() {}', opts)
expect(applied).toBe(false)
finishReview?.()
await applyPromise
expect(applied).toBe(true)
})
})
@@ -1,6 +1,6 @@
<script lang="ts">
import FlowModuleSchemaMap from '$lib/components/flows/map/FlowModuleSchemaMap.svelte'
import { getContext, untrack } from 'svelte'
import { getContext, tick, untrack } from 'svelte'
import type { ExtendedOpenFlow, FlowEditorContext } from '$lib/components/flows/types'
import type { InputTransform } from '$lib/gen'
import type { FlowAIChatHelpers } from './core'
@@ -30,6 +30,22 @@
// Get diffManager from the graph
const diffManager = $derived(flowModuleSchemaMap?.getDiffManager())
async function acceptPendingFlowEditsIfEnabled(waitForComputedDiff = false) {
if (!aiChatManager.autoAcceptEditsActive || !diffManager) {
return
}
diffManager.setCurrentFlow(flowStore.val.value)
diffManager.setCurrentInputSchema(flowStore.val.schema)
if (waitForComputedDiff) {
await tick()
}
if (diffManager.hasPendingChanges) {
diffManager.acceptAll(flowStore)
await tick()
}
}
const flowHelpers: FlowAIChatHelpers = {
// flow context
getFlowAndSelectedId: () => {
@@ -114,6 +130,7 @@
if ($currentEditor && $currentEditor.type === 'script' && $currentEditor.stepId === id) {
$currentEditor.editor.setCode(code)
}
await acceptPendingFlowEditsIfEnabled()
},
getFlowInputsSchema: async () => {
return flowStore.val.schema ?? {}
@@ -204,6 +221,7 @@
// Refresh the state store to update UI
refreshStateStore(flowStore)
await acceptPendingFlowEditsIfEnabled(true)
return result
} catch (error) {
throw new Error(
@@ -215,6 +233,7 @@
$effect(() => {
if (
!aiChatManager.autoAcceptEditsActive &&
$currentEditor?.type === 'script' &&
selectedId &&
diffManager?.moduleActions[selectedId]?.pending &&
@@ -99,7 +99,7 @@
function handleApplyCode() {
if (code && aiChatManager.scriptEditorApplyCode) {
aiChatManager.scriptEditorApplyCode(code, { mode: 'apply' })
void aiChatManager.applyScriptEditorCode(code, { mode: 'apply' })
}
}
</script>
@@ -247,6 +247,49 @@ describe('processToolCall', () => {
expect(result.content).toBe('ok')
})
it('auto-accepts required confirmations when yolo mode is active', async () => {
const { createToolDef, processToolCall } = await import('./shared')
const fn = vi.fn().mockResolvedValue('ok')
const requestConfirmation = vi.fn()
const setToolStatus = vi.fn()
const result = await processToolCall({
tools: [
{
def: createToolDef(z.object({}), 'create_schedule', 'Create schedule'),
requiresConfirmation: true,
confirmationMessage: 'Create schedule',
fn
}
],
toolCall: {
id: 'call_yolo',
type: 'function',
function: { name: 'create_schedule', arguments: '{}' }
},
helpers: {},
workspace: 'test-workspace',
toolCallbacks: {
setToolStatus,
removeToolStatus: vi.fn(),
requestConfirmation,
shouldAutoAcceptToolConfirmations: () => true
}
})
expect(requestConfirmation).not.toHaveBeenCalled()
expect(fn).toHaveBeenCalled()
expect(setToolStatus).toHaveBeenCalledWith(
'call_yolo',
expect.objectContaining({
content: 'Create schedule',
isLoading: true,
needsConfirmation: false
})
)
expect(result.content).toBe('ok')
})
it('blocks workspace mutation tools for undeployed scripts and flows', async () => {
const { processToolCall } = await import('./shared')
const { createWorkspaceMutationTools } = await import('./workspaceTools')
@@ -582,10 +582,13 @@ export async function processToolCall<T>({
}
// Check if tool requires confirmation
const needsConfirmation = tool?.requiresConfirmation
const requiresConfirmation = tool?.requiresConfirmation === true
const autoAcceptConfirmation =
requiresConfirmation && toolCallbacks.shouldAutoAcceptToolConfirmations?.() === true
const needsConfirmation = requiresConfirmation && !autoAcceptConfirmation
toolCallbacks.setToolStatus(toolCall.id, {
...(tool?.requiresConfirmation
...(requiresConfirmation
? { content: tool.confirmationMessage ?? 'Waiting for confirmation...' }
: {}),
parameters: args,
@@ -695,6 +698,7 @@ export interface ToolCallbacks {
setToolStatus: (id: string, metadata?: Partial<ToolDisplayMessage>) => void
removeToolStatus: (id: string) => void
requestConfirmation?: (toolId: string) => Promise<boolean>
shouldAutoAcceptToolConfirmations?: () => boolean
requestUserQuestion?: (
toolId: string,
question: UserQuestionDisplay
@@ -245,11 +245,25 @@ export const settings: Record<string, Setting[]> = {
}
]
},
{
label: 'Nsjail /tmp backing',
key: 'nsjail_tmp_backing',
fieldType: 'select',
description:
'How <code>/tmp</code> is backed inside the nsjail sandbox. <strong>RAM (tmpfs)</strong> is the default — fast, with a hard size cap from <em>Nsjail tmpfs size</em>, but consumes worker memory. <strong>Disk (bind mount)</strong> uses a per-job directory on the worker disk — no RAM cost, but the only remaining per-file ceiling is <code>rlimit_fsize</code> (~1GB for python/ansible, unbounded for most other languages because they set <code>disable_rl: true</code>); pair with host disk monitoring or quotas.',
storage: 'setting',
placeholder: 'tmpfs',
defaultValue: () => 'tmpfs',
select_items: [
{ label: 'RAM (tmpfs) — default', value: 'tmpfs' },
{ label: 'Disk (bind mount)', value: 'disk' }
]
},
{
label: 'Nsjail tmpfs size (MB)',
key: 'nsjail_tmpfs_size_mb',
description:
'Override the size of the <code>/tmp</code> tmpfs mount inside the nsjail sandbox (in MB). When left empty, defaults to 800MB. Only applies when the job isolation mode is set to Nsjail.',
'Override the size of the <code>/tmp</code> tmpfs mount inside the nsjail sandbox (in MB). When left empty, defaults to 800MB. Only applies when <em>Nsjail /tmp backing</em> is RAM (tmpfs).',
fieldType: 'number',
placeholder: '800',
storage: 'setting'
-30
View File
@@ -1,41 +1,11 @@
{
"deprecated_gitSync_0": "hub/9087/sync-script-to-git-repo-windmill",
"deprecated_gitSync_1": "hub/9987/sync-script-to-git-repo-windmill",
"deprecated_gitSync_2": "hub/11498/sync-script-to-git-repo-windmill",
"deprecated_gitSync_3": "hub/11533/sync-script-to-git-repo-windmill",
"deprecated_gitSync_4": "hub/11580/sync-script-to-git-repo-windmill",
"deprecated_gitSync_5": "hub/11641/sync-script-to-git-repo-windmill",
"deprecated_gitSync_6": "hub/11666/sync-script-to-git-repo-windmill",
"deprecated_gitSync_7": "hub/11668/sync-script-to-git-repo-windmill",
"deprecated_gitSync_8": "hub/19673/sync-script-to-git-repo-windmill",
"deprecated_gitSync_9": "hub/19738/sync-script-to-git-repo-windmill",
"deprecated_gitSync_10": "hub/19785/sync-script-to-git-repo-windmill",
"deprecated_gitSync_11": "hub/19789/sync-script-to-git-repo-windmill",
"deprecated_gitSync_12": "hub/19798/sync-script-to-git-repo-windmill",
"deprecated_gitSync_13": "hub/19801/sync-script-to-git-repo-windmill",
"deprecated_gitSync_14": "hub/19803/sync-script-to-git-repo-windmill",
"deprecated_gitSync_15": "hub/19816/sync-script-to-git-repo-windmill",
"deprecated_gitSync_16": "hub/19818/sync-script-to-git-repo-windmill",
"deprecated_gitSync_17": "hub/28073/sync-script-to-git-repo-windmill",
"deprecated_gitSync_18": "hub/28078/sync-script-to-git-repo-windmill",
"deprecated_gitSync_19": "hub/28081/sync-script-to-git-repo-windmill",
"deprecated_gitSync_20": "hub/28102/sync-script-to-git-repo-windmill",
"deprecated_gitSync_21": "hub/28131/sync-script-to-git-repo-windmill",
"deprecated_gitSync_22": "hub/28159/sync-script-to-git-repo-windmill",
"deprecated_gitSync_23": "hub/28160/sync-script-to-git-repo-windmill",
"deprecated_gitSync_24": "hub/28176/sync-script-to-git-repo-windmill",
"deprecated_gitSync_latest": "hub/28180/sync-script-to-git-repo-windmill",
"deprecated_gitSync_25": "hub/28183/sync-script-to-git-repo-windmill",
"gitSyncTest": "hub/28184/git-repo-test-read-write-windmill",
"gitInitRepo": "hub/28219/git-sync%3A-init-repository-windmill",
"slackErrorHandler": "hub/19741/workspace-or-schedule-error-handler-slack",
"slackErrorHandler_0": "hub/9079/workspace-or-schedule-error-handler-slack",
"slackErrorHandler_1": "hub/9206/workspace-or-schedule-error-handler-slack",
"emailErrorHandler": "hub/19795/workspace-or-error-handler-email",
"slackRecoveryHandler": "hub/9080/slack/schedule-recovery-handler-slack",
"slackSuccessHandler": "hub/28220/slack/schedule-success-handler-slack",
"teamsErrorHandler": "hub/19742/workspace-or-schedule-error-handler-teams",
"teamsErrorHandler_0": "hub/11598/workspace-or-schedule-error-handler-teams",
"teamsRecoveryHandler": "hub/11593/schedule-recovery-handler-teams",
"teamsSuccessHandler": "hub/11596/schedule-success-handler-teams",
"slackReport": "hub/9084/slack",
+21 -5
View File
@@ -601,7 +601,7 @@ export function useLocalStorageValue<T>(
*/
transformBeforePersist?: (val: T) => T
}
): { val: T; skipNextWriteOnce(): void } {
): { val: T; skipNextWriteOnce(): void; setWithoutPersist(newVal: T): void } {
const saveInitialValue = options?.saveInitialValue ?? true
const debounceMs = options?.debounce ?? 0
const transformBeforePersist = options?.transformBeforePersist
@@ -626,7 +626,9 @@ export function useLocalStorageValue<T>(
}
}
if (typeof window === 'undefined') return { val: defaultValue, skipNextWriteOnce: () => {} }
if (typeof window === 'undefined') {
return { val: defaultValue, skipNextWriteOnce: () => {}, setWithoutPersist: () => {} }
}
const savedValue = localStorage.getItem(key)
let s = $state<T>(
savedValue != null && savedValue !== 'undefined' ? (deserialize(savedValue) as T) : defaultValue
@@ -662,6 +664,13 @@ export function useLocalStorageValue<T>(
pendingValue = undefined
}, debounceMs)
}
const cancelPendingWrite = () => {
if (debounceTimer != null) {
clearTimeout(debounceTimer)
debounceTimer = undefined
}
pendingValue = undefined
}
$effect(() => {
readFieldsRecursively(s)
@@ -698,12 +707,19 @@ export function useLocalStorageValue<T>(
/**
* Arm the persist skip so the next `set val` (or deep-mutation flush)
* updates only the in-memory cell and leaves localStorage untouched.
* Used by `UserDraft.discard` to reset the in-memory state to a
* fallback without re-persisting it pairs with an explicit LS
* delete to leave the slot empty.
*/
skipNextWriteOnce(): void {
skipNextWrite = true
},
/**
* Reset the in-memory state while canceling any queued debounced write.
* Used when a caller performs its own synchronous persistence action.
*/
setWithoutPersist(newVal: T): void {
cancelPendingWrite()
lastSerialized = newVal === undefined ? undefined : serialize(newVal)
skipNextWrite = false
s = newVal
}
}
}
+159 -31
View File
@@ -4,31 +4,34 @@ import { deepEqual } from 'fast-equals'
import { workspaceStore } from './stores'
import { useLocalStorageValue } from './svelte5Utils.svelte'
export type UserDraftItemKind =
| 'script'
| 'flow'
| 'app'
| 'raw_app'
| 'resource'
| 'variable'
| 'trigger_schedule'
| 'trigger_webhook'
| 'trigger_default_email'
| 'trigger_email'
| 'trigger_http'
| 'trigger_websocket'
| 'trigger_postgres'
| 'trigger_kafka'
| 'trigger_nats'
| 'trigger_mqtt'
| 'trigger_sqs'
| 'trigger_gcp'
| 'trigger_azure'
| 'trigger_poll'
| 'trigger_cli'
| 'trigger_nextcloud'
| 'trigger_google'
| 'trigger_github'
export const USER_DRAFT_ITEM_KINDS = [
'script',
'flow',
'app',
'raw_app',
'resource',
'variable',
'trigger_schedule',
'trigger_webhook',
'trigger_default_email',
'trigger_email',
'trigger_http',
'trigger_websocket',
'trigger_postgres',
'trigger_kafka',
'trigger_nats',
'trigger_mqtt',
'trigger_sqs',
'trigger_gcp',
'trigger_azure',
'trigger_poll',
'trigger_cli',
'trigger_nextcloud',
'trigger_google',
'trigger_github'
] as const
export type UserDraftItemKind = (typeof USER_DRAFT_ITEM_KINDS)[number]
export type UserDraftOptions = {
workspace?: string
@@ -43,6 +46,10 @@ export type UserDraftUseOptions<V> = UserDraftOptions & {
defaultValue?: V
}
export type UserDraftListOptions = UserDraftOptions & {
itemKinds?: readonly UserDraftItemKind[]
}
/**
* A single (kind, path, workspace) tuple that `useMany` should hold a handle
* for. The shape mirrors `use()`'s arguments, just bundled into one object
@@ -93,10 +100,14 @@ function stamp<V>(stored: StoredDraft<V> | undefined): StoredDraft<V> | undefine
type DraftState<V> = {
val: StoredDraft<V> | undefined
skipNextWriteOnce(): void
setWithoutPersist(newVal: StoredDraft<V> | undefined): void
}
type DraftEntry = {
count: number
workspace: string
itemKind: UserDraftItemKind
path: string
state: DraftState<unknown>
/**
* Tears down the `$effect.root` scope that owns the entry's
@@ -109,6 +120,16 @@ type DraftEntry = {
destroyRoot?: () => void
}
export type UserDraftEntry<V = unknown> = {
workspace: string
itemKind: UserDraftItemKind
path: string
value: V | undefined
meta: UserDraftMeta
persisted: boolean
live: boolean
}
const entries = new Map<string, DraftEntry>()
function resolveWorkspace(opts?: UserDraftOptions): string {
@@ -209,6 +230,36 @@ function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: s
return `userdraft/w/${workspace}/${itemKind}/${path}`
}
function parseLocalStorageKey(
key: string,
workspace: string,
itemKinds: readonly UserDraftItemKind[]
): { itemKind: UserDraftItemKind; path: string } | undefined {
const prefix = `userdraft/w/${workspace}/`
if (!key.startsWith(prefix)) return undefined
const rest = key.slice(prefix.length)
for (const itemKind of itemKinds) {
const kindPrefix = `${itemKind}/`
if (rest.startsWith(kindPrefix)) {
return { itemKind, path: rest.slice(kindPrefix.length) }
}
}
return undefined
}
function snapshotDraftValue<V>(value: V | undefined): V | undefined {
if (value === undefined) return undefined
try {
return structuredClone($state.snapshot(value)) as V
} catch {
try {
return JSON.parse(JSON.stringify(value)) as V
} catch {
return undefined
}
}
}
export type UserDraftHandle<V> = {
get draft(): V | undefined
set draft(value: V | undefined)
@@ -299,6 +350,27 @@ export const UserDraft = {
}
},
setDraftAndMeta<V>(
itemKind: UserDraftItemKind,
path: string,
value: V | undefined,
meta: UserDraftMeta,
opts?: UserDraftOptions
): void {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
entry.state.val = wrap(value, meta)
// Static writes represent explicit external draft mutations. A
// freshly acquired live entry may still have the initial-write skip
// armed, so force the storage slot to match the live value.
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
return
}
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
},
/**
* Autosave gate: persist `value` only when it differs (after
* `normalizeForCompare`) from the `deployed` baseline; otherwise remove
@@ -396,6 +468,61 @@ export const UserDraft = {
}
},
clear(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
UserDraft.discard(itemKind, path, undefined, opts)
},
list<V = unknown>(opts?: UserDraftListOptions): UserDraftEntry<V>[] {
const ws = resolveWorkspace(opts)
const itemKinds = opts?.itemKinds ?? USER_DRAFT_ITEM_KINDS
const out = new Map<string, UserDraftEntry<V>>()
if (typeof localStorage !== 'undefined') {
const keys: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key != null && key.startsWith(`userdraft/w/${ws}/`)) keys.push(key)
}
for (const key of keys) {
const parsed = parseLocalStorageKey(key, ws, itemKinds)
if (!parsed) continue
const stored = readPersisted<V>(key)
if (stored === undefined) continue
out.set(mapKey(ws, parsed.itemKind, parsed.path), {
workspace: ws,
itemKind: parsed.itemKind,
path: parsed.path,
value: snapshotDraftValue(unwrap(stored)),
meta: extractMeta(stored),
persisted: true,
live: false
})
}
}
for (const entry of entries.values()) {
if (entry.workspace !== ws || !itemKinds.includes(entry.itemKind)) continue
const stored = untrack(() => entry.state.val as StoredDraft<V> | undefined)
const mk = mapKey(entry.workspace, entry.itemKind, entry.path)
if (stored === undefined) {
out.delete(mk)
continue
}
const existing = out.get(mk)
out.set(mk, {
workspace: entry.workspace,
itemKind: entry.itemKind,
path: entry.path,
value: snapshotDraftValue(unwrap(stored)),
meta: extractMeta(stored),
persisted: existing?.persisted ?? false,
live: true
})
}
return Array.from(out.values())
},
/**
* Like `remove`, but also resets any live handle's `draft` to
* `fallback` in-memory (so reactive readers see it immediately) and
@@ -412,10 +539,11 @@ export const UserDraft = {
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
// Arm the skip before the cell write so the setter suppresses
// the persist; the removeItem below actually clears the slot.
entry.state.skipNextWriteOnce()
entry.state.val = wrap(fallback) as StoredDraft<unknown> | undefined
// Drop any queued debounced write owned by this live entry before
// resetting the in-memory value. Otherwise a timer from the old
// entry can outlive unmount and later delete a freshly written
// draft for the same key.
entry.state.setWithoutPersist(wrap(fallback) as StoredDraft<unknown> | undefined)
}
try {
localStorage.removeItem(localStorageKey(ws, itemKind, path))
@@ -549,7 +677,7 @@ function acquireEntry(
)
})
if (stateRef) {
entries.set(mk, { count: 1, state: stateRef, destroyRoot })
entries.set(mk, { count: 1, workspace, itemKind, path, state: stateRef, destroyRoot })
return
}
// Fallback for the vitest runtime where `$effect.root`'s callback isn't
@@ -560,7 +688,7 @@ function acquireEntry(
undefined,
useLocalStorageOptions
)
entries.set(mk, { count: 1, state })
entries.set(mk, { count: 1, workspace, itemKind, path, state })
}
function releaseEntry(mk: string): void {
+240
View File
@@ -719,3 +719,243 @@ describe('UserDraft.saveIfChanged', () => {
expect(storedShape(KEY)).toBe(wrapped(value))
})
})
describe('UserDraft.list / clear / setDraftAndMeta', () => {
it('enumerates persisted-only drafts for the requested workspace and kinds', () => {
UserDraft.setDraftAndMeta('script', 'f/a', { path: 'f/a', content: 'a' }, { remoteRev: 'h1' })
UserDraft.setDraftAndMeta(
'flow',
'f/b',
{ path: 'f/b', value: { modules: [] } },
{ remoteRev: 2 },
{ workspace: 'other_ws' }
)
UserDraft.setDraftAndMeta('resource', 'f/c', { path: 'f/c' }, {})
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([
{
workspace: 'test_ws',
itemKind: 'script',
path: 'f/a',
value: { path: 'f/a', content: 'a' },
meta: { remoteRev: 'h1' },
persisted: true,
live: false
}
])
expect(UserDraft.list({ workspace: 'other_ws' })).toEqual([
expect.objectContaining({
workspace: 'other_ws',
itemKind: 'flow',
path: 'f/b',
persisted: true,
live: false
})
])
})
it('keeps multiple path-addressed drafts and the empty-path scratch draft distinct', () => {
UserDraft.setDraftAndMeta('script', '', { path: '', content: 'scratch' }, {})
UserDraft.setDraftAndMeta('script', 'f/new-a', { path: 'f/new-a', content: 'a' }, {})
UserDraft.setDraftAndMeta('script', 'f/new-b', { path: 'f/new-b', content: 'b' }, {})
const entries = UserDraft.list<{ path: string; content: string }>({ itemKinds: ['script'] })
expect(entries).toHaveLength(3)
expect(entries).toEqual(
expect.arrayContaining([
expect.objectContaining({
itemKind: 'script',
path: '',
value: { path: '', content: 'scratch' }
}),
expect.objectContaining({
itemKind: 'script',
path: 'f/new-a',
value: { path: 'f/new-a', content: 'a' }
}),
expect.objectContaining({
itemKind: 'script',
path: 'f/new-b',
value: { path: 'f/new-b', content: 'b' }
})
])
)
})
it('enumerates live-only drafts before the debounce persists them', () => {
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/live')
handle.setDraftAndMeta({ path: 'f/live', content: 'live' }, { remoteRev: 'h1' })
expect(localStorage.getItem('userdraft/w/test_ws/script/f/live')).toBeNull()
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([
{
workspace: 'test_ws',
itemKind: 'script',
path: 'f/live',
value: { path: 'f/live', content: 'live' },
meta: { remoteRev: 'h1' },
persisted: false,
live: true
}
])
})
it('dedupes entries that are both persisted and live', () => {
UserDraft.setDraftAndMeta(
'script',
'f/both',
{ path: 'f/both', content: 'persisted' },
{
remoteRev: 'h1'
}
)
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/both')
handle.draft = { path: 'f/both', content: 'live' }
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([
{
workspace: 'test_ws',
itemKind: 'script',
path: 'f/both',
value: { path: 'f/both', content: 'live' },
meta: { remoteRev: 'h1' },
persisted: true,
live: true
}
])
})
it('clear removes persisted storage and live state without re-persisting', () => {
UserDraft.setDraftAndMeta(
'script',
'f/clear',
{ path: 'f/clear', content: 'x' },
{
remoteRev: 'h1'
}
)
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/clear')
expect(handle.draft).toEqual({ path: 'f/clear', content: 'x' })
UserDraft.clear('script', 'f/clear')
flushPersist()
expect(handle.draft).toBeUndefined()
expect(localStorage.getItem('userdraft/w/test_ws/script/f/clear')).toBeNull()
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([])
})
it('clear cancels pending debounced live writes', () => {
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/pending-clear')
handle.draft = { path: 'f/pending-clear', content: 'initial' }
handle.draft = { path: 'f/pending-clear', content: 'pending' }
UserDraft.clear('script', 'f/pending-clear')
expect(handle.draft).toBeUndefined()
expect(localStorage.getItem('userdraft/w/test_ws/script/f/pending-clear')).toBeNull()
flushPersist()
expect(localStorage.getItem('userdraft/w/test_ws/script/f/pending-clear')).toBeNull()
})
it('clear does not let an old debounced remove delete a later direct write', () => {
const key = 'userdraft/w/test_ws/script/f/rewrite-after-clear'
const handle = UserDraft.use<{ path: string; content: string }>(
'script',
'f/rewrite-after-clear'
)
handle.draft = { path: 'f/rewrite-after-clear', content: 'initial' }
handle.draft = { path: 'f/rewrite-after-clear', content: 'pending' }
UserDraft.clear('script', 'f/rewrite-after-clear')
flushDestroyCallbacks()
UserDraft.setDraftAndMeta(
'script',
'f/rewrite-after-clear',
{ path: 'f/rewrite-after-clear', content: 'new' },
{}
)
flushPersist()
expect(storedShape(key)).toBe(
wrapped({ path: 'f/rewrite-after-clear', content: 'new' })
)
})
it('list hides persisted drafts when a live handle has cleared the value', () => {
UserDraft.setDraftAndMeta(
'script',
'f/live-clear',
{ path: 'f/live-clear', content: 'persisted' },
{}
)
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/live-clear')
handle.draft = { path: 'f/live-clear', content: 'edited' }
handle.draft = undefined
expect(localStorage.getItem('userdraft/w/test_ws/script/f/live-clear')).not.toBeNull()
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([])
})
it('setDraftAndMeta updates live handles atomically and preserves metadata on later draft writes', () => {
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/meta')
UserDraft.setDraftAndMeta(
'script',
'f/meta',
{ path: 'f/meta', content: 'first' },
{
remoteRev: 'h1',
remoteDraftRev: 'd1'
}
)
handle.draft = { path: 'f/meta', content: 'second' }
expect(handle.draft).toEqual({ path: 'f/meta', content: 'second' })
expect(handle.meta).toEqual({ remoteRev: 'h1', remoteDraftRev: 'd1' })
expect(UserDraft.list({ itemKinds: ['script'] })[0]).toEqual(
expect.objectContaining({
value: { path: 'f/meta', content: 'second' },
meta: { remoteRev: 'h1', remoteDraftRev: 'd1' }
})
)
})
it('static setDraftAndMeta persists first writes even when a live handle exists', () => {
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/static-live')
UserDraft.setDraftAndMeta(
'script',
'f/static-live',
{ path: 'f/static-live', content: 'first' },
{ remoteRev: 'h1' }
)
expect(handle.draft).toEqual({ path: 'f/static-live', content: 'first' })
expect(storedShape('userdraft/w/test_ws/script/f/static-live')).toBe(
JSON.stringify({
value: { path: 'f/static-live', content: 'first' },
remoteRev: 'h1'
})
)
})
it('lists live drafts with runtime-only values without throwing', () => {
const handle = UserDraft.use<Record<string, unknown>>('script', 'f/runtime')
handle.draft = {
path: 'f/runtime',
content: 'x',
callback: () => 'not serializable'
}
expect(() => UserDraft.list({ itemKinds: ['script'] })).not.toThrow()
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([
expect.objectContaining({
itemKind: 'script',
path: 'f/runtime',
value: { path: 'f/runtime', content: 'x' }
})
])
})
})
+1 -1
View File
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.705.0"
wmill = ">=1.706.0"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.705.0
version: 1.706.0
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.705.0'
ModuleVersion = '1.706.0'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.705.0"
version = "1.706.0"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"
@@ -387,6 +387,15 @@ List all queues with their metrics
- `--instance [instance]` - Name of the instance to push to, override the active instance
- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance
### refresh
Refresh wmill-managed project files (AGENTS.cli.md and skills)
**Subcommands:**
- `refresh prompts` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.
- `--yes` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include.
### resource
resource related commands
+9
View File
@@ -2918,6 +2918,15 @@ List all queues with their metrics
- \`--instance [instance]\` - Name of the instance to push to, override the active instance
- \`--base-url [baseUrl]\` - If used with --token, will be used as the base url for the instance
### refresh
Refresh wmill-managed project files (AGENTS.cli.md and skills)
**Subcommands:**
- \`refresh prompts\` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.
- \`--yes\` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include.
### resource
resource related commands
@@ -392,6 +392,15 @@ List all queues with their metrics
- `--instance [instance]` - Name of the instance to push to, override the active instance
- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance
### refresh
Refresh wmill-managed project files (AGENTS.cli.md and skills)
**Subcommands:**
- `refresh prompts` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.
- `--yes` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include.
### resource
resource related commands
+18 -7
View File
@@ -1828,7 +1828,7 @@ CONTEXT7_REPO_NAME = "windmill-cli-docs"
def extract_agents_md_template() -> str:
"""Extract the AGENTS.md template string from cli/src/guidance/core.ts.
"""Extract the AGENTS.cli.md template string from cli/src/guidance/core.ts.
Keeping a single source of truth in TypeScript avoids drift between what
`wmill init` writes locally and what we publish for context7 ingestion.
@@ -1836,14 +1836,16 @@ def extract_agents_md_template() -> str:
core_ts_path = SCRIPT_DIR.parent / "cli" / "src" / "guidance" / "core.ts"
content = core_ts_path.read_text()
# Anchor on the function name so adding other template-literal-returning
# functions to core.ts can't silently re-target the regex.
# functions to core.ts can't silently re-target the regex. The function
# was renamed from `generateAgentsMdContent` → `generateAgentsCliMdContent`
# when the managed file split out of AGENTS.md into AGENTS.cli.md.
match = re.search(
r"function\s+generateAgentsMdContent\b[\s\S]*?return\s+`([\s\S]*?)`;",
r"function\s+generateAgentsCliMdContent\b[\s\S]*?return\s+`([\s\S]*?)`;",
content,
)
if not match:
raise RuntimeError(
f"Could not extract AGENTS.md template from {core_ts_path}"
f"Could not extract AGENTS.cli.md template from {core_ts_path}"
)
return _unescape_ts_template_literal(match.group(1))
@@ -1865,10 +1867,16 @@ def _unescape_ts_template_literal(raw: str) -> str:
def render_agents_md_for_docs(
skills: list[str], skill_desc_map: dict[str, str]
) -> str:
"""Render AGENTS.md exactly as `wmill init` would, for the docs repo."""
"""Render AGENTS.cli.md exactly as `wmill init` would, for the docs repo.
The skill reference paths point at `.agents/skills/` (the canonical tree
that Codex/Pi read directly and that Claude Code mirrors under
`.claude/skills/`) matching `buildSkillsReference` in
`cli/src/guidance/writer.ts`.
"""
template = extract_agents_md_template()
skills_reference = "\n".join(
f"- `.claude/skills/{name}/SKILL.md` - {skill_desc_map[name]}"
f"- `.agents/skills/{name}/SKILL.md` - {skill_desc_map[name]}"
for name in skills
if name in skill_desc_map
)
@@ -2002,7 +2010,10 @@ def generate_context7_repo(
skill_desc_map = build_skill_desc_map(skills)
# AGENTS.md — the same content `wmill init` writes locally.
# AGENTS.md — the managed CLI guidance (what `wmill init` writes as
# AGENTS.cli.md locally). Kept under the `AGENTS.md` filename here to
# preserve the existing context7 ingest path; docs consumers read this
# as the canonical AGENTS file.
(target_dir / "AGENTS.md").write_text(
render_agents_md_for_docs(skills, skill_desc_map)
)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill/windmill",
"version": "1.705.0",
"version": "1.706.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.705.0",
"version": "1.706.0",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"sideEffects": false,
+1 -1
View File
@@ -1 +1 @@
1.705.0
1.706.0