Compare commits

..

3 Commits

69 changed files with 414 additions and 2379 deletions

74
.workmux.yaml Normal file
View File

@@ -0,0 +1,74 @@
main_branch: main
merge_strategy: rebase
# worktree_dir: .worktrees
worktree_naming: basename
worktree_prefix: ""
# Default: "wm-"
window_prefix: "wm-"
auto_name:
model: "gemini-2.5-flash-lite"
system_prompt: |
Generate a concise git branch name based on the task description.
Rules:
- Use kebab-case (lowercase with hyphens)
- Keep it short: 1-3 words, max 4 if necessary
- Focus on the core task/feature, not implementation details
- No prefixes like feat/, fix/, chore/
Examples of good branch names:
- "Add dark mode toggle" → dark-mode
- "Fix the search results not showing" → fix-search
- "Refactor the authentication module" → auth-refactor
- "Add CSV export to reports" → export-csv
- "Shell completion is broken" → shell-completion
Output ONLY the branch name, nothing else.
background: true
# Commands to run in new worktree before tmux window opens.
# These block window creation - use for short tasks only.
# Use "<global>" to inherit from global config.
# Set to empty list to disable: `post_create: []`
# post_create:
# - "<global>"
# - mise use
post_create:
- ./scripts/worktree-env
pre_remove:
- ./scripts/worktree-cleanup
panes:
- command: >-
claude --dangerously-skip-permissions --append-system-prompt
"You are running inside a tmux session with other panes running services.\n
Pane layout (current window):\n
- Pane 0: this pane (claude agent)\n
- Pane 1: backend (cargo watch -x run)\n
- Pane 2: frontend (npm run dev)\n\n
To check logs, use: \`tmux capture-pane -t .1 -p -S -50\` (backend) or \`tmux capture-pane -t .2 -p -S -50\` (frontend).\n
When restarting backend or frontend, make sure to use the ports listed in .env.local.\n
Because we are running backend with cargo watch, to verify your changes, just check the logs in the backend pane. No need for cargo check.\n\n
IMPORTANT: Read docs/autonomous-mode.md before starting any work."
focus: true
- command: 'ROOT="$(git rev-parse --show-toplevel)"; [ -f "$ROOT/.env.local" ] && source "$ROOT/.env.local"; cd "$ROOT/backend" && PORT=${BACKEND_PORT:-8000} cargo watch -x "run ${CARGO_FEATURES:+--features $CARGO_FEATURES}"'
split: horizontal
- command: 'ROOT="$(git rev-parse --show-toplevel)"; [ -f "$ROOT/.env.local" ] && source "$ROOT/.env.local"; cd "$ROOT/frontend" && npm run generate-backend-client && REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}} npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0'
split: vertical
files:
copy:
- backend/.env
- scripts/
- wm-ts-nav/target/release/wm-ts-nav
sandbox:
enabled: false
toolchain: off

View File

@@ -1,25 +1,5 @@
# Changelog
## [1.660.0](https://github.com/windmill-labs/windmill/compare/v1.659.1...v1.660.0) (2026-03-18)
### Features
* **cli:** use local scripts when previewing flows ([#8365](https://github.com/windmill-labs/windmill/issues/8365)) ([435de95](https://github.com/windmill-labs/windmill/commit/435de95e7d5c9433dafac5369cfc533fd738fc22))
* MCP server readiness for Anthropic connectors directory ([#8438](https://github.com/windmill-labs/windmill/issues/8438)) ([1cfb40b](https://github.com/windmill-labs/windmill/commit/1cfb40bdaa877f1616fc1c1cf5fb6b6aa1832b86))
### Bug Fixes
* exclude wm_deployers group from CE group limit check ([#8429](https://github.com/windmill-labs/windmill/issues/8429)) ([9a6ce44](https://github.com/windmill-labs/windmill/commit/9a6ce44c8414810292ebc8a1ae64950ee2c76307))
* prevent AI agent tool jobs from becoming zombies on cancellation ([#8437](https://github.com/windmill-labs/windmill/issues/8437)) ([f4489cb](https://github.com/windmill-labs/windmill/commit/f4489cbe645489a892994c70d17df2284b494568))
* show cancelled WAC jobs as done in workflow timeline ([#8436](https://github.com/windmill-labs/windmill/issues/8436)) ([bee9282](https://github.com/windmill-labs/windmill/commit/bee928276e098ce7b17e20af74e34458e5c5353e))
### Performance Improvements
* cache composer vendor dir to skip reinstall on repeated php executions ([#8330](https://github.com/windmill-labs/windmill/issues/8330)) ([66a8e84](https://github.com/windmill-labs/windmill/commit/66a8e844a64d91d57dcabb7ad31d9308dec99032))
## [1.659.1](https://github.com/windmill-labs/windmill/compare/v1.659.0...v1.659.1) (2026-03-18)

View File

@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_completed SET\n workflow_as_code_status = jsonb_set(\n jsonb_set(\n workflow_as_code_status,\n array[$1],\n COALESCE(workflow_as_code_status->$1, '{}'::jsonb)\n ),\n array[$1, 'duration_ms'],\n to_jsonb($2::bigint)\n )\n WHERE id = $3 AND workflow_as_code_status IS NOT NULL",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8",
"Uuid"
]
},
"nullable": []
},
"hash": "29935e89475f637d765c516f1aa2be2f0f31fb50d519b42a056d0d73417599a3"
}

View File

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

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM script s WHERE s.workspace_id = $1 AND s.hash NOT IN (\n SELECT DISTINCT ON (path) hash FROM script\n WHERE workspace_id = $1 AND deleted = false AND draft_only IS NOT TRUE\n ORDER BY path, created_at DESC\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "9517395ac7230ab7c40c03ddd2a95fd6118b329a4421c9e8022df90ff7e775c8"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM group_ WHERE name != 'all' AND name != 'error_handler' AND name != 'slack' AND name != 'wm_deployers'",
"query": "SELECT COUNT(*) FROM group_ WHERE name != 'all' AND name != 'error_handler' AND name != 'slack'",
"describe": {
"columns": [
{
@@ -16,5 +16,5 @@
null
]
},
"hash": "bc6ebef9d41aba232f115f95404922c6054df01fa7f38f5d15d6d4af6c726a3c"
"hash": "cec906e3bcfe5b477f23e8bb44500b97166c789ab1cf6487679d092eebf3a48b"
}

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM app_version av\n JOIN app a ON a.id = av.app_id\n WHERE a.workspace_id = $1 AND av.id != a.versions[array_upper(a.versions, 1)]",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "d592ba371d4ad6e7f1bcffc01749f4753b6fe4e42413cce3d55f92797c856f35"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM v2_job_debounce_batch WHERE debounce_batch = (\n SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = $1\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "e050734d7642b26f8859982c55ec2c8b1fc14a8de665b15a2f2dfd6e0b5b7fdf"
}

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM flow_version fv\n JOIN flow f ON f.workspace_id = fv.workspace_id AND f.path = fv.path\n WHERE fv.workspace_id = $1 AND fv.id != f.versions[array_upper(f.versions, 1)]",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "ed202f69f5f0a8f21f2dbfcae98b9a94d039362537b050913ea2b7534f85a047"
}

View File

@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT j.id FROM v2_job j\n JOIN v2_job_queue q ON q.id = j.id\n WHERE j.parent_job = $1 AND j.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false
]
},
"hash": "fc27f306a9399205eaa0946897d797e5b792674c353acb41bd4e7133e19e2a03"
}

8
backend/.workmux.yaml Normal file
View File

@@ -0,0 +1,8 @@
panes:
# Pane 1: Install dependencies, then start dev server
- command: cargo run
# Pane 2: AI agent
- command: <agent>
split: horizontal
focus: true

159
backend/Cargo.lock generated
View File

@@ -1885,20 +1885,19 @@ dependencies = [
[[package]]
name = "borsh"
version = "1.6.1"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a"
checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f"
dependencies = [
"borsh-derive",
"bytes",
"cfg_aliases 0.2.1",
]
[[package]]
name = "borsh-derive"
version = "1.6.1"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59"
checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c"
dependencies = [
"once_cell",
"proc-macro-crate",
@@ -15742,7 +15741,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"async-nats",
@@ -15809,7 +15808,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15822,7 +15821,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"argon2",
@@ -15963,7 +15962,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15986,7 +15985,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15999,7 +15998,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16025,7 +16024,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -16035,7 +16034,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16052,7 +16051,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -16075,7 +16074,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16098,7 +16097,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16114,7 +16113,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16134,7 +16133,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16154,7 +16153,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16168,7 +16167,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"async-nats",
@@ -16196,7 +16195,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16221,7 +16220,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"axum 0.7.9",
"flate2",
@@ -16239,7 +16238,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16260,7 +16259,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16280,7 +16279,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16310,7 +16309,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16337,7 +16336,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"lazy_static",
"serde",
@@ -16349,7 +16348,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"argon2",
"axum 0.7.9",
@@ -16372,7 +16371,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16386,7 +16385,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16417,7 +16416,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"chrono",
"lazy_static",
@@ -16431,7 +16430,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16450,7 +16449,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"aes-gcm",
"anyhow",
@@ -16549,7 +16548,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -16568,7 +16567,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"regex",
"serde",
@@ -16583,7 +16582,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -16607,7 +16606,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"futures",
@@ -16624,7 +16623,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -16640,7 +16639,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16661,7 +16660,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16692,7 +16691,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"async-oauth2",
@@ -16716,7 +16715,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"async-stream",
@@ -16750,7 +16749,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"futures",
@@ -16768,7 +16767,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -16777,7 +16776,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16789,7 +16788,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"serde_json",
@@ -16801,7 +16800,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"gosyn",
@@ -16813,7 +16812,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16825,7 +16824,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"serde_json",
@@ -16837,7 +16836,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"nu-parser",
@@ -16848,7 +16847,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16859,7 +16858,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16871,7 +16870,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16882,7 +16881,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -16906,7 +16905,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16920,7 +16919,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16937,7 +16936,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16951,7 +16950,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"serde",
@@ -16963,7 +16962,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16981,7 +16980,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -16997,7 +16996,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -17013,7 +17012,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"serde",
@@ -17024,7 +17023,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -17061,7 +17060,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"const_format",
@@ -17099,7 +17098,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -17110,7 +17109,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -17139,7 +17138,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -17162,7 +17161,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17195,7 +17194,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17215,7 +17214,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17249,7 +17248,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17284,7 +17283,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17307,7 +17306,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17331,7 +17330,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"async-nats",
@@ -17355,7 +17354,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17390,7 +17389,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17418,7 +17417,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17441,7 +17440,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"bitflags 2.9.4",
@@ -17459,7 +17458,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"anyhow",
"async-once-cell",
@@ -17566,7 +17565,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.660.0"
version = "1.659.1"
dependencies = [
"bytes",
"futures",

View File

@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.660.0"
version = "1.659.1"
authors.workspace = true
edition.workspace = true
@@ -82,7 +82,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal"]
[workspace.package]
version = "1.660.0"
version = "1.659.1"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"

View File

@@ -1 +1 @@
06a848ab04e3f43978b92f0ceee019e9e2fe50f1
878cc2044717e0177228529a50433fe2768e70b5

View File

@@ -571,16 +571,16 @@ async fn test_volume_sql_worker_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
std::fs::write(vol_dir.join("hello.txt"), b"hello from volume")?;
// 3. Push the job and run with SQL-connected worker
let code = r#"// volume: test-vol data
let code = r#"// volume: test-vol /tmp/data
import { readFileSync, writeFileSync, existsSync } from "fs";
export function main() {
const content = readFileSync("data/hello.txt", "utf-8");
writeFileSync("data/output.txt", "written by sql worker");
const content = readFileSync("/tmp/data/hello.txt", "utf-8");
writeFileSync("/tmp/data/output.txt", "written by sql worker");
return {
read_content: content,
output_exists: existsSync("data/output.txt"),
output_exists: existsSync("/tmp/data/output.txt"),
};
}"#;

View File

@@ -452,7 +452,7 @@ async fn create_flow(
.await?;
if nb_flows.unwrap_or(0) >= 1000 {
return Err(Error::BadRequest(
"You have reached the maximum number of flows (1000) on cloud. Check your usage in Workspace Settings > General > Cloud Quotas. Contact support@windmill.dev to increase the limit"
"You have reached the maximum number of flows (1000) on cloud. Contact support@windmill.dev to increase the limit"
.to_string(),
));
}

View File

@@ -215,12 +215,12 @@ pub async fn require_is_owner(
}
async fn _check_nb_of_groups(db: &DB) -> Result<()> {
let nb_groups = sqlx::query_scalar!("SELECT COUNT(*) FROM group_ WHERE name != 'all' AND name != 'error_handler' AND name != 'slack' AND name != 'wm_deployers'",)
let nb_groups = sqlx::query_scalar!("SELECT COUNT(*) FROM group_ WHERE name != 'all' AND name != 'error_handler' AND name != 'slack'",)
.fetch_one(db)
.await?;
if nb_groups.unwrap_or(0) >= 3 {
return Err(Error::BadRequest(
"You have reached the maximum number of groups (3 outside of native groups 'all', 'slack', 'error_handler' and 'wm_deployers') without an enterprise license"
"You have reached the maximum number of groups (3 outside of native groups 'all', 'slack' and 'error_handler') without an enterprise license"
.to_string(),
));
}

View File

@@ -104,8 +104,6 @@ pub struct Input {
created_by: String,
is_public: bool,
success: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
is_preview: bool,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
@@ -115,7 +113,6 @@ pub struct CompletedJobMini {
args: Option<sqlx::types::Json<Box<serde_json::value::RawValue>>>,
created_by: String,
success: bool,
is_preview: bool,
}
#[derive(Deserialize)]
@@ -156,10 +153,9 @@ async fn get_input_history(
let inner_limit = 2 * (per_page + offset);
let sql = &format!(
"SELECT id, completed_at, created_by, args, success, is_preview FROM (\
"SELECT id, completed_at, created_by, args, success FROM (\
SELECT id, v2_job_completed.completed_at, created_by, 'null'::jsonb as args, \
status = 'success' as success, \
kind IN ('preview', 'flowpreview') as is_preview \
status = 'success' as success \
FROM v2_job JOIN v2_job_completed USING (id) \
WHERE v2_job.workspace_id = $3 AND {} = $1 AND kind = any($2) \
{args_query} AND v2_job_completed.status != 'skipped' {include_non_root} \
@@ -213,7 +209,6 @@ async fn get_input_history(
created_by: row.created_by,
is_public: true,
success: row.success,
is_preview: row.is_preview,
});
}
@@ -311,7 +306,6 @@ async fn list_saved_inputs(
created_at: row.created_at,
is_public: row.is_public,
success: true,
is_preview: false,
})
}

View File

@@ -636,7 +636,7 @@ async fn create_script_internal<'c>(
.await?;
if nb_scripts.unwrap_or(0) >= 5000 {
return Err(Error::BadRequest(
"You have reached the maximum number of scripts (5000) on cloud. Check your usage in Workspace Settings > General > Cloud Quotas. Contact support@windmill.dev to increase the limit"
"You have reached the maximum number of scripts (5000) on cloud. Contact support@windmill.dev to increase the limit"
.to_string(),
));
}

View File

@@ -158,8 +158,6 @@ pub fn workspaced_service() -> Router {
post(update_protection_rule).delete(delete_protection_rule),
)
.route("/log_chat", post(log_ai_chat))
.route("/cloud_quotas", get(get_cloud_quotas))
.route("/prune_versions", post(prune_versions))
}
pub fn global_service() -> Router {
Router::new()
@@ -5400,199 +5398,3 @@ async fn log_ai_chat(
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Serialize)]
struct QuotaInfo {
used: i64,
limit: i64,
prunable: i64,
}
#[derive(Serialize)]
struct CloudQuotas {
scripts: QuotaInfo,
flows: QuotaInfo,
apps: QuotaInfo,
variables: QuotaInfo,
resources: QuotaInfo,
}
async fn get_cloud_quotas(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<CloudQuotas> {
require_admin(authed.is_admin, &authed.username)?;
if !*CLOUD_HOSTED {
return Err(Error::BadRequest(
"Cloud quotas are only available on cloud-hosted instances".to_string(),
));
}
let scripts_used =
sqlx::query_scalar!("SELECT COUNT(*) FROM script WHERE workspace_id = $1", &w_id)
.fetch_one(&db)
.await?
.unwrap_or(0);
let scripts_prunable = sqlx::query_scalar!(
"SELECT COUNT(*) FROM script s WHERE s.workspace_id = $1 AND s.hash NOT IN (
SELECT DISTINCT ON (path) hash FROM script
WHERE workspace_id = $1 AND deleted = false AND draft_only IS NOT TRUE
ORDER BY path, created_at DESC
)",
&w_id
)
.fetch_one(&db)
.await?
.unwrap_or(0);
let flows_used =
sqlx::query_scalar!("SELECT COUNT(*) FROM flow WHERE workspace_id = $1", &w_id)
.fetch_one(&db)
.await?
.unwrap_or(0);
let flows_prunable = sqlx::query_scalar!(
"SELECT COUNT(*) FROM flow_version fv
JOIN flow f ON f.workspace_id = fv.workspace_id AND f.path = fv.path
WHERE fv.workspace_id = $1 AND fv.id != f.versions[array_upper(f.versions, 1)]",
&w_id
)
.fetch_one(&db)
.await?
.unwrap_or(0);
let apps_used = sqlx::query_scalar!("SELECT COUNT(*) FROM app WHERE workspace_id = $1", &w_id)
.fetch_one(&db)
.await?
.unwrap_or(0);
let apps_prunable = sqlx::query_scalar!(
"SELECT COUNT(*) FROM app_version av
JOIN app a ON a.id = av.app_id
WHERE a.workspace_id = $1 AND av.id != a.versions[array_upper(a.versions, 1)]",
&w_id
)
.fetch_one(&db)
.await?
.unwrap_or(0);
let variables_used = sqlx::query_scalar!(
"SELECT COUNT(*) FROM variable WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?
.unwrap_or(0);
let resources_used = sqlx::query_scalar!(
"SELECT COUNT(*) FROM resource WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?
.unwrap_or(0);
Ok(Json(CloudQuotas {
scripts: QuotaInfo { used: scripts_used, limit: 5000, prunable: scripts_prunable },
flows: QuotaInfo { used: flows_used, limit: 1000, prunable: flows_prunable },
apps: QuotaInfo { used: apps_used, limit: 1000, prunable: apps_prunable },
variables: QuotaInfo { used: variables_used, limit: 10000, prunable: 0 },
resources: QuotaInfo { used: resources_used, limit: 10000, prunable: 0 },
}))
}
#[derive(Deserialize)]
struct PruneVersionsRequest {
resource_type: String,
}
#[derive(Serialize)]
struct PruneVersionsResponse {
pruned: u64,
}
async fn prune_versions(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(req): Json<PruneVersionsRequest>,
) -> JsonResult<PruneVersionsResponse> {
require_admin(authed.is_admin, &authed.username)?;
if !*CLOUD_HOSTED {
return Err(Error::BadRequest(
"Version pruning is only available on cloud-hosted instances".to_string(),
));
}
let pruned = match req.resource_type.as_str() {
"scripts" => {
let result = sqlx::query(
"DELETE FROM script
WHERE workspace_id = $1 AND hash NOT IN (
SELECT DISTINCT ON (path) hash FROM script
WHERE workspace_id = $1 AND deleted = false AND draft_only IS NOT TRUE
ORDER BY path, created_at DESC
)",
)
.bind(&w_id)
.execute(&db)
.await?;
result.rows_affected()
}
"flows" => {
let deleted = sqlx::query(
"DELETE FROM flow_version fv
USING flow f
WHERE fv.workspace_id = f.workspace_id AND fv.path = f.path
AND fv.workspace_id = $1
AND fv.id != f.versions[array_upper(f.versions, 1)]",
)
.bind(&w_id)
.execute(&db)
.await?;
sqlx::query(
"UPDATE flow SET versions = ARRAY[versions[array_upper(versions, 1)]]
WHERE workspace_id = $1 AND array_length(versions, 1) > 1",
)
.bind(&w_id)
.execute(&db)
.await?;
deleted.rows_affected()
}
"apps" => {
let deleted = sqlx::query(
"DELETE FROM app_version av
USING app a
WHERE av.app_id = a.id AND a.workspace_id = $1
AND av.id != a.versions[array_upper(a.versions, 1)]",
)
.bind(&w_id)
.execute(&db)
.await?;
sqlx::query(
"UPDATE app SET versions = ARRAY[versions[array_upper(versions, 1)]]
WHERE workspace_id = $1 AND array_length(versions, 1) > 1",
)
.bind(&w_id)
.execute(&db)
.await?;
deleted.rows_affected()
}
_ => {
return Err(Error::BadRequest(format!(
"Invalid resource type '{}'. Must be 'scripts', 'flows', or 'apps'",
req.resource_type
)));
}
};
Ok(Json(PruneVersionsResponse { pruned }))
}

View File

@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.660.0
version: 1.659.1
title: Windmill API
contact:
@@ -4491,76 +4491,6 @@ paths:
responses:
"204":
description: logged
/w/{workspace}/workspaces/cloud_quotas:
get:
summary: get cloud quota usage and limits for workspace
operationId: getCloudQuotas
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: cloud quota usage and limits
content:
application/json:
schema:
type: object
properties:
scripts:
$ref: "#/components/schemas/QuotaInfo"
flows:
$ref: "#/components/schemas/QuotaInfo"
apps:
$ref: "#/components/schemas/QuotaInfo"
variables:
$ref: "#/components/schemas/QuotaInfo"
resources:
$ref: "#/components/schemas/QuotaInfo"
required:
- scripts
- flows
- apps
- variables
- resources
/w/{workspace}/workspaces/prune_versions:
post:
summary: prune old versions of scripts, flows, or apps
operationId: pruneVersions
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
resource_type:
type: string
enum:
- scripts
- flows
- apps
required:
- resource_type
responses:
"200":
description: number of pruned versions
content:
application/json:
schema:
type: object
properties:
pruned:
type: integer
required:
- pruned
/w/{workspace}/workspaces/public_app_rate_limit:
post:
summary: Set public app rate limit for this workspace
@@ -24464,19 +24394,6 @@ components:
description: Users that can bypass this ruleset
items:
type: string
QuotaInfo:
type: object
properties:
used:
type: integer
limit:
type: integer
prunable:
type: integer
required:
- used
- limit
- prunable
NativeServiceName:
type: string
enum:

View File

@@ -1179,7 +1179,7 @@ async fn create_app_internal<'a>(
.await?;
if nb_apps.unwrap_or(0) >= 1000 {
return Err(Error::BadRequest(
"You have reached the maximum number of apps (1000) on cloud. Check your usage in Workspace Settings > General > Cloud Quotas. Contact support@windmill.dev to increase the limit"
"You have reached the maximum number of apps (1000) on cloud. Contact support@windmill.dev to increase the limit"
.to_string(),
));
}

View File

@@ -64,6 +64,7 @@ use crate::scim_oss::has_scim_token;
use windmill_common::error::AppError;
mod ai;
mod google;
mod apps;
pub mod args;
mod audit;
@@ -76,7 +77,6 @@ mod bedrock;
mod capture;
mod concurrency_groups;
mod db;
mod google;
mod drafts;
#[cfg(feature = "private")]
@@ -376,7 +376,7 @@ pub async fn run_server(
));
let cors = CorsLayer::new()
.allow_methods([http::Method::GET, http::Method::POST, http::Method::DELETE])
.allow_methods([http::Method::GET, http::Method::POST])
.allow_headers([http::header::CONTENT_TYPE, http::header::AUTHORIZATION])
.allow_origin(Any);
@@ -675,11 +675,8 @@ pub async fn run_server(
)
.layer(from_extractor::<OptAuthed>())
// Deprecated, here for backwards compatibility: user should use /mcp/w/:workspace_id/mcp instead
.nest(
"/mcp/w/:workspace_id/sse",
mcp_router.clone().layer(cors.clone()),
)
.nest("/mcp/w/:workspace_id/mcp", mcp_router.layer(cors.clone()))
.nest("/mcp/w/:workspace_id/sse", mcp_router.clone())
.nest("/mcp/w/:workspace_id/mcp", mcp_router)
.nest("/agent_workers", {
#[cfg(feature = "agent_worker_server")]
{

View File

@@ -386,9 +386,7 @@ async fn handle_authorization_code_grant(
let access_token = rd_string(32);
let access_token_hash = hash_token(&access_token);
let access_token_prefix = access_token
.get(..TOKEN_PREFIX_LEN)
.unwrap_or(&access_token);
let access_token_prefix = access_token.get(..TOKEN_PREFIX_LEN).unwrap_or(&access_token);
let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await {
None
} else {
@@ -532,9 +530,7 @@ async fn handle_refresh_token_grant(
// Generate new tokens
let new_access_token = rd_string(32);
let new_access_token_hash = hash_token(&new_access_token);
let new_access_token_prefix = new_access_token
.get(..TOKEN_PREFIX_LEN)
.unwrap_or(&new_access_token);
let new_access_token_prefix = new_access_token.get(..TOKEN_PREFIX_LEN).unwrap_or(&new_access_token);
let new_plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await {
None
} else {

View File

@@ -28,25 +28,6 @@ use std::sync::Arc;
// Re-export from http crate for extracting request parts
use http::request::Parts as HttpParts;
/// Maximum size in bytes for tool result text content.
/// Anthropic enforces a 25,000 token limit per tool result.
/// At ~3.5 bytes/token, 87,500 bytes is a safe upper bound.
const MAX_TOOL_RESULT_BYTES: usize = 87_500;
fn truncate_tool_result(text: String) -> String {
if text.len() <= MAX_TOOL_RESULT_BYTES {
return text;
}
// Truncate at a char boundary
let mut end = MAX_TOOL_RESULT_BYTES;
while !text.is_char_boundary(end) && end > 0 {
end -= 1;
}
let mut truncated = text[..end].to_string();
truncated.push_str("\n\n[truncated — result exceeded 25,000 token limit]");
truncated
}
/// MCP Server Runner - generic over the backend implementation
///
/// This struct implements the MCP ServerHandler trait and uses a McpBackend
@@ -287,26 +268,29 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
.map_err(|e| ErrorData::internal_error(e.message, None))?;
return Ok(CallToolResult::success(vec![Content::text(
truncate_tool_result(
serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string()),
),
serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string()),
)]));
}
}
// 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 (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)
})?;
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);
@@ -401,9 +385,7 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
match result {
Ok(value) => Ok(CallToolResult::success(vec![Content::text(
truncate_tool_result(
serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string()),
),
serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string()),
)])),
Err(e) => Err(ErrorData::internal_error(
format!("Failed to run {}: {}", tool_type, e.message),

View File

@@ -1041,35 +1041,6 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
.ok()
.flatten();
wac_job_ids = row.flatten();
// If parent was already completed (e.g. cancelled), update v2_job_completed instead
if wac_job_ids.is_none() {
let _ = sqlx::query!(
r#"UPDATE v2_job_completed SET
workflow_as_code_status = jsonb_set(
jsonb_set(
workflow_as_code_status,
array[$1],
COALESCE(workflow_as_code_status->$1, '{}'::jsonb)
),
array[$1, 'duration_ms'],
to_jsonb($2::bigint)
)
WHERE id = $3 AND workflow_as_code_status IS NOT NULL"#,
&completed_job.id.to_string(),
duration,
parent_job
)
.execute(&mut *tx)
.warn_after_seconds(10)
.await
.inspect_err(|e| {
tracing::error!(
"Could not update completed parent job `duration_ms` in workflow as code status: {}",
e,
)
});
}
}
}
// tracing::error!("Added completed job {:#?}", queued_job);

View File

@@ -768,7 +768,7 @@ async fn create_resource(
.await?;
if nb_resources.unwrap_or(0) >= 10000 {
return Err(Error::BadRequest(
"You have reached the maximum number of resources (10000) on cloud. Check your usage in Workspace Settings > General > Cloud Quotas. Contact support@windmill.dev to increase the limit"
"You have reached the maximum number of resources (10000) on cloud. Contact support@windmill.dev to increase the limit"
.to_string(),
));
}
@@ -884,12 +884,13 @@ async fn delete_resource(
let mut tx = user_db.begin(&authed).await?;
// Fetch the resource value before deleting, so we can find linked $var: references
let resource_value: Option<Option<serde_json::Value>> =
sqlx::query_scalar("SELECT value FROM resource WHERE path = $1 AND workspace_id = $2")
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
let resource_value: Option<Option<serde_json::Value>> = sqlx::query_scalar(
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
)
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
let deleted_path = sqlx::query_scalar!(
"DELETE FROM resource WHERE path = $1 AND workspace_id = $2 RETURNING path",
@@ -975,7 +976,10 @@ async fn delete_resource(
webhook.send_message(
w_id.clone(),
WebhookMessage::DeleteVariable { workspace: w_id.clone(), path: var_path.clone() },
WebhookMessage::DeleteVariable {
workspace: w_id.clone(),
path: var_path.clone(),
},
);
}

View File

@@ -414,7 +414,7 @@ async fn create_variable(
.await?;
if nb_variables.unwrap_or(0) >= 10000 {
return Err(Error::BadRequest(
"You have reached the maximum number of variables (10000) on cloud. Check your usage in Workspace Settings > General > Cloud Quotas. Contact support@windmill.dev to increase the limit"
"You have reached the maximum number of variables (10000) on cloud. Contact support@windmill.dev to increase the limit"
.to_string(),
));
}

View File

@@ -66,7 +66,7 @@ mount {
mount {
src: "{JOB_DIR}/out/Main"
src: "{JOB_DIR}/Main"
dst: "/tmp/main"
is_bind: true
mandatory: false

View File

@@ -45,10 +45,6 @@ use windmill_queue::{
MiniPulledJob, PushArgs, PushIsolationLevel,
};
/// Shared collection of abort handles for spawned tool tasks.
/// Used to abort in-flight tasks when the parent agent is force-cancelled.
pub type ToolAbortHandles = Arc<std::sync::Mutex<Vec<tokio::task::AbortHandle>>>;
/// Context for tool execution containing all required references and state
pub struct ToolExecutionContext<'a> {
// Database & connections
@@ -77,9 +73,6 @@ pub struct ToolExecutionContext<'a> {
pub flow_context: &'a mut FlowContext,
pub previous_result: &'a Option<Box<RawValue>>,
pub id_context: &'a Option<crate::js_eval::IdContext>,
// Abort handles for spawned tool tasks (used for force-cancel cleanup)
pub tool_abort_handles: ToolAbortHandles,
}
/// Execute all tool calls from an AI response
@@ -556,19 +549,10 @@ async fn execute_windmill_tool(
(result, occupancy_metrics_spawn)
});
// Register abort handle so the task can be killed on force-cancel
let abort_handle = join_handle.abort_handle();
// unwrap safe: lock is only held briefly for push/drain, no panic possible inside
ctx.tool_abort_handles.lock().unwrap().push(abort_handle);
// Await the spawned task
let (handle_result, updated_occupancy) = join_handle.await.map_err(|e| {
if e.is_cancelled() {
Error::ExecutionErr("Tool execution task was cancelled".to_string())
} else {
Error::internal_err(format!("Tool execution task failed: {}", e))
}
})?;
let (handle_result, updated_occupancy) = join_handle
.await
.map_err(|e| Error::internal_err(format!("Tool execution task failed: {}", e)))?;
// Merge occupancy metrics back
ctx.occupancy_metrics.total_duration_of_running_jobs =

View File

@@ -1,6 +1,6 @@
#[cfg(feature = "bedrock")]
use crate::ai::providers::bedrock::check_env_credentials;
use crate::ai::tools::{execute_tool_calls, ToolAbortHandles, ToolExecutionContext};
use crate::ai::tools::{execute_tool_calls, ToolExecutionContext};
use crate::ai::utils::{
add_message_to_conversation, any_tool_needs_previous_result, cleanup_mcp_clients,
filter_schema_by_input_transforms, find_unique_tool_name, get_flow_context,
@@ -35,7 +35,7 @@ use windmill_common::{
utils::{StripPath, HTTP_CLIENT},
worker::{to_raw_value, Connection},
};
use windmill_queue::{cancel_single_job, CanceledBy, MiniPulledJob};
use windmill_queue::{CanceledBy, MiniPulledJob};
use crate::{
ai::{
@@ -46,7 +46,7 @@ use crate::{
types::*,
},
common::{build_args_map, resolve_job_timeout, OccupancyMetrics, StreamNotifier},
handle_child::{run_future_with_polling_update_job_poller_graceful, GracefulPollOutcome},
handle_child::run_future_with_polling_update_job_poller,
};
lazy_static::lazy_static! {
@@ -445,102 +445,45 @@ pub async fn handle_ai_agent_job(
Some(flow_job_id)
};
// Create cancellation signal for graceful shutdown
let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
let tool_abort_handles: ToolAbortHandles = Arc::new(std::sync::Mutex::new(Vec::new()));
let agent_fut = run_agent(
db,
conn,
job,
flow_status_job.as_ref(),
Some(flow_step_id.as_str()),
&args,
&tools,
&mcp_clients,
summary.as_deref(),
client,
&mut inner_occupancy_metrics,
worker_dir,
base_internal_url,
worker_name,
hostname,
killpill_rx,
has_stream,
has_websearch,
);
/// Grace period for in-flight tool calls to complete after cancellation.
const CANCEL_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_secs(30);
let outcome = {
let agent_fut = run_agent(
db,
conn,
job,
flow_status_job.as_ref(),
Some(flow_step_id.as_str()),
&args,
&tools,
&mcp_clients,
summary.as_deref(),
client,
&mut inner_occupancy_metrics,
worker_dir,
base_internal_url,
worker_name,
hostname,
killpill_rx,
has_stream,
has_websearch,
cancel_rx,
tool_abort_handles.clone(),
);
let mut occupancy_opt = Some(occupancy_metrics);
run_future_with_polling_update_job_poller_graceful(
job.id,
job.timeout,
conn,
mem_peak,
canceled_by,
agent_fut,
worker_name,
&job.workspace_id,
&mut occupancy_opt,
Box::pin(futures::stream::once(async { 0 })),
cancel_tx,
CANCEL_GRACE_PERIOD,
)
.await?
};
// agent_fut and update_job are now dropped — borrows on mcp_clients and canceled_by released
let result = run_future_with_polling_update_job_poller(
job.id,
job.timeout,
conn,
mem_peak,
canceled_by,
agent_fut,
worker_name,
&job.workspace_id,
&mut Some(occupancy_metrics),
Box::pin(futures::stream::once(async { 0 })),
)
.await?;
// Cleanup MCP clients
cleanup_mcp_clients(mcp_clients).await;
let format_cancel_info = |cb: &Option<CanceledBy>| {
cb.as_ref()
.map_or(("unknown".to_string(), "unknown".to_string()), |x| {
(
x.username.clone().unwrap_or_default(),
x.reason.clone().unwrap_or_default(),
)
})
};
match outcome {
GracefulPollOutcome::Ok(result) => Ok(result),
GracefulPollOutcome::Timeout(ms) => {
tracing::error!("AI agent timeout after {}s", ms / 1000);
Err(Error::ExecutionErr(format!(
"AI agent timeout after (>{}s)",
ms / 1000
)))
}
GracefulPollOutcome::Cancelled { canceled_by: cb } => {
let (by, reason) = format_cancel_info(&cb);
Err(Error::ExecutionErr(format!(
"Job cancelled by {by} (reason: {reason})"
)))
}
GracefulPollOutcome::CancelledTimeout { canceled_by: cb } => {
let (by, reason) = format_cancel_info(&cb);
// Abort any still-running spawned tool tasks
// unwrap safe: lock is only held briefly for push/drain, no panic possible inside
for handle in tool_abort_handles.lock().unwrap().drain(..) {
handle.abort();
}
// Hard timeout: clean up orphaned jobs still stuck in v2_job_queue
cleanup_orphaned_tool_jobs(db, &job.id, &job.workspace_id, cb).await;
Err(Error::ExecutionErr(format!(
"Job cancelled by {by} (reason: {reason}, timed out waiting for tool calls)"
)))
}
GracefulPollOutcome::AlreadyCompleted => {
Err(Error::AlreadyCompleted("Job already completed".to_string()))
}
}
Ok(result)
}
#[async_recursion]
@@ -568,12 +511,6 @@ pub async fn run_agent(
killpill_rx: &mut tokio::sync::broadcast::Receiver<()>,
has_stream: &mut bool,
has_websearch: bool,
// cancellation signal from parent
cancel_rx: tokio::sync::watch::Receiver<bool>,
// abort handles for spawned tool tasks
tool_abort_handles: ToolAbortHandles,
) -> error::Result<Box<RawValue>> {
let output_type = args.output_type.as_ref().unwrap_or(&OutputType::Text);
// Skip get_base_url for Bedrock - it uses SDK directly, not HTTP
@@ -825,12 +762,9 @@ pub async fn run_agent(
.map(|m| m.clamp(1, HARD_MAX_AGENT_ITERATIONS))
.unwrap_or(DEFAULT_MAX_AGENT_ITERATIONS);
// Main agent loop
for i in 0..max_iterations {
// Check if parent was canceled — stop iterating but let current tool calls finish
if *cancel_rx.borrow() {
return Err(Error::ExecutionErr("Job cancelled".to_string()));
}
if used_structured_output_tool {
break;
@@ -1154,7 +1088,6 @@ pub async fn run_agent(
flow_context: &mut flow_context,
previous_result: &previous_result,
id_context: &id_context,
tool_abort_handles: tool_abort_handles.clone(),
};
let (tool_messages, tool_content, tool_used_structured_output) =
@@ -1174,11 +1107,6 @@ pub async fn run_agent(
content = Some(tc);
}
used_structured_output_tool = tool_used_structured_output;
// Check cancellation after tool calls complete to avoid a wasted LLM call
if *cancel_rx.borrow() {
return Err(Error::ExecutionErr("Job cancelled".to_string()));
}
}
ParsedResponse::Image { base64_data } => {
// For image output, upload to S3 and track in conversation
@@ -1364,107 +1292,3 @@ async fn handle_credentials_check(provider: &ProviderWithResource) -> Result<Box
serde_json::value::to_raw_value(&result).map_err(|e| Error::internal_err(e.to_string()))
}
/// Hard-timeout fallback: force-cancel any descendant jobs still in v2_job_queue
/// so they don't stay as zombies.
async fn cleanup_orphaned_tool_jobs(
db: &DB,
parent_job_id: &Uuid,
w_id: &str,
canceled_by: Option<CanceledBy>,
) {
let username = canceled_by
.as_ref()
.and_then(|cb| cb.username.clone())
.unwrap_or_else(|| "unknown".to_string());
let reason = canceled_by
.as_ref()
.and_then(|cb| cb.reason.clone())
.unwrap_or_else(|| {
format!(
"parent AI agent {} was cancelled and tool call did not complete in time",
parent_job_id
)
});
// Find direct child jobs still in v2_job_queue (agent tool jobs are always direct children)
let orphaned_ids: Vec<Uuid> = match sqlx::query_scalar!(
r#"SELECT j.id FROM v2_job j
JOIN v2_job_queue q ON q.id = j.id
WHERE j.parent_job = $1 AND j.workspace_id = $2"#,
parent_job_id,
w_id,
)
.fetch_all(db)
.await
{
Ok(ids) => ids,
Err(e) => {
tracing::error!(
"Failed to find orphaned tool jobs for {}: {}",
parent_job_id,
e
);
return;
}
};
if orphaned_ids.is_empty() {
return;
}
tracing::warn!(
"Cleaning up {} orphaned tool jobs for cancelled AI agent {}",
orphaned_ids.len(),
parent_job_id,
);
for job_id in &orphaned_ids {
let queued_job = match windmill_queue::get_queued_job_v2(db, job_id).await {
Ok(Some(j)) => j,
Ok(None) => continue,
Err(e) => {
tracing::error!("Failed to fetch orphaned tool job {}: {}", job_id, e);
continue;
}
};
let tx = match db.begin().await {
Ok(tx) => tx,
Err(e) => {
tracing::error!(
"Failed to begin transaction for orphaned job {}: {}",
job_id,
e
);
continue;
}
};
match cancel_single_job(
&username,
Some(reason.clone()),
queued_job,
w_id,
tx,
db,
true,
)
.await
{
Ok((tx, _)) => {
if let Err(e) = tx.commit().await {
tracing::error!(
"Failed to commit cancel for orphaned tool job {}: {}",
job_id,
e
);
}
}
Err(e) => {
// warn not error: job may have completed between fetch and cancel (expected race)
tracing::warn!("Failed to force-cancel orphaned tool job {}: {}", job_id, e);
}
}
}
}

View File

@@ -379,7 +379,7 @@ async fn build_cs_proj(
"--configuration",
"Release",
"-o",
&format!("{job_dir}/out"),
job_dir,
"--no-self-contained",
"-p:PublishSingleFile=true",
"-p:IncludeNativeLibrariesForSelfExtract=true",
@@ -437,9 +437,9 @@ async fn build_cs_proj(
let bin_path = format!("{}/{hash}", *CSHARP_CACHE_DIR);
#[cfg(unix)]
let target = format!("{job_dir}/out/Main");
let target = format!("{job_dir}/Main");
#[cfg(windows)]
let target = format!("{job_dir}/out/Main.exe");
let target = format!("{job_dir}/Main.exe");
match save_cache(
&bin_path,
@@ -450,7 +450,7 @@ async fn build_cs_proj(
.await
{
Err(e) => {
let em = format!("could not save {job_dir}/out/Main to C# cache: {e:?}",);
let em = format!("could not save {job_dir}/Main to C# cache: {e:?}",);
tracing::error!(em);
Ok(em)
}
@@ -527,17 +527,11 @@ pub async fn handle_csharp_job(
let cache_logs = if cache {
#[cfg(unix)]
{
let out_dir = format!("{job_dir}/out");
std::fs::create_dir_all(&out_dir).map_err(|e| {
Error::ExecutionErr(format!(
"could not create output directory {out_dir}: {e:?}"
))
})?;
let target = format!("{out_dir}/Main");
let target = format!("{job_dir}/Main");
let symlink = std::os::unix::fs::symlink(&bin_path, &target);
symlink.map_err(|e| {
Error::ExecutionErr(format!(
"could not copy cached binary from {bin_path} to {target}: {e:?}"
"could not copy cached binary from {bin_path} to {job_dir}/Main: {e:?}"
))
})?;
}
@@ -637,12 +631,12 @@ pub async fn handle_csharp_job(
start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), true).await?
} else {
#[cfg(unix)]
let compiled_executable_name = "./out/Main".to_string();
let compiled_executable_name = "./Main".to_string();
#[cfg(windows)]
let compiled_executable_name = if cache {
bin_path.to_string()
} else {
format!("{job_dir}/out/Main.exe")
format!("{job_dir}/Main.exe")
};
let mut run_csharp = build_command_with_isolation(&compiled_executable_name, &[]);

View File

@@ -745,96 +745,6 @@ where
Ok(rows)
}
/// Outcome of [`run_future_with_polling_update_job_poller_graceful`].
pub enum GracefulPollOutcome<T> {
/// The future completed normally.
Ok(T),
/// The job timed out.
Timeout(u64),
/// The job was cancelled and the future finished within the grace period.
Cancelled { canceled_by: Option<CanceledBy> },
/// The job was cancelled but the future did NOT finish within the grace period.
CancelledTimeout { canceled_by: Option<CanceledBy> },
/// The job was already moved to v2_job_completed externally.
AlreadyCompleted,
}
/// Like [`run_future_with_polling_update_job_poller`] but on cancellation, signals
/// `cancel_tx` and waits up to `grace_period` for the future to finish instead of
/// dropping it immediately. This lets in-flight work (e.g. AI tool calls) complete
/// and clean up properly.
pub async fn run_future_with_polling_update_job_poller_graceful<Fut, T, S>(
job_id: Uuid,
timeout: Option<i32>,
conn: &Connection,
mem_peak: &mut i32,
canceled_by_ref: &mut Option<CanceledBy>,
result_f: Fut,
worker_name: &str,
w_id: &str,
occupancy_metrics: &mut Option<&mut OccupancyMetrics>,
get_mem: S,
cancel_tx: watch::Sender<bool>,
grace_period: std::time::Duration,
) -> error::Result<GracefulPollOutcome<T>>
where
Fut: Future<Output = error::Result<T>>,
S: stream::Stream<Item = i32> + Unpin,
{
let (tx, rx) = broadcast::channel::<()>(3);
let mut update_job = Box::pin(update_job_poller(
job_id,
conn,
mem_peak,
canceled_by_ref,
get_mem,
worker_name,
w_id,
rx,
occupancy_metrics,
));
let timeout_ms = u64::try_from(
resolve_job_timeout(conn, w_id, job_id, timeout)
.await
.0
.as_millis(),
)
.unwrap_or(200_000);
let mut result_f = Box::pin(result_f);
let outcome = tokio::select! {
biased;
result = tokio::time::timeout(
std::time::Duration::from_millis(timeout_ms),
&mut result_f,
) => {
match result {
Ok(Ok(v)) => GracefulPollOutcome::Ok(v),
Ok(Err(e)) => return Err(e),
Err(_) => GracefulPollOutcome::Timeout(timeout_ms),
}
}
ex = &mut update_job, if job_id != Uuid::nil() => {
match ex {
UpdateJobPollingExit::Done(cb) => {
let _ = cancel_tx.send(true);
match tokio::time::timeout(grace_period, &mut result_f).await {
Ok(_) => GracefulPollOutcome::Cancelled { canceled_by: cb },
Err(_) => GracefulPollOutcome::CancelledTimeout { canceled_by: cb },
}
}
UpdateJobPollingExit::AlreadyCompleted => GracefulPollOutcome::AlreadyCompleted,
}
}
};
drop(tx);
Ok(outcome)
}
pub enum UpdateJobPollingExit {
Done(Option<CanceledBy>),
AlreadyCompleted,

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.660.0";
export const VERSION = "v1.659.1";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({

View File

@@ -16,34 +16,33 @@ import { resolveWorkspace } from "../../core/context.ts";
import {
SyncOptions,
mergeConfigWithConfigFile,
readConfigFile,
} from "../../core/conf.ts";
import { exts, removeExtensionToPath } from "../script/script.ts";
import { inferContentTypeFromFilePath } from "../../utils/script_common.ts";
import { OpenFlow } from "../../../gen/types.gen.ts";
import { FlowFile } from "../flow/flow.ts";
import { replaceInlineScripts, replaceAllPathScriptsWithLocal } from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import { replaceInlineScripts } from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import { parseMetadataFile } from "../../utils/metadata.ts";
import {
getFolderSuffixWithSep,
getMetadataFileName,
extractFolderPath,
} from "../../utils/resource_folders.ts";
import { listSyncCodebases } from "../../utils/codebase.ts";
import { createPreviewLocalScriptReader } from "../../utils/local_path_scripts.ts";
const PORT = 3001;
async function dev(opts: GlobalOptions & SyncOptions) {
opts = await mergeConfigWithConfigFile(opts);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
log.info("Started dev mode");
const conf = await readConfigFile();
let currentLastEdit: LastEditScript | LastEditFlow | undefined = undefined;
const fsWatcher = watch(".", { recursive: true });
const base = await realpath(".");
opts = await mergeConfigWithConfigFile(opts);
const ignore = await ignoreF(opts);
const codebases = await listSyncCodebases(opts);
const changesTimeouts: Record<string, ReturnType<typeof setTimeout>> = {};
function watchChanges() {
@@ -57,11 +56,7 @@ async function dev(opts: GlobalOptions & SyncOptions) {
}
changesTimeouts[key] = setTimeout(async () => {
delete changesTimeouts[key];
await loadPaths([filePath]).catch((error) => {
log.error(
`Failed to reload ${filePath}: ${error instanceof Error ? error.message : error}`
);
});
await loadPaths([filePath]);
}, 100);
});
fsWatcher.on("error", (err) => {
@@ -99,13 +94,6 @@ async function dev(opts: GlobalOptions & SyncOptions) {
SEP,
undefined,
);
// Replace PathScript modules with local file content so dev mode uses local versions
const localScriptReader = createPreviewLocalScriptReader({
exts,
defaultTs: opts.defaultTs,
codebases,
});
await replaceAllPathScriptsWithLocal(localFlow.value, localScriptReader, log);
currentLastEdit = {
type: "flow",
flow: localFlow,
@@ -117,7 +105,7 @@ async function dev(opts: GlobalOptions & SyncOptions) {
const content = await readFile(cpath, "utf-8");
const splitted = cpath.split(".");
const wmPath = splitted[0];
const lang = inferContentTypeFromFilePath(cpath, opts.defaultTs);
const lang = inferContentTypeFromFilePath(cpath, conf.defaultTs);
const typed =
(await parseMetadataFile(
removeExtensionToPath(cpath),

View File

@@ -18,19 +18,8 @@ import { defaultFlowDefinition } from "../../../bootstrap/flow_bootstrap.ts";
import { SyncOptions, mergeConfigWithConfigFile } from "../../core/conf.ts";
import { FSFSElement, elementsToMap, ignoreF } from "../sync/sync.ts";
import { Flow } from "../../../gen/types.gen.ts";
import {
collectPathScriptPaths,
replaceInlineScripts,
replaceAllPathScriptsWithLocal,
} from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import { replaceInlineScripts } from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import { generateFlowLockInternal } from "./flow_metadata.ts";
import { exts } from "../script/script.ts";
import type { SyncCodebase } from "../../utils/codebase.ts";
import { listSyncCodebases } from "../../utils/codebase.ts";
import {
createPreviewLocalScriptReader,
resolvePreviewLocalScriptState,
} from "../../utils/local_path_scripts.ts";
export interface FlowFile {
summary: string;
@@ -39,90 +28,6 @@ export interface FlowFile {
schema?: any;
}
function normalizeOptionalString(value: string | null | undefined): string | undefined {
return typeof value === "string" && value.trim() === "" ? undefined : value ?? undefined;
}
function normalizeComparableContent(value: string | undefined): string | undefined {
return value?.replaceAll("\r\n", "\n").replace(/\n$/, "");
}
async function findDivergedLocalPathScripts(
workspaceId: string,
scriptPaths: string[],
opts: {
exts: string[];
defaultTs?: "bun" | "deno";
codebases: SyncCodebase[];
}
): Promise<{ changed: string[]; missing: string[] }> {
const changed: string[] = [];
const missing: string[] = [];
for (const scriptPath of scriptPaths) {
const localScript = await resolvePreviewLocalScriptState(scriptPath, opts);
if (!localScript) {
continue;
}
let remoteScript;
try {
remoteScript = await wmill.getScriptByPath({
workspace: workspaceId,
path: scriptPath,
});
} catch {
missing.push(scriptPath);
continue;
}
const remoteLock = normalizeOptionalString(remoteScript.lock);
const diverged =
normalizeComparableContent(localScript.content) !==
normalizeComparableContent(remoteScript.content) ||
localScript.language !== remoteScript.language ||
(localScript.lock !== undefined &&
normalizeComparableContent(localScript.lock) !==
normalizeComparableContent(remoteLock)) ||
localScript.tag !== normalizeOptionalString(remoteScript.tag) ||
localScript.codebaseDigest !== normalizeOptionalString(remoteScript.codebase);
if (diverged) {
changed.push(scriptPath);
}
}
return { changed, missing };
}
function warnAboutLocalPathScriptDivergence(
divergence: { changed: string[]; missing: string[] }
): void {
if (divergence.changed.length === 0 && divergence.missing.length === 0) {
return;
}
const details: string[] = [];
if (divergence.changed.length > 0) {
details.push(
`These workspace scripts differ from the deployed version:\n${divergence.changed
.map((path) => `- ${path}`)
.join("\n")}`
);
}
if (divergence.missing.length > 0) {
details.push(
`These scripts do not exist in the workspace yet:\n${divergence.missing
.map((path) => `- ${path}`)
.join("\n")}`
);
}
log.warn(
`Using local PathScript files for flow preview.\n${details.join("\n")}\nUse --remote to preview deployed workspace scripts instead.`
);
}
const alreadySynced: string[] = [];
export async function pushFlow(
@@ -328,17 +233,11 @@ async function preview(
opts: GlobalOptions & {
data?: string;
silent: boolean;
remote?: boolean;
} & SyncOptions,
},
flowPath: string
) {
const useLocalPathScripts = !opts.remote;
if (useLocalPathScripts) {
opts = await mergeConfigWithConfigFile(opts);
}
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const codebases = useLocalPathScripts ? listSyncCodebases(opts) : [];
// Normalize path - ensure it's a directory path to a .flow folder
if (!flowPath.endsWith(".flow") && !flowPath.endsWith(".flow" + SEP)) {
@@ -375,31 +274,6 @@ async function preview(
await replaceInlineScripts([localFlow.value.preprocessor_module], fileReader, log, flowPath, SEP);
}
if (useLocalPathScripts) {
const scriptPaths = collectPathScriptPaths(localFlow.value);
if (scriptPaths.length > 0) {
const divergence = await findDivergedLocalPathScripts(
workspace.workspaceId,
scriptPaths,
{
exts,
defaultTs: opts.defaultTs,
codebases,
}
);
if (!opts.silent) {
warnAboutLocalPathScriptDivergence(divergence);
}
}
const localScriptReader = createPreviewLocalScriptReader({
exts,
defaultTs: opts.defaultTs,
codebases,
});
await replaceAllPathScriptsWithLocal(localFlow.value, localScriptReader, log);
}
const input = opts.data ? await resolve(opts.data) : {};
if (!opts.silent) {
@@ -570,7 +444,7 @@ const command = new Command()
.action(run as any)
.command(
"preview",
"preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default."
"preview a local flow without deploying it. Runs the flow definition from local files."
)
.arguments("<flow_path:string>")
.option(
@@ -581,10 +455,6 @@ const command = new Command()
"-s --silent",
"Do not output anything other then the final output. Useful for scripting."
)
.option(
"--remote",
"Use deployed workspace scripts for PathScript steps instead of local files."
)
.action(preview as any)
.command(
"generate-locks",

View File

@@ -5039,10 +5039,9 @@ flow related commands
- \`flow run <path:string>\` - run a flow by path.
- \`-d --data <data:string>\` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- \`-s --silent\` - Do not ouput anything other then the final output. Useful for scripting.
- \`flow preview <flow_path:string>\` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.
- \`flow preview <flow_path:string>\` - preview a local flow without deploying it. Runs the flow definition from local files.
- \`-d --data <data:string>\` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- \`-s --silent\` - Do not output anything other then the final output. Useful for scripting.
- \`--remote\` - Use deployed workspace scripts for PathScript steps instead of local files.
- \`flow generate-locks [flow:file]\` - re-generate the lock files of all inline scripts of all updated flows
- \`--yes\` - Skip confirmation prompt
- \`-i --includes <patterns:file[]>\` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)

View File

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

View File

@@ -1,155 +0,0 @@
import { execFileSync } from "node:child_process";
import { readFile, stat } from "node:fs/promises";
import type { SyncCodebase } from "./codebase.ts";
import { parseMetadataFileIfExists } from "./metadata.ts";
import { inferContentTypeFromFilePath } from "./script_common.ts";
import { findCodebase } from "../commands/sync/sync.ts";
import type { LocalScriptInfo } from "../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import type { RawScript } from "../../../gen/types.gen.ts";
export class UnsupportedLocalPathScriptPreviewError extends Error {
constructor(message: string) {
super(message);
this.name = "UnsupportedLocalPathScriptPreviewError";
}
}
async function readOptionalLock(scriptPath: string): Promise<string | undefined> {
try {
return await readFile(scriptPath + ".script.lock", "utf-8");
} catch {
return undefined;
}
}
function normalizeOptionalLock(lock: string | undefined): string | undefined {
return typeof lock === "string" && lock.trim() === "" ? undefined : lock;
}
async function bundleSingleFileCodebaseScript(
filePath: string,
codebase: SyncCodebase
): Promise<string> {
if (codebase.customBundler) {
// Pass the script path as a positional shell argument so existing shell-based
// custom bundlers still work without interpolating the path into the command.
return execFileSync(
"sh",
["-lc", `${codebase.customBundler} "$1"`, "sh", filePath],
{
maxBuffer: 1024 * 1024 * 50,
}
).toString();
}
const esbuild = await import("esbuild");
const out = await esbuild.build({
entryPoints: [filePath],
// Inline rawscripts are executed through the standard module wrapper,
// so the bundle must expose `main` as an ESM export.
format: "esm",
bundle: true,
write: false,
external: codebase.external,
inject: codebase.inject,
define: codebase.define,
loader: codebase.loader ?? { ".node": "file" },
outdir: "/",
platform: "node",
packages: "bundle",
target: "esnext",
banner: codebase.banner,
});
if (out.outputFiles.length === 0) {
throw new Error(`No output files found for ${filePath}`);
}
if (out.outputFiles.length > 1) {
throw new UnsupportedLocalPathScriptPreviewError(
`Local PathScript ${filePath} requires a multi-file bundle, which flow preview/dev cannot inline yet`
);
}
if (Array.isArray(codebase.assets) && codebase.assets.length > 0) {
throw new UnsupportedLocalPathScriptPreviewError(
`Local PathScript ${filePath} requires codebase assets, which flow preview/dev cannot inline yet`
);
}
return out.outputFiles[0].text;
}
export function createPreviewLocalScriptReader(opts: {
exts: string[];
defaultTs?: "bun" | "deno";
codebases: SyncCodebase[];
}): (scriptPath: string) => Promise<LocalScriptInfo | undefined> {
return async (scriptPath) => {
const localScript = await resolvePreviewLocalScriptState(scriptPath, opts);
if (!localScript) {
return undefined;
}
const content = localScript.codebase
? await bundleSingleFileCodebaseScript(localScript.filePath, localScript.codebase)
: localScript.content;
return {
content,
language: localScript.language,
lock: localScript.lock,
tag: localScript.tag,
};
};
}
export type PreviewLocalScriptState = {
filePath: string;
content: string;
language: RawScript["language"];
lock?: string;
tag?: string;
codebase?: SyncCodebase;
codebaseDigest?: string;
};
export async function resolvePreviewLocalScriptState(
scriptPath: string,
opts: {
exts: string[];
defaultTs?: "bun" | "deno";
codebases: SyncCodebase[];
}
): Promise<PreviewLocalScriptState | undefined> {
for (const ext of opts.exts) {
const filePath = scriptPath + ext;
let fileStat;
try {
fileStat = await stat(filePath);
} catch {
continue;
}
if (!fileStat.isFile()) continue;
const language = inferContentTypeFromFilePath(filePath, opts.defaultTs);
const metadata = await parseMetadataFileIfExists(scriptPath);
const rawLock = metadata?.payload?.lock ?? (await readOptionalLock(scriptPath));
const codebase =
language === "bun" ? findCodebase(filePath, opts.codebases) : undefined;
return {
filePath,
content: await readFile(filePath, "utf-8"),
language,
lock: normalizeOptionalLock(rawLock),
tag: metadata?.payload?.tag,
codebase,
codebaseDigest: codebase
? await codebase.getDigest(
Array.isArray(codebase.assets) && codebase.assets.length > 0
)
: undefined,
};
}
return undefined;
}

View File

@@ -921,38 +921,6 @@ export function replaceLock(o?: { lock?: string | string[] }) {
}
}
}
export async function parseMetadataFileIfExists(
scriptPath: string
): Promise<{ isJson: boolean; payload: any; path: string } | undefined> {
let metadataFilePath = scriptPath + ".script.json";
try {
await stat(metadataFilePath);
const payload = JSON.parse(await readFile(metadataFilePath, "utf-8"));
replaceLock(payload);
return {
path: metadataFilePath,
payload,
isJson: true,
};
} catch {
try {
metadataFilePath = scriptPath + ".script.yaml";
await stat(metadataFilePath);
const payload: any = await yamlParseFile(metadataFilePath);
replaceLock(payload);
return {
path: metadataFilePath,
payload,
isJson: false,
};
} catch {
return undefined;
}
}
}
export async function parseMetadataFile(
scriptPath: string,
generateMetadataIfMissing:

View File

@@ -1,7 +1,6 @@
import { expect, test } from "bun:test";
import { mkdir, writeFile } from "node:fs/promises";
import { withTestBackend } from "./test_backend.ts";
import { shouldSkipOnCI } from "./cargo_backend.ts";
// =============================================================================
// PREVIEW COMMAND INTEGRATION TESTS
@@ -28,7 +27,6 @@ async function createWmillConfig(
relative_path: string;
includes?: string[];
format?: "cjs" | "esm";
customBundler?: string;
assets?: Array<{ from: string; to: string }>;
}>;
}
@@ -46,9 +44,6 @@ async function createWmillConfig(
if (cb.format) {
yamlContent += ` format: ${cb.format}\n`;
}
if (cb.customBundler) {
yamlContent += ` customBundler: ${JSON.stringify(cb.customBundler)}\n`;
}
if (cb.assets && cb.assets.length > 0) {
yamlContent += " assets:\n";
for (const asset of cb.assets) {
@@ -127,39 +122,6 @@ schema:
await writeFile(`${dir}/flow.yaml`, flowYaml, "utf-8");
}
async function createPathScriptFlow(
tempDir: string,
flowPath: string,
options: {
summary: string;
scriptPath: string;
inputTransforms?: string;
}
): Promise<void> {
const dir = `${tempDir}/${flowPath}`;
await mkdir(dir, { recursive: true });
const inputTransforms = options.inputTransforms
? ` input_transforms:\n${options.inputTransforms}`
: " input_transforms: {}\n";
const flowYaml = `summary: "${options.summary}"
description: "Test flow"
value:
modules:
- id: "a"
value:
type: "script"
path: "${options.scriptPath}"
${inputTransforms}
schema:
$schema: "https://json-schema.org/draft/2020-12/schema"
type: object
properties: {}
required: []
`;
await writeFile(`${dir}/flow.yaml`, flowYaml, "utf-8");
}
// =============================================================================
// SCRIPT PREVIEW TESTS
// =============================================================================
@@ -529,280 +491,3 @@ test("flow preview: simple flow", async () => {
});
});
test("flow preview: uses local PathScript by default and remote PathScript with --remote", async () => {
await withTestBackend(async (backend, tempDir) => {
await createWmillConfig(tempDir, { defaultTs: "bun" });
await createScript(
tempDir,
"f/test/helper_script.ts",
`export function main(name: string = "World") { return \`Remote script says: \${name}!\`; }`
);
const pushResult = await backend.runCLICommand(
["script", "push", "f/test/helper_script.ts"],
tempDir
);
expect(pushResult.code).toEqual(0);
await writeFile(
`${tempDir}/f/test/helper_script.ts`,
`export function main(name: string = "World") { return \`Local script says: \${name}!\`; }`,
"utf-8"
);
await createPathScriptFlow(tempDir, "f/test/path_flow.flow", {
summary: "Flow with PathScript",
scriptPath: "f/test/helper_script",
inputTransforms: ` name:
type: "static"
value: "PathTest"
`,
});
const localResult = await backend.runCLICommand(
["flow", "preview", "f/test/path_flow.flow"],
tempDir
);
expect(localResult.code).toEqual(0);
expect(localResult.stdout + localResult.stderr).toContain(
"Local script says: PathTest!"
);
expect(localResult.stdout + localResult.stderr).toContain(
"Using local PathScript files for flow preview."
);
expect(localResult.stdout + localResult.stderr).toContain(
"These workspace scripts differ from the deployed version:\n- f/test/helper_script"
);
const remoteResult = await backend.runCLICommand(
["flow", "preview", "--remote", "f/test/path_flow.flow"],
tempDir
);
expect(remoteResult.code).toEqual(0);
expect(remoteResult.stdout + remoteResult.stderr).toContain(
"Remote script says: PathTest!"
);
expect(remoteResult.stdout + remoteResult.stderr).not.toContain(
"Using local PathScript files for flow preview."
);
});
});
test.skipIf(shouldSkipOnCI())("flow preview: respects defaultTs when resolving local PathScripts", async () => {
await withTestBackend(async (backend, tempDir) => {
await createWmillConfig(tempDir, { defaultTs: "deno" });
await createScript(
tempDir,
"f/test/deno_helper.ts",
`export function main() { return Deno.version.deno ? "deno-runtime" : "missing"; }`
);
await createPathScriptFlow(tempDir, "f/test/deno_path_flow.flow", {
summary: "Flow with Deno PathScript",
scriptPath: "f/test/deno_helper",
});
const result = await backend.runCLICommand(
["flow", "preview", "f/test/deno_path_flow.flow"],
tempDir
);
expect(result.code).toEqual(0);
expect(result.stdout + result.stderr).toContain("deno-runtime");
});
});
test("flow preview: bundles local PathScripts with local imports", async () => {
await withTestBackend(async (backend, tempDir) => {
await createWmillConfig(tempDir, {
defaultTs: "bun",
codebases: [{ relative_path: "f/flow_codebase", includes: ["**"] }],
});
await mkdir(`${tempDir}/f/flow_codebase`, { recursive: true });
await writeFile(
`${tempDir}/f/flow_codebase/helper.ts`,
`export function greet(name: string): string {
return \`Hello from local flow codebase, \${name}!\`;
}`,
"utf-8"
);
await createScript(
tempDir,
"f/flow_codebase/main_script.ts",
`import { greet } from "./helper";
export function main(name: string = "World") {
return greet(name);
}`
);
await createPathScriptFlow(tempDir, "f/test/importing_path_flow.flow", {
summary: "Flow with imported PathScript",
scriptPath: "f/flow_codebase/main_script",
inputTransforms: ` name:
type: "static"
value: "FlowTest"
`,
});
const result = await backend.runCLICommand(
["flow", "preview", "f/test/importing_path_flow.flow"],
tempDir
);
expect(result.code).toEqual(0);
expect(result.stdout + result.stderr).toContain(
"Hello from local flow codebase, FlowTest!"
);
});
});
test("flow preview: customBundler handles script paths with spaces", async () => {
await withTestBackend(async (backend, tempDir) => {
await createWmillConfig(tempDir, {
defaultTs: "bun",
codebases: [{
relative_path: "f/codebase custom",
includes: ["f/codebase custom/**"],
customBundler: "cat",
}],
});
await createScript(
tempDir,
"f/codebase custom/custom bundler.ts",
`export function main() {
return "Custom bundler path with spaces";
}`
);
await createPathScriptFlow(tempDir, "f/test/custom_bundler_path.flow", {
summary: "Flow with customBundler path",
scriptPath: "f/codebase custom/custom bundler",
});
const result = await backend.runCLICommand(
["flow", "preview", "f/test/custom_bundler_path.flow"],
tempDir
);
expect(result.code).toEqual(0);
expect(result.stdout + result.stderr).toContain(
"Custom bundler path with spaces"
);
});
});
test("flow preview: warns when local PathScript is not deployed remotely", async () => {
await withTestBackend(async (backend, tempDir) => {
await createWmillConfig(tempDir, { defaultTs: "bun" });
await createScript(
tempDir,
"f/test/undeployed_helper.ts",
`export function main() { return "Local only script"; }`
);
await createPathScriptFlow(tempDir, "f/test/undeployed_path_flow.flow", {
summary: "Flow with undeployed PathScript",
scriptPath: "f/test/undeployed_helper",
});
const result = await backend.runCLICommand(
["flow", "preview", "f/test/undeployed_path_flow.flow"],
tempDir
);
expect(result.code).toEqual(0);
expect(result.stdout + result.stderr).toContain("Local only script");
expect(result.stdout + result.stderr).toContain(
"These scripts do not exist in the workspace yet:\n- f/test/undeployed_helper"
);
});
});
test("flow preview: does not warn when local and deployed PathScripts match", async () => {
await withTestBackend(async (backend, tempDir) => {
await createWmillConfig(tempDir, { defaultTs: "bun" });
await createScript(
tempDir,
"f/test/matching_helper.ts",
`export function main() { return "Matching script"; }`
);
const pushResult = await backend.runCLICommand(
["script", "push", "f/test/matching_helper.ts"],
tempDir
);
expect(pushResult.code).toEqual(0);
await createPathScriptFlow(tempDir, "f/test/matching_path_flow.flow", {
summary: "Flow with matching PathScript",
scriptPath: "f/test/matching_helper",
});
const result = await backend.runCLICommand(
["flow", "preview", "f/test/matching_path_flow.flow"],
tempDir
);
expect(result.code).toEqual(0);
expect(result.stdout + result.stderr).toContain("Matching script");
expect(result.stdout + result.stderr).not.toContain(
"Using local PathScript files for flow preview."
);
});
});
test("flow preview: fails loudly for asset-backed codebase scripts", async () => {
await withTestBackend(async (backend, tempDir) => {
await createWmillConfig(tempDir, {
defaultTs: "bun",
codebases: [{
relative_path: "f/codebase_tar",
includes: ["**"],
assets: [{ from: "f/codebase_tar/data.json", to: "data.json" }],
}],
});
await mkdir(`${tempDir}/f/codebase_tar`, { recursive: true });
await writeFile(
`${tempDir}/f/codebase_tar/data.json`,
JSON.stringify({ message: "Hello from asset!" }),
"utf-8"
);
await createScript(
tempDir,
"f/codebase_tar/main_script.ts",
`import * as fs from "fs";
export function main() {
const data = JSON.parse(fs.readFileSync("data.json", "utf-8"));
return data.message;
}`
);
await createPathScriptFlow(tempDir, "f/test/assets_path_flow.flow", {
summary: "Flow with asset-backed PathScript",
scriptPath: "f/codebase_tar/main_script",
});
const localResult = await backend.runCLICommand(
["flow", "preview", "f/test/assets_path_flow.flow"],
tempDir
);
expect(localResult.code).not.toEqual(0);
expect(localResult.stdout + localResult.stderr).toContain(
"requires codebase assets"
);
});
});

