mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 08:01:35 +00:00
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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
+22
@@ -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"
|
||||
}
|
||||
Generated
+80
-80
@@ -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
@@ -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 @@
|
||||
daffe7bb81cfcaca666c61de1ee838a44d60ebc2
|
||||
eb18d7b4c0e37fea3f6e1e2cc44e0fddd74ff817
|
||||
|
||||
+24
-24
@@ -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
@@ -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
@@ -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,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.705.0
|
||||
version: 1.706.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
|
||||
@@ -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),
|
||||
)?;
|
||||
|
||||
@@ -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),
|
||||
)?;
|
||||
|
||||
@@ -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),
|
||||
)?;
|
||||
|
||||
@@ -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),
|
||||
)?;
|
||||
|
||||
@@ -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),
|
||||
)?;
|
||||
|
||||
@@ -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),
|
||||
)?;
|
||||
|
||||
@@ -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),
|
||||
)?;
|
||||
|
||||
@@ -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),
|
||||
)?;
|
||||
|
||||
@@ -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(),
|
||||
)?;
|
||||
|
||||
@@ -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()),
|
||||
)?;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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),
|
||||
)?;
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
`;
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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") {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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,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'
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -4,7 +4,7 @@ verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
wmill = ">=1.705.0"
|
||||
wmill = ">=1.706.0"
|
||||
sendgrid = "*"
|
||||
mysql-connector-python = "*"
|
||||
pymongo = "*"
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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
@@ -1 +1 @@
|
||||
1.705.0
|
||||
1.706.0
|
||||
|
||||
Reference in New Issue
Block a user