Compare commits

..

11 Commits

Author SHA1 Message Date
wendrul
5cc3928726 fix: delete_variable endpoint also deleting resource 2026-03-05 18:00:22 +01:00
Ruben Fiszel
86065aaac8 chore(main): release 1.651.1 (#8242)
* chore(main): release 1.651.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-03-05 14:51:38 +00:00
Ruben Fiszel
e3f4130c68 nits 2026-03-05 14:36:51 +00:00
Ruben Fiszel
2e582b1bc1 fix: prevent slow loading toast interval from leaking on promise cancellation (#8240)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:23:36 +00:00
Ruben Fiszel
2d583826dc fix: suppress unused variable warnings on windows builds (#8241)
* fix: suppress unused variable warnings on windows builds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: update ee-repo-ref.txt to merged commit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:17:51 +01:00
Ruben Fiszel
972ae7aa29 chore(main): release 1.651.0 (#8235)
* chore(main): release 1.651.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-03-05 13:42:07 +00:00
Ruben Fiszel
d46913b74a fix: write fallback package.json for codebase mode nsjail (#8239)
* fix: write fallback package.json for codebase mode to fix nsjail ERR_INVALID_PACKAGE_CONFIG

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: add e2e tests for codebase mode with and without nsjail

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-05 13:35:53 +00:00
Roderik-WU
90f4c64ee1 fix(python-client): add delete_s3_object (#8216)
* Implement remove_s3_file method

Add method to permanently delete a file from S3 bucket.

* Add test for removing S3 file

Added a test case to verify removal of a file from S3.

* Add remove_s3_file function to delete S3 files

Added a function to permanently delete a file from the S3 bucket.

* Rename remove_s3_file to remove_3_object

* Rename remove_3_object to remove_s3_object

* Rename test method and update S3 object handling

* Rename remove_s3_object to delete_s3_object

* Rename test_remove_s3_object to test_delete_s3_object and remove_s3_object to delete_s3_object
2026-03-05 12:49:59 +00:00
hugocasa
a8cbe9396f fix: update CLI bun template to match UI template (#8238)
* fix: update CLI bun template to match UI template

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: simplify CLI bun template, only add mode comments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:04:38 +00:00
centdix
ce041e8a5e feat: hash-based MCP tool names for long paths (#8133)
* feat: replace _TRUNC with hash-based MCP tool names (50 char limit)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: reduce MCP tool name limit from 50 to 40 chars

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: use path prefix filtering instead of separate DB query for hashed name resolution

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: remove long path warning from MCP token creation (hashing handles long names)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: unify tool prefix parsing and fix extract_path_prefix_from_hashed for Hs- names

- Replace `is_hashed_name` + `parse_hashed_name` with unified `parse_tool_prefix`
  that returns `(type_str, is_hub, is_hashed)` in one call
- Fix `extract_path_prefix_from_hashed` to dynamically determine prefix length
  (3 for `Hs-`, 2 for `S-`/`F-`) instead of hardcoding index 2
- Simplify `reverse_transform` to reuse `parse_tool_prefix`
- Add tests for invalid prefixes and `Hs-` prefix handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: escape LIKE wildcards in MCP hashed name path prefix query

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: respect favorites scope in hashed tool name resolution

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: deduplicate MCP tool name resolution and rename get_path_or_id

- Extract `unescape_path` helper in transform.rs to deduplicate the
  3-step placeholder unescape logic
- Extract `find_matching_path` helper in runner.rs to deduplicate
  script/flow candidate matching via ToolableItem trait
- Remove verbose tracing::info! logs from hashed tool resolution hot path
- Fix doc comment referencing nonexistent `is_hashed_name` function
- Rename `get_path_or_id` to `get_transformed_path` for clarity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: update stale doc comments to reflect MAX_PATH_LENGTH=40

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-05 12:04:20 +00:00
Ruben Fiszel
65082159d8 tighten volume limits (#8236)
* feat: add volume limits info in CE volumes drawer

Show an info alert in the volumes drawer when running in Community
Edition, mentioning the 20 volumes per workspace and 50 MB per file
limits. Update ee-repo-ref for companion EE changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: update ee-repo-ref to a61366dd4d9e9b1f98a421aaa6d3f63194615275

This commit updates the EE repository reference after PR #438 was merged in windmill-ee-private.

Previous ee-repo-ref: 05385738e36e81f5bc51d15c0ca60bba30457c21

New ee-repo-ref: a61366dd4d9e9b1f98a421aaa6d3f63194615275

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-03-05 06:44:32 +00:00
38 changed files with 883 additions and 205 deletions

View File

@@ -1,5 +1,28 @@
# Changelog
## [1.651.1](https://github.com/windmill-labs/windmill/compare/v1.651.0...v1.651.1) (2026-03-05)
### Bug Fixes
* prevent slow loading toast interval from leaking on promise cancellation ([#8240](https://github.com/windmill-labs/windmill/issues/8240)) ([2e582b1](https://github.com/windmill-labs/windmill/commit/2e582b1bc1c299388a3c97cfddff9d0eb92858f2))
* suppress unused variable warnings on windows builds ([#8241](https://github.com/windmill-labs/windmill/issues/8241)) ([2d58382](https://github.com/windmill-labs/windmill/commit/2d583826dc065c05684d4cd1d1510f0d1f2d9ae9))
## [1.651.0](https://github.com/windmill-labs/windmill/compare/v1.650.0...v1.651.0) (2026-03-05)
### Features
* add sandbox annotations, volume mounts, for AI sandbox starting with claude ([#8058](https://github.com/windmill-labs/windmill/issues/8058)) ([5f0ef93](https://github.com/windmill-labs/windmill/commit/5f0ef936d1d5d07d01c8e07e26ec254feebef8fb))
* hash-based MCP tool names for long paths ([#8133](https://github.com/windmill-labs/windmill/issues/8133)) ([ce041e8](https://github.com/windmill-labs/windmill/commit/ce041e8a5e7ff105df389875d9981f3843d4ce39))
### Bug Fixes
* **python-client:** add delete_s3_object ([#8216](https://github.com/windmill-labs/windmill/issues/8216)) ([90f4c64](https://github.com/windmill-labs/windmill/commit/90f4c64ee12e1d04ce846ff88d6658f667e194e0))
* update CLI bun template to match UI template ([#8238](https://github.com/windmill-labs/windmill/issues/8238)) ([a8cbe93](https://github.com/windmill-labs/windmill/commit/a8cbe9396ffc51140dce5582d57f4dc59873304e))
* write fallback package.json for codebase mode nsjail ([#8239](https://github.com/windmill-labs/windmill/issues/8239)) ([d46913b](https://github.com/windmill-labs/windmill/commit/d46913b74a0ffd41d2323e0355cc81954f09e29d))
## [1.650.0](https://github.com/windmill-labs/windmill/compare/v1.649.0...v1.650.0) (2026-03-05)

View File

@@ -15,7 +15,7 @@
]
},
"nullable": [
true
false
]
},
"hash": "2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91"

View File

@@ -15,7 +15,7 @@
]
},
"nullable": [
null
true
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"

View File

@@ -15,7 +15,7 @@
]
},
"nullable": [
true
false
]
},
"hash": "eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06"

143
backend/Cargo.lock generated
View File

@@ -15741,7 +15741,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-nats",
@@ -15773,6 +15773,7 @@ dependencies = [
"sql-builder",
"sqlx",
"strum 0.27.2",
"tar",
"tempfile",
"tikv-jemalloc-ctl",
"tikv-jemalloc-sys",
@@ -15807,7 +15808,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15820,7 +15821,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"argon2",
@@ -15959,7 +15960,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15982,7 +15983,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15995,7 +15996,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16021,7 +16022,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -16031,7 +16032,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16048,7 +16049,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -16071,7 +16072,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16094,7 +16095,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16110,7 +16111,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16130,7 +16131,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16150,7 +16151,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16164,7 +16165,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-nats",
@@ -16191,7 +16192,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16216,7 +16217,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"flate2",
@@ -16234,7 +16235,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16255,7 +16256,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16275,7 +16276,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16305,7 +16306,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16332,7 +16333,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"lazy_static",
"serde",
@@ -16344,7 +16345,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"argon2",
"axum 0.7.9",
@@ -16367,7 +16368,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16381,7 +16382,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16412,7 +16413,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"chrono",
"lazy_static",
@@ -16426,7 +16427,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16445,7 +16446,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"aes-gcm",
"anyhow",
@@ -16544,7 +16545,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -16563,7 +16564,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"regex",
"serde",
@@ -16578,7 +16579,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -16602,7 +16603,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"futures",
@@ -16619,7 +16620,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -16635,7 +16636,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16656,7 +16657,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16687,7 +16688,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-oauth2",
@@ -16711,7 +16712,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-stream",
@@ -16745,7 +16746,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"futures",
@@ -16763,7 +16764,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -16772,7 +16773,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16784,7 +16785,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"serde_json",
@@ -16796,7 +16797,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"gosyn",
@@ -16808,7 +16809,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16820,7 +16821,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"serde_json",
@@ -16832,7 +16833,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"nu-parser",
@@ -16843,7 +16844,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16854,7 +16855,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16867,7 +16868,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -16891,7 +16892,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16905,7 +16906,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16922,7 +16923,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16937,7 +16938,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16956,7 +16957,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"serde",
@@ -16967,7 +16968,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -17004,7 +17005,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"const_format",
@@ -17042,7 +17043,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -17053,7 +17054,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -17082,7 +17083,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -17105,7 +17106,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17138,7 +17139,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17158,7 +17159,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17192,7 +17193,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17227,7 +17228,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17250,7 +17251,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17274,7 +17275,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-nats",
@@ -17298,7 +17299,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17333,7 +17334,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17361,7 +17362,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17384,7 +17385,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"bitflags 2.9.4",
@@ -17402,7 +17403,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-once-cell",
@@ -17508,7 +17509,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.650.0"
version = "1.651.1"
dependencies = [
"bytes",
"futures",

View File

@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.650.0"
version = "1.651.1"
authors.workspace = true
edition.workspace = true
@@ -77,7 +77,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal"]
[workspace.package]
version = "1.650.0"
version = "1.651.1"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -257,6 +257,7 @@ axum.workspace = true
serde.workspace = true
windmill-api-client.workspace = true
tempfile.workspace = true
tar.workspace = true
windmill-parser-ts.workspace = true
rumqttc.workspace = true
rdkafka.workspace = true

View File

@@ -1 +1 @@
05385738e36e81f5bc51d15c0ca60bba30457c21
f9549c813b3dba5324ea9d1edacc8756a6d699bf

View File

@@ -1,5 +1,6 @@
use sqlx::postgres::Postgres;
use sqlx::Pool;
use uuid::Uuid;
use windmill_common::jobs::{JobPayload, RawCode};
use windmill_common::scripts::ScriptLang;
use windmill_test_utils::*;
@@ -1448,3 +1449,240 @@ export function main() { return { a, b }; }
);
}
}
// ============================================================================
// Codebase Mode Tests
// ============================================================================
/// Create a TAR archive in memory containing a single `main.js` file.
fn create_codebase_tar(main_js_content: &str) -> Vec<u8> {
let mut builder = tar::Builder::new(Vec::new());
let content = main_js_content.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_path("main.js").unwrap();
header.set_size(content.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder.append(&header, content).unwrap();
builder.into_inner().unwrap()
}
/// Place a TAR codebase at the expected cache path for the given job ID and hash.
fn place_codebase_in_cache(job_id: &Uuid, tar_bytes: &[u8], is_esm: bool) {
let codebase_id = if is_esm {
format!("{}.esm.tar", job_id)
} else {
format!("{}.tar", job_id)
};
let bundle_path = format!("script_bundle/test-workspace/{}", codebase_id);
let cache_path = format!(
"{}/{}.tar",
*windmill_common::worker::ROOT_CACHE_NOMOUNT_DIR,
bundle_path,
);
let parent = std::path::Path::new(&cache_path).parent().unwrap();
std::fs::create_dir_all(parent).unwrap();
std::fs::write(&cache_path, tar_bytes).unwrap();
}
#[sqlx::test(fixtures("base"))]
async fn test_cjs_codebase_tar(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let main_js = r#"
module.exports.main = function() {
return "cjs codebase ok";
};
"#;
let inner_content = r#"export function main() { return "cjs codebase ok"; }"#;
let job_id = Uuid::new_v4();
let tar_bytes = create_codebase_tar(main_js);
place_codebase_in_cache(&job_id, &tar_bytes, false);
let job = JobPayload::Code(RawCode {
hash: Some(-43), // PREVIEW_IS_TAR_CODEBASE_HASH
content: inner_content.to_string(),
path: None,
language: ScriptLang::Bun,
lock: None,
concurrency_settings: Default::default(),
debouncing_settings: Default::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
});
let result = RunJob::from(job)
.job_id(job_id)
.run_until_complete(&db, false, port)
.await
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!("cjs codebase ok"));
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_esm_codebase_tar(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let main_js = r#"
export function main() {
return "esm codebase ok";
}
"#;
let inner_content = r#"export function main() { return "esm codebase ok"; }"#;
let job_id = Uuid::new_v4();
let tar_bytes = create_codebase_tar(main_js);
place_codebase_in_cache(&job_id, &tar_bytes, true);
let job = JobPayload::Code(RawCode {
hash: Some(-45), // PREVIEW_IS_TAR_ESM_CODEBASE_HASH
content: inner_content.to_string(),
path: None,
language: ScriptLang::Bun,
lock: None,
concurrency_settings: Default::default(),
debouncing_settings: Default::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
});
let result = RunJob::from(job)
.job_id(job_id)
.run_until_complete(&db, false, port)
.await
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!("esm codebase ok"));
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_cjs_codebase_tar_nsjail(db: Pool<Postgres>) -> anyhow::Result<()> {
if std::process::Command::new("nsjail")
.arg("--help")
.output()
.is_err()
{
eprintln!("nsjail not found, skipping test");
return Ok(());
}
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let main_js = r#"
module.exports.main = function() {
return "cjs nsjail ok";
};
"#;
let inner_content = r#"export function main() { return "cjs nsjail ok"; }"#;
let job_id = Uuid::new_v4();
let tar_bytes = create_codebase_tar(main_js);
place_codebase_in_cache(&job_id, &tar_bytes, false);
let job = JobPayload::Code(RawCode {
hash: Some(-43),
content: inner_content.to_string(),
path: None,
language: ScriptLang::Bun,
lock: None,
concurrency_settings: Default::default(),
debouncing_settings: Default::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
});
use std::sync::atomic::Ordering;
windmill_worker::JOB_ISOLATION.store(
windmill_worker::JobIsolationLevel::NsjailSandboxing as u8,
Ordering::Relaxed,
);
let result = RunJob::from(job)
.job_id(job_id)
.run_until_complete(&db, false, port)
.await;
windmill_worker::JOB_ISOLATION.store(
windmill_worker::JobIsolationLevel::Undefined as u8,
Ordering::Relaxed,
);
let json = result.json_result().unwrap();
assert_eq!(json, serde_json::json!("cjs nsjail ok"));
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_esm_codebase_tar_nsjail(db: Pool<Postgres>) -> anyhow::Result<()> {
if std::process::Command::new("nsjail")
.arg("--help")
.output()
.is_err()
{
eprintln!("nsjail not found, skipping test");
return Ok(());
}
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let main_js = r#"
export function main() {
return "esm nsjail ok";
}
"#;
let inner_content = r#"export function main() { return "esm nsjail ok"; }"#;
let job_id = Uuid::new_v4();
let tar_bytes = create_codebase_tar(main_js);
place_codebase_in_cache(&job_id, &tar_bytes, true);
let job = JobPayload::Code(RawCode {
hash: Some(-45),
content: inner_content.to_string(),
path: None,
language: ScriptLang::Bun,
lock: None,
concurrency_settings: Default::default(),
debouncing_settings: Default::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
});
use std::sync::atomic::Ordering;
windmill_worker::JOB_ISOLATION.store(
windmill_worker::JobIsolationLevel::NsjailSandboxing as u8,
Ordering::Relaxed,
);
let result = RunJob::from(job)
.job_id(job_id)
.run_until_complete(&db, false, port)
.await;
windmill_worker::JOB_ISOLATION.store(
windmill_worker::JobIsolationLevel::Undefined as u8,
Ordering::Relaxed,
);
let json = result.json_result().unwrap();
assert_eq!(json, serde_json::json!("esm nsjail ok"));
Ok(())
}

View File

@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.650.0
version: 1.651.1
title: Windmill API
contact:

View File

@@ -64,9 +64,10 @@ impl McpBackend for WindmillBackend {
auth: &ApiAuthed,
workspace_id: &str,
favorites_only: bool,
path_prefix: Option<&str>,
) -> BackendResult<Vec<ScriptInfo>> {
let scope_type = if favorites_only { "favorites" } else { "all" };
get_items::<ScriptInfo>(&self.user_db, auth, workspace_id, scope_type, "script")
get_items::<ScriptInfo>(&self.user_db, auth, workspace_id, scope_type, "script", path_prefix)
.await
.map_err(|e| ErrorData::internal_error(e.message, None))
}
@@ -76,9 +77,10 @@ impl McpBackend for WindmillBackend {
auth: &ApiAuthed,
workspace_id: &str,
favorites_only: bool,
path_prefix: Option<&str>,
) -> BackendResult<Vec<FlowInfo>> {
let scope_type = if favorites_only { "favorites" } else { "all" };
get_items::<FlowInfo>(&self.user_db, auth, workspace_id, scope_type, "flow")
get_items::<FlowInfo>(&self.user_db, auth, workspace_id, scope_type, "flow", path_prefix)
.await
.map_err(|e| ErrorData::internal_error(e.message, None))
}

View File

@@ -136,6 +136,7 @@ pub async fn get_items<T: for<'a> sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen
workspace_id: &str,
scope_type: &str,
item_type: &str,
path_prefix: Option<&str>,
) -> Result<Vec<T>, ErrorData> {
let mut sqlb = SqlBuilder::select_from(&format!("{} as o", item_type));
let fields = vec!["o.path", "o.summary", "o.description", "o.schema"];
@@ -153,6 +154,11 @@ pub async fn get_items<T: for<'a> sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen
sqlb.and_where("(o.no_main_func IS NOT TRUE OR o.no_main_func IS NULL)");
}
if let Some(prefix) = path_prefix {
let escaped = prefix.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
sqlb.and_where("o.path LIKE ? ESCAPE '\\'".bind(&format!("{}%", escaped)));
}
sqlb.order_by(
if item_type == "flow" {
"o.edited_at"

View File

@@ -11,6 +11,8 @@ pub mod types;
pub use schema::convert_schema_to_schema_type;
pub use scope::{is_resource_allowed, parse_mcp_scopes, McpScopeConfig};
pub use transform::{
apply_key_transformation, reverse_transform, reverse_transform_key, transform_path,
apply_key_transformation, extract_hub_version_id_from_hashed,
extract_path_prefix_from_hashed, parse_tool_prefix, reverse_transform, reverse_transform_key,
transform_hub_path, transform_path,
};
pub use types::*;

View File

@@ -4,9 +4,15 @@
//! to make them compatible with MCP tool naming requirements.
use super::types::SchemaType;
use windmill_common::utils::calculate_hash;
/// MCP clients do not allow names longer than 60 characters
const MAX_PATH_LENGTH: usize = 60;
/// Max tool name length. The MCP spec allows 64 chars, but some clients
/// (e.g. Cursor) prepend the server name to the tool name, so we use 40
/// to leave room for that prefix.
const MAX_PATH_LENGTH: usize = 40;
/// Length of the SHA256 hash suffix used for hashed names
const HASH_LEN: usize = 16;
/// Transform the path for workspace scripts/flows
///
@@ -14,19 +20,133 @@ const MAX_PATH_LENGTH: usize = 60;
/// path with the type prefix. This is used when listing, because we can't
/// have names with slashes. Because we replace slashes with underscores,
/// we also need to escape underscores.
///
/// For short names (≤40 chars): `s-{escaped_path}` or `f-{escaped_path}`
/// For long names (>40 chars): `S-{escaped[:22]}{sha256[:16]}` or `F-{escaped[:22]}{sha256[:16]}`
///
/// The uppercase prefix signals that the name is hashed.
pub fn transform_path(path: &str, type_str: &str) -> String {
let escaped_path = path.replace('_', "__").replace('/', "_");
// first letter of type_str is used as prefix, only one letter to avoid reaching 60 char name limit
let transformed_path = format!("{}-{}", &type_str[..1], escaped_path);
if transformed_path.len() > MAX_PATH_LENGTH {
let suffix = "_TRUNC";
return format!(
"{}{}",
&transformed_path[..MAX_PATH_LENGTH - suffix.len()],
suffix
);
let prefix_char = &type_str[..1];
let short_name = format!("{}-{}", prefix_char, escaped_path);
if short_name.len() <= MAX_PATH_LENGTH {
return short_name;
}
transformed_path
let upper_prefix = prefix_char.to_uppercase();
// Layout: "{Upper}-" (2 chars) + prefix_body (22 chars) + hash (16 chars) = 40
let prefix_body_len = MAX_PATH_LENGTH - 2 - HASH_LEN;
let hash = calculate_hash(&short_name);
let hash_suffix = &hash[..HASH_LEN];
let truncated = truncate_to_char_boundary(&escaped_path, prefix_body_len);
format!("{}-{}{}", upper_prefix, truncated, hash_suffix)
}
/// Transform the path for hub scripts
///
/// For short names (≤40 chars): `hs-{id}-{summary}`
/// For long names (>40 chars): `Hs-{id}-{summary[:N]}{sha256[:16]}`
pub fn transform_hub_path(version_id: u64, summary: &str) -> String {
let escaped_summary = summary.replace(' ', "_");
let short_name = format!("hs-{}-{}", version_id, escaped_summary);
if short_name.len() <= MAX_PATH_LENGTH {
return short_name;
}
let hash = calculate_hash(&short_name);
let hash_suffix = &hash[..HASH_LEN];
// "Hs-{id}-" prefix, then fill remaining with summary + hash
let fixed_prefix = format!("Hs-{}-", version_id);
let available = MAX_PATH_LENGTH - fixed_prefix.len() - HASH_LEN;
let truncated_summary = truncate_to_char_boundary(&escaped_summary, available);
format!("{}{}{}", fixed_prefix, truncated_summary, hash_suffix)
}
/// Parse the prefix of any tool name (both short and hashed).
/// Returns `(type_str, is_hub, is_hashed)`.
/// Hashed names use an uppercase first character as the signal.
pub fn parse_tool_prefix(name: &str) -> Result<(&str, bool, bool), String> {
let is_hashed = name.chars().next().map(|c| c.is_ascii_uppercase()).unwrap_or(false);
let lower = name.to_ascii_lowercase();
let (type_str, is_hub) = if lower.starts_with("hs-") {
("script", true)
} else if lower.starts_with("s-") {
("script", false)
} else if lower.starts_with("f-") {
("flow", false)
} else {
return Err(format!("Invalid tool name prefix: {}", name));
};
Ok((type_str, is_hub, is_hashed))
}
/// Extract the hub version_id from a hashed hub script name like `Hs-{id}-...`
pub fn extract_hub_version_id_from_hashed(name: &str) -> Result<String, String> {
let rest = name
.strip_prefix("Hs-")
.ok_or_else(|| format!("Not a hashed hub name: {}", name))?;
let id = rest
.split('-')
.next()
.ok_or_else(|| format!("No version_id in hashed hub name: {}", name))?;
if id.is_empty() {
return Err(format!("Empty version_id in hashed hub name: {}", name));
}
Ok(id.to_string())
}
/// Extract a safe original-path prefix from a hashed tool name.
///
/// Given `S-u_admin_engineering__te<hash16>`, extracts the escaped prefix between
/// the type prefix (`S-`, `F-`, or `Hs-`) and the hash, un-escapes it, and
/// returns a prefix suitable for `WHERE path LIKE '{prefix}%'`.
///
/// Returns `None` if the name is too short or has an unrecognized prefix.
pub fn extract_path_prefix_from_hashed(name: &str) -> Option<String> {
let prefix_len = if name.starts_with("Hs-") {
3
} else if name.starts_with("S-") || name.starts_with("F-") {
2
} else {
return None;
};
if name.len() <= prefix_len + HASH_LEN {
return None;
}
let escaped_prefix = &name[prefix_len..name.len() - HASH_LEN];
if escaped_prefix.is_empty() {
return None;
}
// Strip trailing underscores — they may be half of a `__` pair split by truncation
let trimmed = escaped_prefix.trim_end_matches('_');
if trimmed.is_empty() {
return None;
}
Some(unescape_path(trimmed))
}
/// Un-escape a mangled path segment: `__` → `_`, standalone `_` → `/`.
fn unescape_path(s: &str) -> String {
const TEMP_PLACEHOLDER: &str = "@@UNDERSCORE@@";
s.replace("__", TEMP_PLACEHOLDER)
.replace('_', "/")
.replace(TEMP_PLACEHOLDER, "_")
}
/// Truncate a string to at most `max_len` bytes, ensuring we don't split a UTF-8 character.
fn truncate_to_char_boundary(s: &str, max_len: usize) -> &str {
if s.len() <= max_len {
return s;
}
let mut end = max_len;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
/// Reverse the transformation of a path
@@ -38,25 +158,22 @@ pub fn transform_path(path: &str, type_str: &str) -> String {
/// This is used in call_tool to get the original path, and the type of the item.
///
/// Returns: (type, original_path, is_hub)
///
/// Note: This only works for non-hashed (short) names. Hashed names must be
/// resolved via `parse_tool_prefix` + path enumeration in the runner.
pub fn reverse_transform(transformed_path: &str) -> Result<(&str, String, bool), String> {
let is_hub = transformed_path.starts_with("h");
let transformed_path = if is_hub {
transformed_path[1..].to_string()
} else {
transformed_path.to_string()
};
let type_str = if transformed_path.starts_with("s-") {
"script"
} else if transformed_path.starts_with("f-") {
"flow"
} else {
return Err(format!(
"Invalid prefix in transformed path: {}",
transformed_path
));
};
let (type_str, is_hub, is_hashed) = parse_tool_prefix(transformed_path)?;
let mangled_path = &transformed_path[2..];
if is_hashed {
return Err(
"Hashed names cannot be reverse-transformed directly; use path enumeration instead"
.to_string(),
);
}
// Strip the prefix: "hs-" (3 chars) for hub, "s-"/"f-" (2 chars) for others
let prefix_len = if is_hub { 3 } else { 2 };
let mangled_path = &transformed_path[prefix_len..];
let original_path = if is_hub {
let parts = mangled_path.split("-").collect::<Vec<&str>>();
@@ -65,11 +182,7 @@ pub fn reverse_transform(transformed_path: &str) -> Result<(&str, String, bool),
}
parts[0].to_string()
} else {
const TEMP_PLACEHOLDER: &str = "@@UNDERSCORE@@";
mangled_path
.replace("__", TEMP_PLACEHOLDER)
.replace('_', "/")
.replace(TEMP_PLACEHOLDER, "_")
unescape_path(mangled_path)
};
Ok((type_str, original_path, is_hub))
@@ -97,16 +210,13 @@ pub fn reverse_transform_key(transformed_key: &str, schema_obj: &Option<SchemaTy
let schema_obj = match schema_obj {
Some(s) => s,
None => {
// No schema available, return the key as is (best guess)
return transformed_key.to_string();
}
};
for original_key_in_schema in schema_obj.properties.keys() {
// Apply the SAME forward transformation to the schema key
let potential_transformed_key = apply_key_transformation(original_key_in_schema);
// If it matches the key we received, we found the likely original
if potential_transformed_key == transformed_key {
return original_key_in_schema.clone();
}
@@ -120,7 +230,7 @@ mod tests {
use super::*;
#[test]
fn test_transform_path() {
fn test_transform_path_short() {
assert_eq!(
transform_path("u/admin/script", "script"),
"s-u_admin_script"
@@ -130,7 +240,108 @@ mod tests {
}
#[test]
fn test_reverse_transform() {
fn test_transform_path_long_is_hashed() {
let long_path = "u/engineering/team/automation/very_long_script_name_that_exceeds_limit";
let result = transform_path(long_path, "script");
assert_eq!(result.len(), MAX_PATH_LENGTH);
assert!(result.starts_with("S-"));
let (_, _, is_hashed) = parse_tool_prefix(&result).unwrap();
assert!(is_hashed);
}
#[test]
fn test_transform_path_long_flow_is_hashed() {
let long_path = "f/engineering/team/automation/very_long_flow_name_that_exceeds_limit";
let result = transform_path(long_path, "flow");
assert_eq!(result.len(), MAX_PATH_LENGTH);
assert!(result.starts_with("F-"));
let (_, _, is_hashed) = parse_tool_prefix(&result).unwrap();
assert!(is_hashed);
}
#[test]
fn test_transform_path_hashing_is_deterministic() {
let path = "u/engineering/team/automation/very_long_script_name_that_exceeds_limit";
let a = transform_path(path, "script");
let b = transform_path(path, "script");
assert_eq!(a, b);
}
#[test]
fn test_transform_path_different_long_paths_differ() {
let a = transform_path(
"u/engineering/team/automation/very_long_script_name_that_exceeds_limit_a",
"script",
);
let b = transform_path(
"u/engineering/team/automation/very_long_script_name_that_exceeds_limit_b",
"script",
);
assert_ne!(a, b);
}
#[test]
fn test_transform_hub_path_short() {
let result = transform_hub_path(12345, "Send Slack Message");
assert_eq!(result, "hs-12345-Send_Slack_Message");
let (_, _, is_hashed) = parse_tool_prefix(&result).unwrap();
assert!(!is_hashed);
}
#[test]
fn test_transform_hub_path_long_is_hashed() {
let result = transform_hub_path(
12345,
"Send Slack Message To Channel With Very Long Description That Exceeds Limit",
);
assert_eq!(result.len(), MAX_PATH_LENGTH);
assert!(result.starts_with("Hs-12345-"));
let (_, _, is_hashed) = parse_tool_prefix(&result).unwrap();
assert!(is_hashed);
}
#[test]
fn test_extract_hub_version_id_from_hashed() {
let name = "Hs-12345-Send_Slack_Message_To_Ch9e8d7c6b5a4f3e2d";
let id = extract_hub_version_id_from_hashed(name).unwrap();
assert_eq!(id, "12345");
}
#[test]
fn test_parse_tool_prefix() {
let (t, hub, hashed) = parse_tool_prefix("S-something").unwrap();
assert_eq!(t, "script");
assert!(!hub);
assert!(hashed);
let (t, hub, hashed) = parse_tool_prefix("F-something").unwrap();
assert_eq!(t, "flow");
assert!(!hub);
assert!(hashed);
let (t, hub, hashed) = parse_tool_prefix("Hs-12345-something").unwrap();
assert_eq!(t, "script");
assert!(hub);
assert!(hashed);
let (t, hub, hashed) = parse_tool_prefix("s-u_admin_script").unwrap();
assert_eq!(t, "script");
assert!(!hub);
assert!(!hashed);
let (t, hub, hashed) = parse_tool_prefix("f-f_folder_flow").unwrap();
assert_eq!(t, "flow");
assert!(!hub);
assert!(!hashed);
let (t, hub, hashed) = parse_tool_prefix("hs-12345-summary").unwrap();
assert_eq!(t, "script");
assert!(hub);
assert!(!hashed);
}
#[test]
fn test_reverse_transform_short_names() {
let (type_str, path, is_hub) = reverse_transform("s-u_admin_script").unwrap();
assert_eq!(type_str, "script");
assert_eq!(path, "u/admin/script");
@@ -142,6 +353,70 @@ mod tests {
assert!(!is_hub);
}
#[test]
fn test_extract_path_prefix_from_hashed() {
// Generate a real hashed name and verify prefix extraction
let long_path = "u/admin/engineering/team/automation/very_long_script";
let hashed = transform_path(long_path, "script");
let (_, _, is_hashed) = parse_tool_prefix(&hashed).unwrap();
assert!(is_hashed);
let prefix = extract_path_prefix_from_hashed(&hashed).unwrap();
// The original path should start with the extracted prefix
assert!(
long_path.starts_with(&prefix),
"path '{}' should start with prefix '{}'",
long_path,
prefix
);
}
#[test]
fn test_extract_path_prefix_underscore_in_path() {
let long_path = "u/admin/my_team/automation/very_long_script_name_here";
let hashed = transform_path(long_path, "script");
let prefix = extract_path_prefix_from_hashed(&hashed).unwrap();
assert!(
long_path.starts_with(&prefix),
"path '{}' should start with prefix '{}'",
long_path,
prefix
);
}
#[test]
fn test_extract_path_prefix_rejects_invalid_prefix() {
assert!(extract_path_prefix_from_hashed("x-something").is_none());
assert!(extract_path_prefix_from_hashed("").is_none());
assert!(extract_path_prefix_from_hashed("S-").is_none());
}
#[test]
fn test_extract_path_prefix_handles_hs_prefix() {
// Hs- is 3 chars, not 2 — ensure the prefix is stripped correctly
let hashed = transform_hub_path(12345, "a]very long hub script summary that exceeds the limit");
let (_, is_hub, is_hashed) = parse_tool_prefix(&hashed).unwrap();
assert!(is_hub);
assert!(is_hashed);
let prefix = extract_path_prefix_from_hashed(&hashed);
// Should not start with 's' (leftover from Hs- if sliced at index 2)
if let Some(ref p) = prefix {
assert!(
!p.starts_with('s'),
"prefix '{}' should not start with 's' from mis-sliced Hs- prefix",
p
);
}
}
#[test]
fn test_reverse_transform_rejects_hashed_names() {
assert!(reverse_transform("S-something").is_err());
assert!(reverse_transform("F-something").is_err());
assert!(reverse_transform("Hs-12345-something").is_err());
}
#[test]
fn test_apply_key_transformation() {
assert_eq!(apply_key_transformation("my key"), "my_key");

View File

@@ -92,8 +92,10 @@ pub struct ItemSchema {
/// Trait for objects that can be converted to MCP tools
pub trait ToolableItem {
/// Get the path or identifier for this item (transformed for MCP compatibility)
fn get_path_or_id(&self) -> String;
/// Get the MCP-compatible tool name (path transformed with escaping/hashing)
fn get_transformed_path(&self) -> String;
/// Get the original full path of this item (for display in tool title)
fn get_full_path(&self) -> &str;
/// Get the summary/title of this item
fn get_summary(&self) -> &str;
/// Get the description of this item

View File

@@ -14,9 +14,9 @@ pub mod client;
// Re-export common types at crate root for convenience
pub use common::{
convert_schema_to_schema_type, is_resource_allowed, parse_mcp_scopes, transform_path, FlowInfo,
HubResponse, HubScriptInfo, ItemSchema, McpScopeConfig, ResourceInfo, ResourceType, SchemaType,
ScriptInfo, ToolableItem, WorkspaceId,
convert_schema_to_schema_type, is_resource_allowed, parse_mcp_scopes, transform_hub_path,
transform_path, FlowInfo, HubResponse, HubScriptInfo, ItemSchema, McpScopeConfig, ResourceInfo,
ResourceType, SchemaType, ScriptInfo, ToolableItem, WorkspaceId,
};
// Re-export client types at crate root for backward compatibility

View File

@@ -55,20 +55,22 @@ pub trait McpBackend: Send + Sync + Clone + 'static {
// Listing Operations
// ─────────────────────────────────────────────────────────────────
/// List scripts, optionally filtered to favorites only
/// List scripts, optionally filtered to favorites only and/or by path prefix
async fn list_scripts(
&self,
auth: &Self::Auth,
workspace_id: &str,
favorites_only: bool,
path_prefix: Option<&str>,
) -> BackendResult<Vec<ScriptInfo>>;
/// List flows, optionally filtered to favorites only
/// List flows, optionally filtered to favorites only and/or by path prefix
async fn list_flows(
&self,
auth: &Self::Auth,
workspace_id: &str,
favorites_only: bool,
path_prefix: Option<&str>,
) -> BackendResult<Vec<FlowInfo>>;
/// List resource types in workspace

View File

@@ -5,7 +5,10 @@
use crate::common::schema::extract_resource_types_from_schema;
use crate::common::scope::parse_mcp_scopes;
use crate::common::transform::{reverse_transform, reverse_transform_key};
use crate::common::transform::{
extract_hub_version_id_from_hashed, extract_path_prefix_from_hashed, parse_tool_prefix,
reverse_transform, reverse_transform_key,
};
use crate::common::types::{ResourceInfo, ToolableItem, WorkspaceId};
use crate::server::backend::{McpAuth, McpBackend};
use crate::server::endpoints::endpoint_tool_to_mcp_tool;
@@ -81,6 +84,13 @@ impl<B: McpBackend> Runner<B> {
}
}
fn find_matching_path<T: ToolableItem>(candidates: Vec<T>, request_name: &str) -> Option<String> {
candidates
.into_iter()
.find(|item| item.get_transformed_path() == request_name)
.map(|item| item.get_full_path().to_string())
}
impl<B: McpBackend> ServerHandler for Runner<B> {
fn get_info(&self) -> ServerInfo {
ServerInfo {
@@ -120,9 +130,9 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
// Fetch all items concurrently
let (scripts, flows, resource_types, hub_scripts) = tokio::try_join!(
self.backend
.list_scripts(&auth, &workspace_id, favorites_only),
.list_scripts(&auth, &workspace_id, favorites_only, None),
self.backend
.list_flows(&auth, &workspace_id, favorites_only),
.list_flows(&auth, &workspace_id, favorites_only, None),
self.backend.list_resource_types(&auth, &workspace_id),
async {
if let Some(ref apps) = scope_config.hub_apps {
@@ -231,17 +241,6 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
let scope_config =
parse_mcp_scopes(scopes).map_err(|e| ErrorData::internal_error(e, None))?;
// Handle truncated tool names
if request.name.ends_with("_TRUNC") {
return Ok(CallToolResult::error(vec![rmcp::model::Annotated::new(
rmcp::model::RawContent::Text(rmcp::model::RawTextContent {
text: "Tool path is too long. Consider shortening it to make it compatible with MCP.".to_string(),
meta: None,
}),
None,
)]));
}
let args = request.arguments.map(Value::Object).unwrap_or(Value::Null);
// Check if this is an endpoint tool
@@ -274,10 +273,58 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
}
}
// Not an endpoint tool - parse as script/flow
let (tool_type, path, is_hub) = reverse_transform(&request.name).map_err(|e| {
ErrorData::internal_error(format!("Failed to parse tool name: {}", e), None)
})?;
// Resolve the tool name to (type, path, is_hub)
let (type_str, is_hub, is_hashed) =
parse_tool_prefix(&request.name).map_err(|e| {
ErrorData::internal_error(format!("Failed to parse tool name: {}", e), None)
})?;
let (tool_type, path, is_hub) = if !is_hashed {
reverse_transform(&request.name).map_err(|e| {
ErrorData::internal_error(format!("Failed to parse tool name: {}", e), None)
})?
} else if is_hub {
let version_id =
extract_hub_version_id_from_hashed(&request.name).map_err(|e| {
ErrorData::internal_error(
format!("Failed to extract hub version_id: {}", e),
None,
)
})?;
(type_str, version_id, true)
} else {
let path_prefix = extract_path_prefix_from_hashed(&request.name);
let favorites_only = scope_config.favorites;
let matched_path = if type_str == "script" {
find_matching_path(
self.backend
.list_scripts(&auth, &workspace_id, favorites_only, path_prefix.as_deref())
.await
.map_err(|e| ErrorData::internal_error(e.message, None))?,
&request.name,
)
} else {
find_matching_path(
self.backend
.list_flows(&auth, &workspace_id, favorites_only, path_prefix.as_deref())
.await
.map_err(|e| ErrorData::internal_error(e.message, None))?,
&request.name,
)
};
let matched_path = matched_path.ok_or_else(|| {
ErrorData::internal_error(
format!(
"No {} found matching hashed tool name '{}'",
type_str, request.name
),
None,
)
})?;
(type_str, matched_path, false)
};
// Validate script/flow scope
if !is_hub && scope_config.granular {

View File

@@ -9,7 +9,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use crate::common::schema::{convert_schema_to_schema_type, make_schema_compatible};
use crate::common::transform::transform_path;
use crate::common::transform::{transform_hub_path, transform_path};
use crate::common::types::{
FlowInfo, HubScriptInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo, ToolableItem,
};
@@ -17,10 +17,14 @@ use crate::server::backend::McpBackend;
/// Implementation of ToolableItem for ScriptInfo
impl ToolableItem for ScriptInfo {
fn get_path_or_id(&self) -> String {
fn get_transformed_path(&self) -> String {
transform_path(&self.path, "script")
}
fn get_full_path(&self) -> &str {
&self.path
}
fn get_summary(&self) -> &str {
self.summary.as_deref().unwrap_or("No summary")
}
@@ -48,10 +52,14 @@ impl ToolableItem for ScriptInfo {
/// Implementation of ToolableItem for FlowInfo
impl ToolableItem for FlowInfo {
fn get_path_or_id(&self) -> String {
fn get_transformed_path(&self) -> String {
transform_path(&self.path, "flow")
}
fn get_full_path(&self) -> &str {
&self.path
}
fn get_summary(&self) -> &str {
self.summary.as_deref().unwrap_or("No summary")
}
@@ -79,10 +87,13 @@ impl ToolableItem for FlowInfo {
/// Implementation of ToolableItem for HubScriptInfo
impl ToolableItem for HubScriptInfo {
fn get_path_or_id(&self) -> String {
let id = self.version_id;
fn get_transformed_path(&self) -> String {
let summary = self.summary.as_deref().unwrap_or("No summary");
format!("hs-{}-{}", id, summary.replace(" ", "_"))
transform_hub_path(self.version_id, summary)
}
fn get_full_path(&self) -> &str {
self.summary.as_deref().unwrap_or("No summary")
}
fn get_summary(&self) -> &str {
@@ -124,7 +135,7 @@ pub fn create_tool_from_item<T: ToolableItem, B: McpBackend>(
resources_types: &[ResourceType],
) -> Tool {
let is_hub = item.is_hub();
let path = item.get_path_or_id();
let path = item.get_transformed_path();
let item_type = item.item_type();
let description = format!(
"This is a {} named `{}` with the following description: `{}`.{}",
@@ -170,15 +181,24 @@ pub fn create_tool_from_item<T: ToolableItem, B: McpBackend>(
}
};
let title = {
let summary = item.get_summary();
if summary == "No summary" {
item.get_full_path().to_string()
} else {
summary.to_string()
}
};
Tool {
name: Cow::Owned(path),
description: Some(Cow::Owned(description)),
input_schema: Arc::new(input_schema_map),
title: Some(item.get_summary().to_string()),
title: Some(title.clone()),
output_schema: None,
icons: None,
annotations: Some(ToolAnnotations {
title: Some(item.get_summary().to_string()),
title: Some(title),
read_only_hint: Some(false), // Can modify environment
destructive_hint: Some(true), // Can potentially be destructive
idempotent_hint: Some(false), // Are not guaranteed to be idempotent

View File

@@ -536,13 +536,6 @@ async fn delete_variable(
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM resource WHERE path = $1 AND workspace_id = $2",
path,
w_id
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,

View File

@@ -151,6 +151,7 @@ pub struct RunJob {
pub args: serde_json::Map<String, serde_json::Value>,
pub scheduled_for_o: Option<chrono::DateTime<chrono::Utc>>,
pub email: String,
pub job_id: Option<Uuid>,
}
impl From<JobPayload> for RunJob {
@@ -160,6 +161,7 @@ impl From<JobPayload> for RunJob {
args: Default::default(),
scheduled_for_o: None,
email: "test@windmill.dev".to_string(),
job_id: None,
}
}
}
@@ -183,8 +185,13 @@ impl RunJob {
self
}
pub fn job_id(mut self, id: Uuid) -> Self {
self.job_id = Some(id);
self
}
pub async fn push(self, db: &Pool<Postgres>) -> Uuid {
let RunJob { payload, args, scheduled_for_o, email } = self;
let RunJob { payload, args, scheduled_for_o, email, job_id } = self;
let mut hm_args = std::collections::HashMap::new();
for (k, v) in args {
hm_args.insert(k, windmill_common::worker::to_raw_value(&v));
@@ -206,7 +213,7 @@ impl RunJob {
/* parent_job */ None,
/* root job */ None,
/* flow_innermost_root_job */ None,
/* job_id */ None,
/* job_id */ job_id,
/* is_flow_step */ false,
/* same_worker */ false,
None,
@@ -230,7 +237,7 @@ impl RunJob {
/// Push the job as a specific user (for testing permissions)
pub async fn push_as(self, db: &Pool<Postgres>, username: &str, email: &str) -> Uuid {
let RunJob { payload, args, scheduled_for_o, .. } = self;
let RunJob { payload, args, scheduled_for_o, job_id, .. } = self;
let mut hm_args = std::collections::HashMap::new();
for (k, v) in args {
hm_args.insert(k, windmill_common::worker::to_raw_value(&v));
@@ -252,7 +259,7 @@ impl RunJob {
/* parent_job */ None,
/* root job */ None,
/* flow_innermost_root_job */ None,
/* job_id */ None,
/* job_id */ job_id,
/* is_flow_step */ false,
/* same_worker */ false,
None,

View File

@@ -1036,6 +1036,12 @@ pub async fn handle_bun_job(
let _ = write_file(job_dir, "main.ts", inner_content)?;
} else if !annotation.native && codebase.is_none() {
let _ = write_file(job_dir, "package.json", r#"{ "type": "module" }"#)?;
} else if codebase.is_some() {
// Write a valid fallback package.json for codebase mode. Without this,
// nsjail creates an empty 0-byte file (from the mandatory: false mount)
// which Node.js fails to parse as JSON (ERR_INVALID_PACKAGE_CONFIG).
// If the codebase TAR includes a package.json, it will overwrite this.
let _ = write_file(job_dir, "package.json", "{}")?;
};
let common_bun_proc_envs: HashMap<String, String> =

View File

@@ -4771,6 +4771,7 @@ mount {{
// Clean up absolute-path symlinks created by setup_volume_mount_paths
if !is_sandboxing_enabled() {
#[allow(unused_variables)] // state is only used on unix
for state in &volume_setup.states {
#[cfg(unix)]
if state.mount.target.starts_with('/') {

View File

@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.650.0";
export const VERSION = "v1.651.1";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({

View File

@@ -39,7 +39,10 @@ export const scriptBootstrapCode = {
}
`,
bun: `export async function main() {
bun: `// there are multiple modes to add as header: //nobundling //native //npm //nodejs
// https://www.windmill.dev/docs/getting_started/scripts_quickstart/typescript#modes
export async function main() {
return "Hello world";
}
`,

View File

@@ -67,7 +67,7 @@ export {
workspaceAdd,
};
export const VERSION = "1.650.0";
export const VERSION = "1.651.1";
// Re-exported from constants.ts to maintain backwards compatibility
export { WM_FORK_PREFIX } from "./core/constants.ts";

View File

@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.650.0",
"version": "1.651.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.650.0",
"version": "1.651.1",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.650.0",
"version": "1.651.1",
"scripts": {
"dev": "vite dev",
"build": "vite build",

View File

@@ -133,7 +133,7 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
])
})
}
promise = CancelablePromiseUtils.pipe(promise, () => {
promise = CancelablePromiseUtils.finallyDo(promise, () => {
if (slowStreamIntervalId) {
clearInterval(slowStreamIntervalId)
slowStreamIntervalId = undefined
@@ -161,7 +161,7 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
)
}, 15000)
paramChangePromise = loadJobsIntern(false, size)
paramChangePromise = CancelablePromiseUtils.pipe(paramChangePromise, () => {
paramChangePromise = CancelablePromiseUtils.finallyDo(paramChangePromise, () => {
if (slowStreamIntervalId) {
clearInterval(slowStreamIntervalId)
slowStreamIntervalId = undefined
@@ -687,6 +687,10 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
return () => {
clearTimeout(paramChangeTimeout)
paramChangePromise?.cancel()
if (slowStreamIntervalId) {
clearInterval(slowStreamIntervalId)
slowStreamIntervalId = undefined
}
}
})
$effect(() => {

View File

@@ -17,7 +17,7 @@
import { safeSelectItems } from '../select/utils.svelte'
import TokenDisplay from './TokenDisplay.svelte'
import ScopeSelector from './ScopeSelector.svelte'
import Alert from '../common/alert/Alert.svelte'
import FolderPicker from '../FolderPicker.svelte'
import TextInput from '../text_input/TextInput.svelte'
import Select from '../select/Select.svelte'
@@ -43,8 +43,6 @@
displayCreateToken = true
}: Props = $props()
// MCP clients do not allow names longer than 60 characters, here we use 55 because final tool name server side will add ~5 characters
const MAX_PATH_LENGTH = 55
let newToken = $state<string | undefined>(undefined)
let newMcpToken = $state<string | undefined>(undefined)
@@ -185,17 +183,6 @@
? `You do not have any favorite scripts or flows. You can favorite some scripts and flows to include them, or change the scope to "All scripts/flows" to include all your scripts and flows.`
: `You do not have any scripts or flows in the selected folder.`
)
const longPathRunnables = $derived(
includedRunnables.filter((path) => path.length > MAX_PATH_LENGTH)
)
const validRunnables = $derived(
includedRunnables.filter((path) => path.length <= MAX_PATH_LENGTH)
)
const longPathWarning = $derived(
longPathRunnables.length > 0
? `${longPathRunnables.length} script(s)/flow(s) have paths longer than 60 characters and will be excluded from MCP tools. Consider shortening the paths: ${longPathRunnables.slice(0, 3).join(', ')}${longPathRunnables.length > 3 ? ` and ${longPathRunnables.length - 3} more` : ''}`
: ''
)
$effect(() => {
if (mcpCreationMode) {
@@ -627,23 +614,18 @@
</div>
{:else}
<div class="flex flex-col gap-2 col-span-2 pr-4">
{#if longPathWarning}
<Alert type="warning" title="Some paths are too long" size="xs">
{longPathWarning}
</Alert>
{/if}
<span class="block text-xs">Scripts & Flows that will be available via MCP</span>
<div class="flex flex-wrap gap-1">
{#if validRunnables.length > 0 && validRunnables.length <= 5}
{#each validRunnables as scriptOrFlow}
{#if includedRunnables.length > 0 && includedRunnables.length <= 5}
{#each includedRunnables as scriptOrFlow}
<Badge rounded small color="blue">{scriptOrFlow}</Badge>
{/each}
{:else if validRunnables.length > 0}
{#each validRunnables.slice(0, 3) as scriptOrFlow}
{:else if includedRunnables.length > 0}
{#each includedRunnables.slice(0, 3) as scriptOrFlow}
<Badge rounded small color="blue">{scriptOrFlow}</Badge>
{/each}
<Badge rounded small color="dark-gray">
+{validRunnables.length - 3} more
+{includedRunnables.length - 3} more
</Badge>
{:else}
<p class="text-xs text-primary">

View File

@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.650.0"
wmill = ">=1.651.1"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"

View File

@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.650.0
version: 1.651.1
title: OpenFlow Spec
contact:
name: Ruben Fiszel

View File

@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.650.0'
ModuleVersion = '1.651.1'
# Supported PSEditions
# CompatiblePSEditions = @()

View File

@@ -108,6 +108,21 @@ SET s3_secret_access_key='80yMndIMcyXwEujxVNINQbf0tBlIzRaLPyM2m1n4';
)
print(file_key)
@unittest.skip("skipping")
def test_delete_s3_object(self):
# Upload a temporary file
s3_obj = wmill.write_s3_file(
S3Object(s3="_wmill_test_delete_s3_object.txt"), b"delete_s3_object test content"
)
# Verify it exists
content = wmill.load_s3_file(s3_obj)
self.assertEqual(content, b"delete_s3_object test content")
# Delete it
wmill.delete_s3_object(s3_obj)
# Verify it's gone
with self.assertRaises(Exception):
wmill.load_s3_file(s3_obj)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.650.0"
version = "1.651.1"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"

View File

@@ -984,6 +984,40 @@ class Windmill:
raise Exception("Could not write file to S3") from e
return S3Object(s3=response["file_key"], storage=s3object.get("storage") if s3object else None)
def delete_s3_object(
self,
s3object: S3Object | str,
s3_resource_path: str | None = None,
) -> None:
"""
Permanently delete a file from the workspace S3 bucket.
'''python
from wmill import S3Object
s3_obj = S3Object(s3="/path/to/my_file.txt")
client.delete_s3_object(s3_obj)
'''
"""
s3object = parse_s3_object(s3object)
query_params: Dict[str, Any] = {"file_key": s3object["s3"]}
if s3_resource_path is not None and s3_resource_path != "":
query_params["s3_resource_path"] = s3_resource_path
if "storage" in s3object and s3object["storage"] is not None:
query_params["storage"] = s3object["storage"]
try:
resp = self.client.delete(
f"/w/{self.workspace}/job_helpers/delete_s3_file",
params=query_params,
)
resp.raise_for_status()
except httpx.HTTPStatusError as err:
error = f"{err.request.url}: {err.response.status_code}, {err.response.text}"
logger.error(error)
raise Exception(error)
except Exception as e:
raise Exception("Could not delete file from S3") from e
def sign_s3_objects(self, s3_objects: list[S3Object | str]) -> list[S3Object]:
"""Sign S3 objects for use by anonymous users in public apps.
@@ -1692,6 +1726,20 @@ def write_s3_file(
)
@init_global_client
def delete_s3_object(
s3object: S3Object | str,
s3_resource_path: str | None = None,
) -> None:
"""
Permanently delete a file from the workspace S3 bucket.
"""
return _client.delete_s3_object(
s3object,
s3_resource_path if s3_resource_path != "" else None,
)
@init_global_client
def sign_s3_objects(s3_objects: list[S3Object | str]) -> list[S3Object]:
"""
@@ -2368,4 +2416,4 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]]:
name, schema = name.split(":", 1)
if not name:
name = "main"
return name, schema
return name, schema

View File

@@ -1,6 +1,6 @@
{
"name": "@windmill/windmill",
"version": "1.650.0",
"version": "1.651.1",
"exports": "./src/index.ts",
"publish": {
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"]

View File

@@ -1,7 +1,7 @@
{
"name": "windmill-client",
"description": "Windmill SDK client for browsers and Node.js",
"version": "1.650.0",
"version": "1.651.1",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"sideEffects": false,

View File

@@ -1 +1 @@
1.650.0
1.651.1