View File

@@ -1,322 +0,0 @@
/**
* Unit tests for replacePathScriptsWithLocal.
*
* Tests that PathScript ("script" type) modules are correctly converted
* to RawScript ("rawscript" type) using local file content during
* flow preview / dev mode.
*/
import { expect, test, describe } from "bun:test";
import { replacePathScriptsWithLocal, type LocalScriptInfo } from "../windmill-utils-internal/src/inline-scripts/replacer.ts";
import type { FlowModule } from "../windmill-utils-internal/src/gen/types.gen.ts";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makePathScriptModule(
id: string,
path: string,
inputTransforms: Record<string, any> = {},
tagOverride?: string,
): FlowModule {
return {
id,
value: {
type: "script" as const,
path,
input_transforms: inputTransforms,
tag_override: tagOverride,
},
};
}
function makeRawscriptModule(
id: string,
content: string,
language: "bun" | "python3" | "deno" = "bun",
): FlowModule {
return {
id,
value: {
type: "rawscript" as const,
content,
language,
input_transforms: {},
},
};
}
const noopLogger = {
info: () => {},
error: () => {},
};
// ---------------------------------------------------------------------------
// Basic conversion tests
// ---------------------------------------------------------------------------
describe("replacePathScriptsWithLocal", () => {
test("converts PathScript to RawScript when local file exists", async () => {
const module = makePathScriptModule("a", "f/scripts/my_script", {
x: { type: "static", value: 42 },
});
const scriptReader = async (scriptPath: string): Promise<LocalScriptInfo | undefined> => {
if (scriptPath === "f/scripts/my_script") {
return {
content: 'export function main() { return "hello"; }',
language: "bun",
lock: "some-lock",
};
}
return undefined;
};
await replacePathScriptsWithLocal([module], scriptReader, noopLogger);
expect(module.value.type).toBe("rawscript");
expect((module.value as any).content).toBe('export function main() { return "hello"; }');
expect((module.value as any).language).toBe("bun");
expect((module.value as any).lock).toBe("some-lock");
expect((module.value as any).path).toBe("f/scripts/my_script");
expect((module.value as any).input_transforms).toEqual({ x: { type: "static", value: 42 } });
});
test("preserves tag_override as tag", async () => {
const module = makePathScriptModule("a", "f/scripts/tagged", {}, "my-worker");
const scriptReader = async (scriptPath: string): Promise<LocalScriptInfo | undefined> => {
if (scriptPath === "f/scripts/tagged") {
return { content: "code", language: "python3", tag: "script-worker" };
}
return undefined;
};
await replacePathScriptsWithLocal([module], scriptReader, noopLogger);
expect(module.value.type).toBe("rawscript");
expect((module.value as any).tag).toBe("my-worker");
});
test("uses local script tag when no tag_override is set", async () => {
const module = makePathScriptModule("a", "f/scripts/tagged");
const scriptReader = async (scriptPath: string): Promise<LocalScriptInfo | undefined> => {
if (scriptPath === "f/scripts/tagged") {
return { content: "code", language: "python3", tag: "script-worker" };
}
return undefined;
};
await replacePathScriptsWithLocal([module], scriptReader, noopLogger);
expect(module.value.type).toBe("rawscript");
expect((module.value as any).tag).toBe("script-worker");
});
test("leaves PathScript untouched when local file not found", async () => {
const module = makePathScriptModule("a", "f/scripts/remote_only");
const scriptReader = async (): Promise<LocalScriptInfo | undefined> => undefined;
await replacePathScriptsWithLocal([module], scriptReader, noopLogger);
expect(module.value.type).toBe("script");
expect((module.value as any).path).toBe("f/scripts/remote_only");
});
test("does not affect rawscript modules", async () => {
const module = makeRawscriptModule("a", "existing code", "bun");
const scriptReader = async (): Promise<LocalScriptInfo | undefined> => {
throw new Error("should not be called for rawscript");
};
await replacePathScriptsWithLocal([module], scriptReader, noopLogger);
expect(module.value.type).toBe("rawscript");
expect((module.value as any).content).toBe("existing code");
});
test("handles mixed PathScript and RawScript modules", async () => {
const pathModule = makePathScriptModule("a", "f/scripts/local_script");
const rawModule = makeRawscriptModule("b", "inline code", "bun");
const remoteModule = makePathScriptModule("c", "f/scripts/remote_only");
const scriptReader = async (scriptPath: string): Promise<LocalScriptInfo | undefined> => {
if (scriptPath === "f/scripts/local_script") {
return { content: "local code", language: "python3" };
}
return undefined;
};
await replacePathScriptsWithLocal(
[pathModule, rawModule, remoteModule],
scriptReader,
noopLogger,
);
// PathScript with local file → converted
expect(pathModule.value.type).toBe("rawscript");
expect((pathModule.value as any).content).toBe("local code");
// RawScript → untouched
expect(rawModule.value.type).toBe("rawscript");
expect((rawModule.value as any).content).toBe("inline code");
// PathScript without local file → untouched
expect(remoteModule.value.type).toBe("script");
expect((remoteModule.value as any).path).toBe("f/scripts/remote_only");
});
test("handles module without lock", async () => {
const module = makePathScriptModule("a", "f/scripts/no_lock");
const scriptReader = async (scriptPath: string): Promise<LocalScriptInfo | undefined> => {
if (scriptPath === "f/scripts/no_lock") {
return { content: "code", language: "go" };
}
return undefined;
};
await replacePathScriptsWithLocal([module], scriptReader, noopLogger);
expect(module.value.type).toBe("rawscript");
expect((module.value as any).lock).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Nested structure tests
// ---------------------------------------------------------------------------
describe("replacePathScriptsWithLocal nested structures", () => {
test("processes modules inside forloopflow", async () => {
const innerModule = makePathScriptModule("inner", "f/scripts/loop_script");
const loopModule: FlowModule = {
id: "loop",
value: {
type: "forloopflow" as const,
iterator: { type: "static" as const, value: "" },
modules: [innerModule],
skip_failures: false,
},
};
const scriptReader = async (scriptPath: string): Promise<LocalScriptInfo | undefined> => {
if (scriptPath === "f/scripts/loop_script") {
return { content: "loop code", language: "bun" };
}
return undefined;
};
await replacePathScriptsWithLocal([loopModule], scriptReader, noopLogger);
expect(innerModule.value.type).toBe("rawscript");
expect((innerModule.value as any).content).toBe("loop code");
});
test("processes modules inside whileloopflow", async () => {
const innerModule = makePathScriptModule("inner", "f/scripts/while_script");
const whileModule: FlowModule = {
id: "while",
value: {
type: "whileloopflow" as const,
modules: [innerModule],
skip_failures: false,
},
};
const scriptReader = async (scriptPath: string): Promise<LocalScriptInfo | undefined> => {
if (scriptPath === "f/scripts/while_script") {
return { content: "while code", language: "bun" };
}
return undefined;
};
await replacePathScriptsWithLocal([whileModule], scriptReader, noopLogger);
expect(innerModule.value.type).toBe("rawscript");
expect((innerModule.value as any).content).toBe("while code");
});
test("processes modules inside branchall", async () => {
const branch1Module = makePathScriptModule("b1", "f/scripts/branch1");
const branch2Module = makePathScriptModule("b2", "f/scripts/branch2");
const branchAllModule: FlowModule = {
id: "branches",
value: {
type: "branchall" as const,
branches: [
{ modules: [branch1Module], skip_failure: false },
{ modules: [branch2Module], skip_failure: false },
],
},
};
const scriptReader = async (scriptPath: string): Promise<LocalScriptInfo | undefined> => {
if (scriptPath === "f/scripts/branch1") {
return { content: "branch1 code", language: "bun" };
}
if (scriptPath === "f/scripts/branch2") {
return { content: "branch2 code", language: "python3" };
}
return undefined;
};
await replacePathScriptsWithLocal([branchAllModule], scriptReader, noopLogger);
expect(branch1Module.value.type).toBe("rawscript");
expect((branch1Module.value as any).content).toBe("branch1 code");
expect(branch2Module.value.type).toBe("rawscript");
expect((branch2Module.value as any).content).toBe("branch2 code");
expect((branch2Module.value as any).language).toBe("python3");
});
test("processes modules inside branchone (branches + default)", async () => {
const branchModule = makePathScriptModule("b1", "f/scripts/branch");
const defaultModule = makePathScriptModule("d1", "f/scripts/default");
const branchOneModule: FlowModule = {
id: "branchone",
value: {
type: "branchone" as const,
branches: [
{ modules: [branchModule], expr: "true" },
],
default: [defaultModule],
},
};
const scriptReader = async (scriptPath: string): Promise<LocalScriptInfo | undefined> => {
if (scriptPath === "f/scripts/branch") {
return { content: "branch code", language: "bun" };
}
if (scriptPath === "f/scripts/default") {
return { content: "default code", language: "bash" };
}
return undefined;
};
await replacePathScriptsWithLocal([branchOneModule], scriptReader, noopLogger);
expect(branchModule.value.type).toBe("rawscript");
expect((branchModule.value as any).content).toBe("branch code");
expect(defaultModule.value.type).toBe("rawscript");
expect((defaultModule.value as any).content).toBe("default code");
expect((defaultModule.value as any).language).toBe("bash");
});
test("handles empty modules array", async () => {
const scriptReader = async (): Promise<LocalScriptInfo | undefined> => undefined;
// Should not throw
await replacePathScriptsWithLocal([], scriptReader, noopLogger);
});
test("handles module with undefined value gracefully", async () => {
const module: FlowModule = { id: "x", value: undefined as any };
const scriptReader = async (): Promise<LocalScriptInfo | undefined> => undefined;
// Should not throw
await replacePathScriptsWithLocal([module], scriptReader, noopLogger);
});
});

View File

@@ -1,12 +1,12 @@
{
"name": "windmill-utils-internal",
"version": "1.3.5",
"version": "1.3.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-utils-internal",
"version": "1.3.5",
"version": "1.3.4",
"license": "Apache 2.0",
"devDependencies": {
"@types/node": "^24.2.0",

View File

@@ -1,6 +1,6 @@
{
"name": "windmill-utils-internal",
"version": "1.3.5",
"version": "1.3.4",
"description": "Internal utility functions for Windmill",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
@@ -31,4 +31,4 @@
"files": [
"dist/**/*"
]
}
}

View File

@@ -1,11 +1,4 @@
import { AiAgent, FlowModule, FlowValue, RawScript } from "../gen/types.gen.ts";
export type LocalScriptInfo = {
content: string;
language: RawScript["language"];
lock?: string;
tag?: string;
};
import { FlowModule, RawScript } from "../gen/types.gen.ts";
async function replaceRawscriptInline(
id: string,
@@ -125,154 +118,4 @@ export async function replaceInlineScripts(
}));
}
}));
}
/**
* Replaces PathScript ("script" type) modules with RawScript ("rawscript" type) using local file content.
* This is used during flow preview so that local script changes are tested instead of remote versions.
*
* @param modules - Array of flow modules to process
* @param scriptReader - Function that takes a script path and returns local content/language/lock, or undefined if not found locally
* @param logger - Logger for info/error messages
*/
export async function replacePathScriptsWithLocal(
modules: FlowModule[],
scriptReader: (scriptPath: string) => Promise<LocalScriptInfo | undefined>,
logger: {
info: (message: string) => void;
error: (message: string) => void;
} = {
info: () => {},
error: () => {},
}
): Promise<void> {
await Promise.all(modules.map(async (module) => {
if (!module.value) {
return;
}
if (module.value.type === "script") {
const scriptPath = module.value.path;
const localScript = await scriptReader(scriptPath);
if (localScript) {
const pathScript = module.value;
module.value = {
type: "rawscript",
content: localScript.content,
language: localScript.language,
lock: localScript.lock,
path: scriptPath,
input_transforms: pathScript.input_transforms,
tag: pathScript.tag_override ?? localScript.tag,
} satisfies RawScript;
}
} else if (module.value.type === "forloopflow" || module.value.type === "whileloopflow") {
await replacePathScriptsWithLocal(module.value.modules, scriptReader, logger);
} else if (module.value.type === "branchall") {
await Promise.all(module.value.branches.map(async (branch) => {
await replacePathScriptsWithLocal(branch.modules, scriptReader, logger);
}));
} else if (module.value.type === "branchone") {
await Promise.all(module.value.branches.map(async (branch) => {
await replacePathScriptsWithLocal(branch.modules, scriptReader, logger);
}));
await replacePathScriptsWithLocal(module.value.default, scriptReader, logger);
} else if (module.value.type === "aiagent") {
await Promise.all((module.value.tools ?? []).map(async (tool) => {
const toolValue = tool.value;
if (!toolValue || toolValue.tool_type !== "flowmodule" || toolValue.type !== "script") {
return;
}
const localScript = await scriptReader(toolValue.path);
if (localScript) {
(tool as AiAgent["tools"][number]).value = {
tool_type: "flowmodule",
type: "rawscript",
content: localScript.content,
language: localScript.language,
lock: localScript.lock,
path: toolValue.path,
input_transforms: toolValue.input_transforms,
tag: toolValue.tag_override ?? localScript.tag,
};
}
}));
}
}));
}
function collectPathScriptPathsFromModules(
modules: FlowModule[],
paths: Set<string>
): void {
for (const module of modules) {
if (!module.value) {
continue;
}
if (module.value.type === "script") {
paths.add(module.value.path);
} else if (
module.value.type === "forloopflow" ||
module.value.type === "whileloopflow"
) {
collectPathScriptPathsFromModules(module.value.modules, paths);
} else if (module.value.type === "branchall") {
for (const branch of module.value.branches) {
collectPathScriptPathsFromModules(branch.modules, paths);
}
} else if (module.value.type === "branchone") {
for (const branch of module.value.branches) {
collectPathScriptPathsFromModules(branch.modules, paths);
}
collectPathScriptPathsFromModules(module.value.default, paths);
} else if (module.value.type === "aiagent") {
for (const tool of module.value.tools ?? []) {
const toolValue = tool.value;
if (
toolValue &&
toolValue.tool_type === "flowmodule" &&
toolValue.type === "script"
) {
paths.add(toolValue.path);
}
}
}
}
}
/**
* Replaces all PathScript modules in a flow value (modules, failure_module, preprocessor_module)
* with RawScript using local file content.
*/
export async function replaceAllPathScriptsWithLocal(
flowValue: FlowValue,
scriptReader: (scriptPath: string) => Promise<LocalScriptInfo | undefined>,
logger: {
info: (message: string) => void;
error: (message: string) => void;
} = {
info: () => {},
error: () => {},
}
): Promise<void> {
await replacePathScriptsWithLocal(flowValue.modules, scriptReader, logger);
if (flowValue.failure_module) {
await replacePathScriptsWithLocal([flowValue.failure_module], scriptReader, logger);
}
if (flowValue.preprocessor_module) {
await replacePathScriptsWithLocal([flowValue.preprocessor_module], scriptReader, logger);
}
}
export function collectPathScriptPaths(flowValue: FlowValue): string[] {
const paths = new Set<string>();
collectPathScriptPathsFromModules(flowValue.modules, paths);
if (flowValue.failure_module) {
collectPathScriptPathsFromModules([flowValue.failure_module], paths);
}
if (flowValue.preprocessor_module) {
collectPathScriptPathsFromModules([flowValue.preprocessor_module], paths);
}
return [...paths];
}
}

View File

@@ -1069,14 +1069,11 @@ const server = Bun.serve({
// Handle WebSocket upgrade with path-based routing
if (server.upgrade(req, { data: { path } })) {
logger.info(`WS upgrade: ${path}`)
return undefined as unknown as Response
}
logger.info(`HTTP ${req.method} ${path}`)
// Health check endpoint
if (path === '/health' || path === '/ws_debug/health') {
if (path === '/health') {
return new Response(JSON.stringify({
status: 'ok',
service: 'debugger',
@@ -1105,7 +1102,6 @@ const server = Bun.serve({
// Handle ping test — respond and close immediately
if (path === '/ping') {
logger.info(`WS ping test`)
ws.send(JSON.stringify({ type: 'pong', service: 'debugger' }))
ws.close()
return

15
frontend/.workmux.yaml Normal file
View File

@@ -0,0 +1,15 @@
panes:
# Pane 1: Install dependencies, then start dev server
- command: npm install && npm run generate-backend-client && npm run dev
# Pane 2: AI agent
- command: <agent>
split: horizontal
focus: true
files:
copy:
- .env
symlink:
- node_modules

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
node ./scripts/untar_ui_builder.js
# node ./scripts/untar_ui_builder.js
mkdir ui_builder_serve || true
cp -r static/ui_builder ui_builder_serve/ui_builder || true
rm -rf static/ui_builder || true
# mkdir ui_builder_serve || true
# cp -r static/ui_builder ui_builder_serve/ui_builder || true
# rm -rf static/ui_builder || true
python3 -c "
import os
os.chdir('ui_builder_serve')
@@ -14,4 +14,4 @@ class H(SimpleHTTPRequestHandler):
self.send_header('Cross-Origin-Resource-Policy', 'cross-origin')
super().end_headers()
HTTPServer(('', 4000), H).serve_forever()
"
"

View File

@@ -63,19 +63,18 @@
const inputsWithPayload = await Promise.all(
inputs.map(async (input) => {
const isPreview = !!(input as any).is_preview
if (cachedArgs[input.id]) {
return {
...input,
payloadData: cachedArgs[input.id]
}
}
const payloadData = await loadArgsFromHistory(input.id, undefined, false, isPreview)
const payloadData = await loadArgsFromHistory(input.id, undefined, false)
if (payloadData === 'WINDMILL_TOO_BIG') {
return {
...input,
payloadData: 'WINDMILL_TOO_BIG',
getFullPayload: () => loadArgsFromHistory(input.id, undefined, true, isPreview)
getFullPayload: () => loadArgsFromHistory(input.id, undefined, true)
}
}
cachedArgs[input.id] = payloadData
@@ -94,17 +93,10 @@
timeout && clearTimeout(timeout)
})
function filterPreviewArgs(args: any, isPreview: boolean): any {
if (!isPreview || !args || typeof args !== 'object' || Array.isArray(args)) return args
const { _MODULES, ...rest } = args
return rest
}
async function loadArgsFromHistory(
id: string | undefined,
input: boolean | undefined,
allowLarge: boolean,
isPreview: boolean = false
allowLarge: boolean
): Promise<any> {
if (!id) return
const payloadData = await InputService.getArgsFromHistoryOrSavedInput({
@@ -113,7 +105,7 @@
input,
allowLarge
})
return filterPreviewArgs(payloadData, isPreview)
return payloadData
}
function updateViewerOpenState(itemId: string, isOpen: boolean) {
@@ -139,7 +131,6 @@
job={item}
hovering={hover}
payloadData={item.payloadData}
isPreview={!!item.is_preview}
{showAuthor}
{placement}
{viewerOpen}

View File

@@ -59,13 +59,11 @@
)
let max = $derived(
flowDone
? Object.values(flow_status).reduce((a, b) => {
if (!b.started_at) return a
const startedAt = new Date(b.started_at).getTime()
// For cancelled steps without duration_ms, use `now` as end time
const endAt = b.duration_ms != undefined ? startedAt + b.duration_ms : now
return Math.max(a, endAt)
}, 0)
? Object.values(flow_status).reduce(
(a, b) =>
Math.max(a, b.started_at ? new Date(b.started_at).getTime() + (b.duration_ms ?? 0) : 0),
0
)
: undefined
)
let total = $derived(flowDone && max ? max - min : Math.max(now - min, 2000))
@@ -108,7 +106,7 @@
let pollInterval = setInterval(() => {
for (const [id, v] of Object.entries(flow_status)) {
if (isStep(id)) continue
const isRunning = !flowDone && v.duration_ms == undefined && v.started_at != undefined
const isRunning = v.duration_ms == undefined && v.started_at != undefined
if (expandedRows[id] && isRunning) {
fetchChildJob(id)
}
@@ -156,8 +154,8 @@
{@const isInlineStep = isStep(k)}
{@const isSleep = (v as any).sleep_duration_s != undefined}
{@const isApproval = (v as any).approval === true}
{@const isRunning = !flowDone && v.duration_ms == undefined && v.started_at != undefined}
{@const isDone = v.duration_ms != undefined || flowDone}
{@const isRunning = v.duration_ms == undefined && v.started_at != undefined}
{@const isDone = v.duration_ms != undefined}
{@const isExpanded = expandedRows[k] ?? false}
<div class="border-b last:border-b-0">
{#if isSleep}
@@ -236,7 +234,7 @@
{min}
started_at={startedAt}
len={v.duration_ms ?? now - startedAt}
running={!flowDone && v.duration_ms == undefined}
running={v.duration_ms == undefined}
/>
{/if}
{:else}
@@ -248,7 +246,7 @@
gray
started_at={scheduledFor}
len={waitingLen < 100 ? 0 : waitingLen - 100}
running={!flowDone && startedAt == undefined}
running={startedAt == undefined}
/>
{#if startedAt}
<TimelineBar
@@ -259,7 +257,7 @@
concat
started_at={startedAt}
len={v.duration_ms ?? now - startedAt}
running={!flowDone && v.duration_ms == undefined}
running={v.duration_ms == undefined}
/>
{/if}
{/if}

View File

@@ -9,7 +9,6 @@
payloadData?: any | undefined
hovering?: boolean
showAuthor?: boolean
isPreview?: boolean
placement?: 'bottom-start' | 'top-start' | 'bottom-end' | 'top-end'
viewerOpen?: boolean
limitPayloadSize?: boolean
@@ -20,7 +19,6 @@
payloadData = undefined,
hovering = false,
showAuthor = false,
isPreview = false,
placement = 'bottom-start',
viewerOpen = false,
limitPayloadSize = false
@@ -37,17 +35,11 @@
{limitPayloadSize}
>
{#snippet start()}
<div class="center-center relative">
<div class="center-center">
<div
class="rounded-full w-2 h-2 {job.success ? 'bg-green-400' : 'bg-red-400'}"
title={job.success ? 'Success' : 'Failed'}
></div>
{#if isPreview}
<span
class="absolute -top-1.5 right-0.5 text-[8px] font-bold leading-none text-tertiary"
title="Preview run">P</span
>
{/if}
</div>
{/snippet}
{#snippet extra()}

View File

@@ -68,6 +68,7 @@
showCustomResultPanel = false
}: Props = $props()
type DContent = {
mode: 'json' | Preview['language'] | 'plain'
title: string
@@ -106,10 +107,7 @@
let forceJson = $state(false)
let isWac = $derived(!!previewJob?.workflow_as_code_status)
let wacDone = $derived(
previewJob?.type == 'CompletedJob' ||
(previewJob != undefined && !previewIsLoading && !!previewJob.workflow_as_code_status)
)
let wacDone = $derived(previewJob?.type == 'CompletedJob')
</script>
<Drawer bind:open={drawerOpen} size="800px">
@@ -161,67 +159,67 @@
/>
</div>
{:else}
<SplitPanesWrapper>
<Splitpanes horizontal>
<Pane class="relative">
<LogViewer
jobId={previewJob?.id}
duration={previewJob?.['duration_ms']}
mem={previewJob?.['mem_peak']}
content={previewJob?.logs}
isLoading={previewJob?.['running'] == false && previewIsLoading}
tag={previewJob?.tag}
download={customUi?.disableDownload !== true}
tagLabel={customUi?.tagLabel}
/>
</Pane>
<Pane>
{@render children?.()}
{#if showCustomResultPanel && customResultPanel}
<div class="h-full">
{@render customResultPanel()}
</div>
{:else if previewJob != undefined && (previewJob.result_stream || previewJob.result)}
<div class="relative w-full h-full p-2">
<div class="relative h-full">
<DisplayResult
bind:forceJson
workspaceId={previewJob?.workspace_id}
jobId={previewJob?.id}
result={previewJob.result}
customUi={customUi?.displayResult}
language={lang}
result_stream={previewJob?.result_stream}
fixTableSizingToParent
>
{#snippet copilot_fix()}
{#if lang && editor && diffEditor && args && previewJob && !previewJob.success && getStringError(previewJob.result)}
<ScriptFix {lang} />
{/if}
{/snippet}
</DisplayResult>
</div>
</div>
{:else}
<div class="text-sm text-primary p-2 flex justify-between items-center">
<span>
{#if previewIsLoading}
<Loader2 class="animate-spin" />
{:else}
Test to see the result here
{/if}
</span>
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/rich_display_rendering"
<SplitPanesWrapper>
<Splitpanes horizontal>
<Pane class="relative">
<LogViewer
jobId={previewJob?.id}
duration={previewJob?.['duration_ms']}
mem={previewJob?.['mem_peak']}
content={previewJob?.logs}
isLoading={previewJob?.['running'] == false && previewIsLoading}
tag={previewJob?.tag}
download={customUi?.disableDownload !== true}
tagLabel={customUi?.tagLabel}
/>
</Pane>
<Pane>
{@render children?.()}
{#if showCustomResultPanel && customResultPanel}
<div class="h-full">
{@render customResultPanel()}
</div>
{:else if previewJob != undefined && (previewJob.result_stream || previewJob.result)}
<div class="relative w-full h-full p-2">
<div class="relative h-full">
<DisplayResult
bind:forceJson
workspaceId={previewJob?.workspace_id}
jobId={previewJob?.id}
result={previewJob.result}
customUi={customUi?.displayResult}
language={lang}
result_stream={previewJob?.result_stream}
fixTableSizingToParent
>
The result renderer in Windmill supports rich display rendering, allowing
you to customize the display format of your results.
</Tooltip>
{#snippet copilot_fix()}
{#if lang && editor && diffEditor && args && previewJob && !previewJob.success && getStringError(previewJob.result)}
<ScriptFix {lang} />
{/if}
{/snippet}
</DisplayResult>
</div>
{/if}
</Pane>
</Splitpanes>
</SplitPanesWrapper>
</div>
{:else}
<div class="text-sm text-primary p-2 flex justify-between items-center">
<span>
{#if previewIsLoading}
<Loader2 class="animate-spin" />
{:else}
Test to see the result here
{/if}
</span>
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/rich_display_rendering"
>
The result renderer in Windmill supports rich display rendering, allowing you
to customize the display format of your results.
</Tooltip>
</div>
{/if}
</Pane>
</Splitpanes>
</SplitPanesWrapper>
{/if}
{/if}
{#if selectedTab === 'history'}
@@ -325,7 +323,9 @@
{#if previewJob?.id}
<JobOtelTraces jobId={previewJob.id} />
{:else}
<div class="p-4 text-secondary"> Run a preview to see HTTP request traces </div>
<div class="p-4 text-secondary">
Run a preview to see HTTP request traces
</div>
{/if}
{/if}
</div>

View File

@@ -1,167 +0,0 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import Button from '../common/button/Button.svelte'
import Drawer from '../common/drawer/Drawer.svelte'
import DrawerContent from '../common/drawer/DrawerContent.svelte'
import { sendUserToast } from '$lib/toast'
import { WorkspaceService } from '$lib/gen'
import type { QuotaInfo } from '$lib/gen'
import { untrack } from 'svelte'
import { Trash2 } from 'lucide-svelte'
type ResourceType = 'scripts' | 'flows' | 'apps'
let quotas:
| {
scripts: QuotaInfo
flows: QuotaInfo
apps: QuotaInfo
variables: QuotaInfo
resources: QuotaInfo
}
| undefined = $state(undefined)
let loading = $state(false)
let pruning = $state(false)
let drawer: Drawer | undefined = $state()
let pruneTarget: ResourceType | undefined = $state(undefined)
$effect(() => {
if ($workspaceStore) {
untrack(() => loadQuotas())
}
})
async function loadQuotas() {
loading = true
try {
quotas = await WorkspaceService.getCloudQuotas({ workspace: $workspaceStore! })
} catch (e) {
sendUserToast(`Failed to load cloud quotas: ${e}`, true)
} finally {
loading = false
}
}
function openPruneDrawer(type: ResourceType) {
pruneTarget = type
drawer?.openDrawer()
}
async function confirmPrune() {
if (!pruneTarget) return
pruning = true
try {
const result = await WorkspaceService.pruneVersions({
workspace: $workspaceStore!,
requestBody: { resource_type: pruneTarget }
})
sendUserToast(`Pruned ${result.pruned} old ${pruneTarget} versions`)
drawer?.closeDrawer()
await loadQuotas()
} catch (e) {
sendUserToast(`Failed to prune: ${e}`, true)
} finally {
pruning = false
}
}
function getPrunableCount(type: ResourceType): number {
if (!quotas) return 0
return quotas[type].prunable
}
function getPruneDescription(type: ResourceType): string {
switch (type) {
case 'scripts':
return 'This will permanently delete all non-HEAD script versions (old edits). The latest deployed version of each script will be preserved. This directly frees up quota space since each script edit creates a new row counted against the limit.'
case 'flows':
return 'This will permanently delete all non-HEAD flow versions. Only the latest version of each flow will be kept. This frees up storage but does not reduce the flow count (quota counts unique flows, not versions).'
case 'apps':
return 'This will permanently delete all non-HEAD app versions. Only the latest version of each app will be kept. This frees up storage but does not reduce the app count (quota counts unique apps, not versions).'
}
}
const rows: { label: string; key: keyof NonNullable<typeof quotas>; prunable: boolean }[] = [
{ label: 'Scripts', key: 'scripts', prunable: true },
{ label: 'Flows', key: 'flows', prunable: true },
{ label: 'Apps', key: 'apps', prunable: true },
{ label: 'Variables', key: 'variables', prunable: false },
{ label: 'Resources', key: 'resources', prunable: false }
]
</script>
<div class="flex flex-col gap-2">
<p class="font-semibold text-xs text-emphasis">Cloud Quotas</p>
<p class="text-xs text-secondary font-normal">
Current usage and limits for this workspace. Prune old versions to free up space.
</p>
{#if loading && !quotas}
<p class="text-xs text-tertiary">Loading...</p>
{:else if quotas}
<div class="border rounded-md overflow-hidden">
<table class="w-full text-xs">
<thead>
<tr class="bg-surface-secondary border-b">
<th class="text-left px-3 py-2 text-secondary font-medium">Resource</th>
<th class="text-left px-3 py-2 text-secondary font-medium">Usage</th>
<th class="text-right px-3 py-2 text-secondary font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#each rows as row (row.key)}
{@const info = quotas[row.key]}
<tr class="border-b last:border-b-0">
<td class="px-3 py-2 text-primary font-medium">{row.label}</td>
<td class="px-3 py-2">
<span
class={info.used >= info.limit ? 'text-red-500 font-semibold' : 'text-primary'}
>
{info.used}
</span>
<span class="text-tertiary">/ {info.limit}</span>
</td>
<td class="px-3 py-2 text-right">
{#if row.prunable && info.prunable > 0}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: Trash2 }}
on:click={() => openPruneDrawer(row.key as ResourceType)}
>
Prune {info.prunable} old versions
</Button>
{:else if row.prunable}
<span class="text-tertiary">No old versions</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<Drawer bind:this={drawer}>
<DrawerContent title="Prune old versions" on:close={drawer?.closeDrawer}>
{#if pruneTarget}
<div class="flex flex-col gap-4">
<p class="text-sm text-primary">
You are about to prune <span class="font-semibold">{getPrunableCount(pruneTarget)}</span>
old {pruneTarget} versions.
</p>
<p class="text-xs text-secondary">
{getPruneDescription(pruneTarget)}
</p>
<p class="text-xs text-red-500 font-medium">This action cannot be undone.</p>
</div>
{/if}
{#snippet actions()}
<Button variant="accent" on:click={confirmPrune} disabled={pruning}>
{pruning ? 'Pruning...' : 'Confirm Prune'}
</Button>
{/snippet}
</DrawerContent>
</Drawer>

View File

@@ -43,7 +43,6 @@
import ChangeWorkspaceName from '$lib/components/settings/ChangeWorkspaceName.svelte'
import ChangeWorkspaceId from '$lib/components/settings/ChangeWorkspaceId.svelte'
import ChangeWorkspaceColor from '$lib/components/settings/ChangeWorkspaceColor.svelte'
import CloudQuotas from '$lib/components/settings/CloudQuotas.svelte'
import {
convertBackendSettingsToFrontendSettings,
type S3ResourceSettings
@@ -1526,11 +1525,6 @@
link="https://www.windmill.dev/docs/core_concepts/workspace_settings"
/>
{#if isCloudHosted()}
<CloudQuotas />
<div class="my-4 border-b"></div>
{/if}
<div class="flex flex-col gap-6">
<ChangeWorkspaceName />
<ChangeWorkspaceId />

View File

@@ -51,11 +51,13 @@
}
} else {
if (
(!page.url.pathname.startsWith('/user/') || page.url.pathname.startsWith('/user/cli')) &&
!page.url.pathname.startsWith('/oauth/mcp_authorize')
!page.url.pathname.startsWith('/user/') ||
page.url.pathname.startsWith('/user/cli')
) {
goto(
`/user/workspaces?rd=${encodeURIComponent(page.url.href.replace(page.url.origin, ''))}`
`/user/workspaces?rd=${encodeURIComponent(
page.url.href.replace(page.url.origin, '')
)}`
)
}
let user = await UserService.globalWhoami()

View File

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

View File

@@ -96,7 +96,6 @@ class HealthHandler(web.RequestHandler):
self.set_header("Content-Type", "application/json")
def get(self):
log.info("HTTP GET %s", self.request.uri)
self.write(json.dumps({"status": "ok", "service": "lsp"}))
def options(self):
@@ -105,7 +104,6 @@ class HealthHandler(web.RequestHandler):
class PingHandler(websocket.WebSocketHandler):
def open(self):
log.info("WS ping from %s", self.request.remote_ip)
self.write_message(json.dumps({"type": "pong", "service": "lsp"}))
self.close()

View File

@@ -122,7 +122,6 @@ const setupWSConnection = (conn, req, docName) => {
}
const server = http.createServer((req, res) => {
console.log(`[${new Date().toISOString()}] HTTP ${req.method} ${req.url} from=${req.socket.remoteAddress}`)
if (req.url === '/' || req.url === '/health' || req.url === '/ws_mp/health') {
res.writeHead(200, {
'Content-Type': 'application/json',
@@ -145,16 +144,15 @@ wss.on('connection', (ws, req) => {
docName = docName.slice('ws_mp/'.length)
}
const clientIp = req.socket.remoteAddress
// Handle ping test — respond and close immediately
if (docName === '__ping__') {
console.log(`[${new Date().toISOString()}] WS ping from=${clientIp}`)
ws.send(JSON.stringify({ type: 'pong', service: 'multiplayer' }))
ws.close()
return
}
const clientIp = req.socket.remoteAddress
console.log(`[${new Date().toISOString()}] CONNECT: doc="${docName}" from=${clientIp}`)
ws.on('close', () => {

View File

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

View File

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

View File

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

View File

@@ -86,10 +86,9 @@ flow related commands
- `flow run <path:string>` - run a flow by path.
- `-d --data <data:string>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- `-s --silent` - Do not ouput anything other then the final output. Useful for scripting.
- `flow preview <flow_path:string>` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.
- `flow preview <flow_path:string>` - preview a local flow without deploying it. Runs the flow definition from local files.
- `-d --data <data:string>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- `-s --silent` - Do not output anything other then the final output. Useful for scripting.
- `--remote` - Use deployed workspace scripts for PathScript steps instead of local files.
- `flow generate-locks [flow:file]` - re-generate the lock files of all inline scripts of all updated flows
- `--yes` - Skip confirmation prompt
- `-i --includes <patterns:file[]>` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)

View File

@@ -1460,10 +1460,9 @@ flow related commands
- \`flow run <path:string>\` - run a flow by path.
- \`-d --data <data:string>\` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- \`-s --silent\` - Do not ouput anything other then the final output. Useful for scripting.
- \`flow preview <flow_path:string>\` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.
- \`flow preview <flow_path:string>\` - preview a local flow without deploying it. Runs the flow definition from local files.
- \`-d --data <data:string>\` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- \`-s --silent\` - Do not output anything other then the final output. Useful for scripting.
- \`--remote\` - Use deployed workspace scripts for PathScript steps instead of local files.
- \`flow generate-locks [flow:file]\` - re-generate the lock files of all inline scripts of all updated flows
- \`--yes\` - Skip confirmation prompt
- \`-i --includes <patterns:file[]>\` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)

View File

@@ -91,10 +91,9 @@ flow related commands
- `flow run <path:string>` - run a flow by path.
- `-d --data <data:string>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- `-s --silent` - Do not ouput anything other then the final output. Useful for scripting.
- `flow preview <flow_path:string>` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.
- `flow preview <flow_path:string>` - preview a local flow without deploying it. Runs the flow definition from local files.
- `-d --data <data:string>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- `-s --silent` - Do not output anything other then the final output. Useful for scripting.
- `--remote` - Use deployed workspace scripts for PathScript steps instead of local files.
- `flow generate-locks [flow:file]` - re-generate the lock files of all inline scripts of all updated flows
- `--yes` - Skip confirmation prompt
- `-i --includes <patterns:file[]>` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)

View File

@@ -1,6 +1,6 @@
{
"name": "@windmill/windmill",
"version": "1.660.0",
"version": "1.659.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.660.0",
"version": "1.659.1",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"sideEffects": false,

View File

@@ -1 +1 @@
1.660.0
1.659.1