Merge remote-tracking branch 'origin/main' into raw-apps-wmill-client-frontend

# Conflicts:
#	backend/ee-repo-ref.txt
This commit is contained in:
Diego Imbert
2026-07-30 20:53:54 +02:00
80 changed files with 2282 additions and 1022 deletions
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## [1.775.2](https://github.com/windmill-labs/windmill/compare/v1.775.1...v1.775.2) (2026-07-29)
### Bug Fixes
* **cli:** compile runes in .svelte.ts / .svelte.js modules ([#10400](https://github.com/windmill-labs/windmill/issues/10400)) ([9c37b02](https://github.com/windmill-labs/windmill/commit/9c37b0217c79390271d9855abe135df02f35c41f))
* distinguish waiting-on-user from streaming in ai sessions ([#10396](https://github.com/windmill-labs/windmill/issues/10396)) ([a17ccbd](https://github.com/windmill-labs/windmill/commit/a17ccbdd0d3c9f2f6234c4cc70d0917e83eeaceb))
* surface the real reason git sync settings saves are rejected ([#10398](https://github.com/windmill-labs/windmill/issues/10398)) ([33d2845](https://github.com/windmill-labs/windmill/commit/33d28456cc8ef9f64301eb3dd6dad6d7325018e3))
## [1.775.1](https://github.com/windmill-labs/windmill/compare/v1.775.0...v1.775.1) (2026-07-28)
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, deploy_to, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, deploy_to, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts FROM workspace_settings WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "042c42957221352891f7433f2ec0d6b7f686d9c7d505590eec7ba3bfeeb406ca"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET deploy_to = $1 WHERE deploy_to = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "0621faf69b1ef866a95f6310c9651875df409a2d2d72ada629bd71e8abdbbf8b"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET error_handler_fallback_to_instance_alerts = false WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "0c5558686b4e5a9bacc73c7b6709ad4e3e6e69582605eeb84a93c29d629b94be"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE workspace_settings\n SET\n ai_config = source_ws.ai_config,\n large_file_storage = source_ws.large_file_storage,\n ducklake = source_ws.ducklake,\n datatable = source_ws.datatable,\n git_app_installations = source_ws.git_app_installations\n FROM workspace_settings source_ws\n WHERE source_ws.workspace_id = $1\n AND workspace_settings.workspace_id = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "2213dc4b594d27b3788c48b19e13c600224681e11c016eea5cee3086a7bea8d0"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_settings SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, deploy_to, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts FROM workspace_settings WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "2dc5a74c4e614b43148925cdbac234f6a2f295a9c46897f5e9aab8f7298ff0d7"
}
@@ -1,208 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n deploy_to,\n ai_config,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "slack_team_id",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "teams_team_id",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "teams_team_name",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "teams_team_guid",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "slack_name",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "slack_command_script",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "teams_command_script",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "slack_email",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "slack_oauth_client_id",
"type_info": "Varchar"
},
{
"ordinal": 10,
"name": "slack_oauth_client_secret",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "customer_id",
"type_info": "Varchar"
},
{
"ordinal": 12,
"name": "plan",
"type_info": "Varchar"
},
{
"ordinal": 13,
"name": "webhook",
"type_info": "Text"
},
{
"ordinal": 14,
"name": "deploy_to",
"type_info": "Varchar"
},
{
"ordinal": 15,
"name": "ai_config",
"type_info": "Jsonb"
},
{
"ordinal": 16,
"name": "large_file_storage",
"type_info": "Jsonb"
},
{
"ordinal": 17,
"name": "datatable",
"type_info": "Jsonb"
},
{
"ordinal": 18,
"name": "ducklake",
"type_info": "Jsonb"
},
{
"ordinal": 19,
"name": "git_sync",
"type_info": "Jsonb"
},
{
"ordinal": 20,
"name": "deploy_ui",
"type_info": "Jsonb"
},
{
"ordinal": 21,
"name": "default_app",
"type_info": "Varchar"
},
{
"ordinal": 22,
"name": "default_scripts",
"type_info": "Jsonb"
},
{
"ordinal": 23,
"name": "mute_critical_alerts",
"type_info": "Bool"
},
{
"ordinal": 24,
"name": "color",
"type_info": "Varchar"
},
{
"ordinal": 25,
"name": "operator_settings",
"type_info": "Jsonb"
},
{
"ordinal": 26,
"name": "git_app_installations",
"type_info": "Jsonb"
},
{
"ordinal": 27,
"name": "auto_invite",
"type_info": "Jsonb"
},
{
"ordinal": 28,
"name": "error_handler",
"type_info": "Jsonb"
},
{
"ordinal": 29,
"name": "success_handler",
"type_info": "Jsonb"
},
{
"ordinal": 30,
"name": "public_app_execution_limit_per_minute",
"type_info": "Int4"
},
{
"ordinal": 31,
"name": "error_handler_fallback_to_instance_alerts",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
true,
true,
true,
true,
true,
true,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
true,
true,
true,
true,
false
]
},
"hash": "5ae9ad14effe923f1952d0e1d1f67ad00ff6c13918d46dd474cc48824b12ccaf"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET deploy_to = $1, error_handler_fallback_to_instance_alerts = false WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "620c9efca071cb5fb8b33857129e63954f39b291f525e36e6ece916b98b4d2c9"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT ahead FROM workspace_diff\n WHERE source_workspace_id = 'test-workspace'\n AND fork_workspace_id = 'wm-fork-no-deploy-to'\n AND kind = 'script'\n AND path = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "ahead",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "6ade445fab717d2dadbebfb5747db8b49c98709cfc99a6ee25c29a230bf9ec46"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_settings SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, deploy_to, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler FROM workspace_settings WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "7c45f8d05a10ccf538c1b63aa1337e6d0491a2e8d04fe87eddb5a573de00d125"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts FROM workspace_settings WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "8058042d5564a934534517e9c0446c5fcc0c3654746c5e65994123ffb4d05f78"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n deploy_to,\n ai_config,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -75,88 +75,88 @@
},
{
"ordinal": 14,
"name": "deploy_to",
"type_info": "Varchar"
},
{
"ordinal": 15,
"name": "ai_config",
"type_info": "Jsonb"
},
{
"ordinal": 16,
"ordinal": 15,
"name": "large_file_storage",
"type_info": "Jsonb"
},
{
"ordinal": 17,
"ordinal": 16,
"name": "datatable",
"type_info": "Jsonb"
},
{
"ordinal": 18,
"ordinal": 17,
"name": "ducklake",
"type_info": "Jsonb"
},
{
"ordinal": 19,
"ordinal": 18,
"name": "git_sync",
"type_info": "Jsonb"
},
{
"ordinal": 20,
"ordinal": 19,
"name": "deploy_ui",
"type_info": "Jsonb"
},
{
"ordinal": 21,
"ordinal": 20,
"name": "default_app",
"type_info": "Varchar"
},
{
"ordinal": 22,
"ordinal": 21,
"name": "default_scripts",
"type_info": "Jsonb"
},
{
"ordinal": 23,
"ordinal": 22,
"name": "mute_critical_alerts",
"type_info": "Bool"
},
{
"ordinal": 24,
"ordinal": 23,
"name": "color",
"type_info": "Varchar"
},
{
"ordinal": 25,
"ordinal": 24,
"name": "operator_settings",
"type_info": "Jsonb"
},
{
"ordinal": 26,
"ordinal": 25,
"name": "git_app_installations",
"type_info": "Jsonb"
},
{
"ordinal": 27,
"ordinal": 26,
"name": "auto_invite",
"type_info": "Jsonb"
},
{
"ordinal": 28,
"ordinal": 27,
"name": "error_handler",
"type_info": "Jsonb"
},
{
"ordinal": 29,
"ordinal": 28,
"name": "success_handler",
"type_info": "Jsonb"
},
{
"ordinal": 30,
"ordinal": 29,
"name": "public_app_execution_limit_per_minute",
"type_info": "Int4"
},
{
"ordinal": 30,
"name": "error_handler_fallback_to_instance_alerts",
"type_info": "Bool"
}
],
"parameters": {
@@ -190,13 +190,13 @@
true,
true,
true,
true,
false,
true,
true,
true,
true
true,
false
]
},
"hash": "a479cd371fb5d1f52e7c727730cf48ab229e63b8dfe377975d48dcd223251e7c"
"hash": "88a6a86285fcfaa778e9716d5072f555dff175feb704c3d825d6100defe91bce"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE workspace_settings\n SET\n deploy_to = $1,\n ai_config = source_ws.ai_config,\n large_file_storage = source_ws.large_file_storage,\n ducklake = source_ws.ducklake,\n datatable = source_ws.datatable,\n git_app_installations = source_ws.git_app_installations\n FROM workspace_settings source_ws\n WHERE source_ws.workspace_id = $1\n AND workspace_settings.workspace_id = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "897a5b32e0f5890d68017c0c1225475568b02f4f4472dacf3d61e57b56335b69"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT ahead FROM workspace_diff\n WHERE source_workspace_id = 'test-workspace'\n AND fork_workspace_id = 'wm-fork-tally'\n AND kind = 'script'\n AND path = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "ahead",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "9e19f8203f97e1159928f9afe36d1c5190b6168ded757adf1791fbb9312b41a3"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT raw_app FROM app_version WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "raw_app",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "d20a717824d89e89969e94b024b11d425cb1dbdd132f3fba16c5fec72dd31490"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH RECURSIVE chain AS (\n SELECT id, parent_workspace_id, is_dev_workspace, 0 AS depth\n FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.parent_workspace_id, w.is_dev_workspace, chain.depth + 1\n FROM workspace w\n JOIN chain ON w.id = chain.parent_workspace_id\n WHERE chain.depth < 20\n )\n SELECT id AS \"id!\" FROM chain\n WHERE parent_workspace_id IS NULL\n OR (is_dev_workspace AND id NOT LIKE 'wm-fork-%')\n ORDER BY depth LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "e30be7c812aa294df6899892531083221146338193e38ef7268bacff58270bf8"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT deploy_to FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "deploy_to",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "f2f42aded6f1a400c84e2575abb551dfcd9260714eef333a85b1e5d7d5c9f67e"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET deploy_to = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "ff89e9f0941507d5cbfbb27e7c3b1ebad6b2b0a836334a5e20f151711fc11370"
}
+79 -79
View File
@@ -14489,7 +14489,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-nats",
@@ -14574,7 +14574,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"async-stream",
"async-trait",
@@ -14607,7 +14607,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14620,7 +14620,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"argon2",
@@ -14759,7 +14759,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14782,7 +14782,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14797,7 +14797,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14823,7 +14823,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -14833,7 +14833,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14850,7 +14850,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -14872,7 +14872,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14895,7 +14895,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14911,7 +14911,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14932,7 +14932,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14953,7 +14953,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14967,7 +14967,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-nats",
@@ -15002,7 +15002,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15027,7 +15027,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"axum 0.8.9",
"flate2",
@@ -15045,7 +15045,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15067,7 +15067,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15087,7 +15087,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15124,7 +15124,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15152,7 +15152,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"lazy_static",
"serde",
@@ -15164,7 +15164,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -15189,7 +15189,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15203,7 +15203,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15238,7 +15238,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"chrono",
"lazy_static",
@@ -15252,7 +15252,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15271,7 +15271,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -15375,7 +15375,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -15394,7 +15394,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"regex",
"serde",
@@ -15409,7 +15409,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -15433,7 +15433,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"futures",
@@ -15450,7 +15450,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15466,7 +15466,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-trait",
@@ -15487,7 +15487,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-trait",
@@ -15518,7 +15518,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"arc-swap",
@@ -15543,7 +15543,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-stream",
@@ -15577,7 +15577,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"futures",
@@ -15595,7 +15595,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15604,7 +15604,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -15616,7 +15616,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"serde_json",
@@ -15628,7 +15628,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"gosyn",
@@ -15640,7 +15640,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -15652,7 +15652,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"serde_json",
@@ -15664,7 +15664,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"nu-parser",
@@ -15675,7 +15675,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15686,7 +15686,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15698,7 +15698,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -15709,7 +15709,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-recursion",
@@ -15731,7 +15731,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"serde_json",
@@ -15743,7 +15743,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -15757,7 +15757,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -15774,7 +15774,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -15787,7 +15787,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"serde",
@@ -15799,7 +15799,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -15817,7 +15817,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -15833,7 +15833,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -15849,7 +15849,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"serde",
@@ -15860,7 +15860,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-recursion",
@@ -15899,7 +15899,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"const_format",
@@ -15939,7 +15939,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -15950,7 +15950,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-recursion",
@@ -15984,7 +15984,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16008,7 +16008,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16041,7 +16041,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-amqp"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16068,7 +16068,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16101,7 +16101,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16121,7 +16121,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16155,7 +16155,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16191,7 +16191,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16214,7 +16214,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16238,7 +16238,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-nats",
@@ -16262,7 +16262,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16297,7 +16297,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16325,7 +16325,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-trait",
@@ -16350,7 +16350,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"bitflags 2.13.1",
@@ -16369,7 +16369,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-once-cell",
@@ -16484,7 +16484,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"bytes",
"futures",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.775.1"
version = "1.775.2"
authors.workspace = true
edition.workspace = true
@@ -88,7 +88,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.775.1"
version = "1.775.2"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
02e02bb245cce368e9366e548f1407a45f2034d8
9dd772f32775ba8ec21b82897c66d86271495cfd
@@ -0,0 +1,175 @@
-- Restores the column and the `deploy_to`-based traversal.
--
-- Every fork and dev workspace had `deploy_to` seeded to its parent, so re-deriving it from
-- `parent_workspace_id` restores those rows exactly, and the links the up migration could not
-- convert come back from `workspace_deploy_to_unmigrated`. What this does not undo is the up
-- migration's conversions: a legacy pair that became a fork keeps its `parent_workspace_id`, so it
-- reads as a fork afterwards rather than as the standalone workspace it once was.
ALTER TABLE workspace_settings ADD COLUMN deploy_to VARCHAR(255);
UPDATE workspace_settings ws
SET deploy_to = w.parent_workspace_id
FROM workspace w
WHERE w.id = ws.workspace_id
AND w.parent_workspace_id IS NOT NULL;
-- The up migration keeps this table only when it preserved something, so restore from it
-- conditionally. plpgsql plans a branch's statements only when it runs, so the references below
-- are safe when the table was dropped. The probe is unqualified so it resolves through
-- `search_path`, which a `PG_SCHEMA` install points at its own schema rather than `public`.
DO $$
BEGIN
IF to_regclass('workspace_deploy_to_unmigrated') IS NOT NULL THEN
UPDATE workspace_settings ws
SET deploy_to = u.deploy_to
FROM workspace_deploy_to_unmigrated u
WHERE u.workspace_id = ws.workspace_id;
DROP TABLE workspace_deploy_to_unmigrated;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS workspace_settings_deploy_to_idx
ON workspace_settings (deploy_to)
WHERE deploy_to IS NOT NULL;
CREATE OR REPLACE FUNCTION list_ws_specific_versions(
seed_workspace TEXT,
user_email TEXT,
item_kind TEXT,
item_path TEXT
) RETURNS TABLE(ws VARCHAR) AS $$
DECLARE
rel RECORD;
usr_row RECORD;
user_perms TEXT[];
groups_csv TEXT;
pgroups_csv TEXT;
folders_read_csv TEXT;
folders_write_csv TEXT;
item_exists BOOLEAN;
is_super BOOLEAN;
BEGIN
IF item_kind NOT IN ('resource', 'variable') THEN
RAISE EXCEPTION 'Invalid kind: %', item_kind;
END IF;
SELECT COALESCE(super_admin, false) INTO is_super
FROM password WHERE email = user_email;
is_super := COALESCE(is_super, false);
BEGIN
FOR rel IN
WITH RECURSIVE related_workspaces(ws_id, depth) AS (
SELECT seed_workspace::VARCHAR, 0
UNION
SELECT CASE
WHEN ws.workspace_id = r.ws_id THEN ws.deploy_to
ELSE ws.workspace_id
END, r.depth + 1
FROM workspace_settings ws, related_workspaces r
WHERE r.depth < 32
AND ((ws.workspace_id = r.ws_id AND ws.deploy_to IS NOT NULL)
OR ws.deploy_to = r.ws_id)
)
SELECT DISTINCT r.ws_id
FROM related_workspaces r
INNER JOIN workspace w ON w.id = r.ws_id AND w.deleted = false
LOOP
SELECT u.username, u.is_admin
INTO usr_row
FROM usr u
WHERE u.email = user_email
AND u.workspace_id = rel.ws_id
AND u.disabled = false;
IF NOT FOUND AND NOT is_super THEN
CONTINUE;
END IF;
IF NOT FOUND THEN
-- super admin without a usr row in this workspace: synthesize an
-- admin identity so RLS is bypassed (windmill_admin role).
usr_row.username := user_email;
usr_row.is_admin := true;
groups_csv := '';
pgroups_csv := '';
folders_read_csv := '';
folders_write_csv := '';
ELSE
SELECT
COALESCE(string_agg(g, ','), ''),
COALESCE(string_agg('g/' || g, ','), '')
INTO groups_csv, pgroups_csv
FROM (
SELECT group_ AS g FROM usr_to_group
WHERE usr_to_group.usr = usr_row.username
AND usr_to_group.workspace_id = rel.ws_id
UNION ALL
SELECT igroup FROM email_to_igroup WHERE email = user_email
) gs;
user_perms := ARRAY['u/' || usr_row.username] || ARRAY(
SELECT 'g/' || g FROM (
SELECT group_ AS g FROM usr_to_group
WHERE usr = usr_row.username AND workspace_id = rel.ws_id
UNION ALL
SELECT igroup FROM email_to_igroup WHERE email = user_email
) gs2
);
-- folders_read: every folder the user can see (write implies read);
-- folders_write: only those granting write access.
WITH user_folders AS (
SELECT name, EXISTS (
SELECT 1 FROM jsonb_each_text(extra_perms) t
WHERE t.key = ANY(user_perms) AND t.value::boolean IS true
) AS is_write
FROM folder
WHERE extra_perms ?| user_perms AND folder.workspace_id = rel.ws_id
)
SELECT
COALESCE(string_agg(name, ','), ''),
COALESCE(string_agg(name, ',') FILTER (WHERE is_write), '')
INTO folders_read_csv, folders_write_csv
FROM user_folders;
IF is_super THEN
usr_row.is_admin := true;
END IF;
END IF;
PERFORM set_session_context(
usr_row.is_admin,
usr_row.username,
groups_csv,
pgroups_csv,
folders_read_csv,
folders_write_csv
);
EXECUTE format(
'SELECT EXISTS(SELECT 1 FROM %I WHERE workspace_id = $1 AND path = $2)',
item_kind
)
INTO item_exists
USING rel.ws_id, item_path;
IF item_exists THEN
ws := rel.ws_id;
RETURN NEXT;
END IF;
END LOOP;
EXCEPTION WHEN OTHERS THEN
-- Reset to a deny-default state before re-raising so a half-set
-- session context can't leak past the failed call.
PERFORM set_session_context(false, '', '', '', '', '');
RAISE;
END;
-- Reset to a deny-default state on the happy path too. SET LOCAL is
-- transaction-scoped so this also unwinds at transaction end, but
-- being explicit defends against the function being called inside a
-- longer outer transaction.
PERFORM set_session_context(false, '', '', '', '', '');
END;
$$ LANGUAGE plpgsql;
@@ -0,0 +1,345 @@
-- `workspace_settings.deploy_to` (2023) and `workspace.parent_workspace_id` (2025) both expressed
-- "which workspace does this one deploy into". Fork creation and dev-workspace attach seeded both,
-- but nothing kept them in agreement, so every reader had to pick one and they disagreed. Fold the
-- surviving `deploy_to` pairs into the lineage and drop the column.
--
-- A converted pair becomes a dev workspace when it can: that is what a long-lived staging paired
-- with prod actually is, and it keeps its own tag domain and promotion mode, so no worker
-- configuration changes underneath it. Only one dev workspace is allowed per parent
-- (`workspace_canonical_dev_idx`) and the app only ever attaches a dev to a root, so a pair becomes
-- a plain fork when it fails either test. Those keep their link but borrow the parent's job tags;
-- an admin can re-attach one of them as the dev workspace afterwards.
--
-- Chains (`dev -> staging -> prod`) convert too: `parent_workspace_id` represents them natively, up
-- to the depth-20 backstop that billing and count resolution walk.
-- Whatever the lineage genuinely cannot express is preserved here rather than destroyed with the
-- column. `deploy_to` was admin-settable to any workspace, so these rows are real configuration; an
-- operator needs to see what was dropped, and the down migration restores from this table.
CREATE TABLE workspace_deploy_to_unmigrated (
workspace_id VARCHAR(50) PRIMARY KEY REFERENCES workspace(id) ON DELETE CASCADE,
deploy_to VARCHAR(255) NOT NULL,
reason TEXT NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE workspace_deploy_to_unmigrated IS
'Legacy workspace_settings.deploy_to links that could not be expressed as fork lineage when the column was dropped. Written once by migration 20260730080304; never written by the application. Dropped again by that migration when every link converted, so it only exists where something was preserved.';
-- Tables created after the one-time GRANT ALL in 20250205131523 need explicit grants: ALTER
-- DEFAULT PRIVILEGES only covers objects created by the role that set them. Without these the
-- operator this table exists for cannot read it through the application's role.
GRANT ALL ON workspace_deploy_to_unmigrated TO windmill_user;
GRANT ALL ON workspace_deploy_to_unmigrated TO windmill_admin;
DO $$
DECLARE
leftover RECORD;
demoted RECORD;
converted_count INT;
dev_count INT;
BEGIN
-- Rows that could become lineage at all: a live source with no parent yet, pointing at a live
-- workspace other than itself.
CREATE TEMP TABLE eligible ON COMMIT DROP AS
SELECT ws.workspace_id, ws.deploy_to
FROM workspace_settings ws
JOIN workspace src ON src.id = ws.workspace_id
JOIN workspace tgt ON tgt.id = ws.deploy_to
WHERE ws.deploy_to IS NOT NULL
AND ws.deploy_to <> ws.workspace_id
AND NOT src.deleted AND NOT tgt.deleted
AND src.parent_workspace_id IS NULL
AND NOT EXISTS (
SELECT 1 FROM workspace d
WHERE d.parent_workspace_id = ws.workspace_id
AND d.is_dev_workspace AND NOT d.deleted
);
-- Cycle detection has to run over the lineage as it would exist AFTER conversion, not over the
-- `deploy_to` graph alone: a root whose target is one of its own existing forks would close a
-- loop that no `deploy_to` edge reveals. Every eligible source is parentless, so the combined
-- edge set still gives each node at most one parent.
CREATE TEMP TABLE edge ON COMMIT DROP AS
SELECT id AS child, parent_workspace_id AS parent
FROM workspace WHERE parent_workspace_id IS NOT NULL
UNION ALL
SELECT workspace_id, deploy_to FROM eligible;
CREATE TEMP TABLE chain_info ON COMMIT DROP AS
WITH RECURSIVE walk(start_id, cur_id, depth, path, cyclic) AS (
SELECT e.child, e.parent, 1, ARRAY[e.child, e.parent], e.parent = e.child
FROM edge e
UNION ALL
SELECT w.start_id, nxt.parent, w.depth + 1,
w.path || nxt.parent, nxt.parent = ANY(w.path)
FROM walk w
JOIN edge nxt ON nxt.child = w.cur_id
WHERE NOT w.cyclic AND w.depth < 25
)
SELECT start_id, bool_or(cyclic) AS cyclic, max(depth) AS chain_depth
FROM walk GROUP BY start_id;
-- Every remaining link gets a verdict. A fork whose `deploy_to` already names its parent is the
-- redundant seed every fork carried, so dropping it loses nothing and it is not reported.
CREATE TEMP TABLE classified ON COMMIT DROP AS
SELECT ws.workspace_id, ws.deploy_to,
CASE
WHEN src.parent_workspace_id = ws.deploy_to THEN 'redundant'
WHEN src.deleted THEN 'source workspace is archived'
WHEN ws.deploy_to = ws.workspace_id THEN 'self-reference'
WHEN tgt.id IS NULL THEN 'target workspace does not exist'
WHEN tgt.deleted THEN 'target workspace is archived'
WHEN src.parent_workspace_id IS NOT NULL
THEN 'already a fork, pointing somewhere other than its parent'
WHEN EXISTS (
SELECT 1 FROM workspace d
WHERE d.parent_workspace_id = ws.workspace_id
AND d.is_dev_workspace AND NOT d.deleted
) THEN 'source owns a dev workspace; linking it would nest that dev under a fork'
WHEN ci.cyclic THEN 'linking it would form a cycle in the workspace lineage'
WHEN ci.chain_depth > 20 THEN 'chain exceeds the lineage depth limit'
END AS reason
FROM workspace_settings ws
JOIN workspace src ON src.id = ws.workspace_id
LEFT JOIN workspace tgt ON tgt.id = ws.deploy_to
LEFT JOIN chain_info ci ON ci.start_id = ws.workspace_id
WHERE ws.deploy_to IS NOT NULL;
INSERT INTO workspace_deploy_to_unmigrated (workspace_id, deploy_to, reason)
SELECT workspace_id, deploy_to, reason
FROM classified WHERE reason IS NOT NULL AND reason <> 'redundant';
FOR leftover IN SELECT workspace_id, deploy_to, reason FROM workspace_deploy_to_unmigrated
LOOP
RAISE WARNING 'deploy_to unification: % -> % kept in workspace_deploy_to_unmigrated (%)',
leftover.workspace_id, leftover.deploy_to, leftover.reason;
END LOOP;
CREATE TEMP TABLE convertible ON COMMIT DROP AS
SELECT c.workspace_id, c.deploy_to,
-- Sole claimant on a free root that has no dev workspace yet, mirroring what
-- `attach_dev_workspace` would allow. Anything else stays a plain fork.
(COUNT(*) OVER (PARTITION BY c.deploy_to) = 1
AND tgt.parent_workspace_id IS NULL
AND NOT EXISTS (
SELECT 1 FROM workspace d
WHERE d.parent_workspace_id = c.deploy_to
AND d.is_dev_workspace AND NOT d.deleted
)
AND NOT EXISTS (
SELECT 1 FROM classified c2
WHERE c2.workspace_id = c.deploy_to AND c2.reason IS NULL
)) AS as_dev
FROM classified c
JOIN workspace tgt ON tgt.id = c.deploy_to
WHERE c.reason IS NULL;
FOR demoted IN SELECT workspace_id, deploy_to FROM convertible WHERE NOT as_dev
LOOP
RAISE NOTICE 'deploy_to unification: % -> % becomes a plain fork (target is not a free root); its jobs will use the parent''s tags',
demoted.workspace_id, demoted.deploy_to;
END LOOP;
UPDATE workspace w
SET parent_workspace_id = c.deploy_to,
is_dev_workspace = c.as_dev
FROM convertible c
WHERE w.id = c.workspace_id;
GET DIAGNOSTICS converted_count = ROW_COUNT;
-- Dispatch ignores this flag once a workspace has a parent, but the stored `true` outlives the
-- pairing: detaching later would silently re-enable instance alerting nobody asked for.
-- `attach_dev_workspace` clears it for the same reason.
UPDATE workspace_settings ws
SET error_handler_fallback_to_instance_alerts = false
FROM convertible c
WHERE c.workspace_id = ws.workspace_id
AND ws.error_handler_fallback_to_instance_alerts;
-- A converted workspace is now parent-managed, exactly as if `attach_dev_workspace` had run.
-- That path also strips git-sync state that would otherwise keep pulling and pushing against
-- the workspace's pre-conversion tracked branch: promotion repos are dropped, and auto-pull and
-- fork PRs are cleared on the rest. Any managed webhook is left registered but inert -- the
-- migration cannot call GitHub -- and is cleaned up on the next settings save.
UPDATE workspace_settings ws
SET git_sync = jsonb_set(
ws.git_sync,
'{repositories}',
COALESCE((
SELECT jsonb_agg(
(elem - 'auto_pull' - 'open_pr_error')
|| jsonb_build_object('fork_open_prs', false))
FROM jsonb_array_elements(ws.git_sync->'repositories') AS elem
WHERE COALESCE((elem->>'use_individual_branch')::boolean, false) = false
), '[]'::jsonb)
)
FROM convertible c
WHERE c.workspace_id = ws.workspace_id
AND jsonb_typeof(ws.git_sync->'repositories') = 'array';
SELECT count(*) INTO dev_count FROM convertible WHERE as_dev;
RAISE NOTICE 'deploy_to unification: linked % workspace(s) to their parent (% as dev workspaces), % preserved in workspace_deploy_to_unmigrated',
converted_count, dev_count, (SELECT count(*) FROM workspace_deploy_to_unmigrated);
-- Nothing to preserve is the normal outcome; leaving an empty table behind on every instance
-- forever buys nothing. It survives only where it holds something an operator needs to see.
IF NOT EXISTS (SELECT 1 FROM workspace_deploy_to_unmigrated) THEN
DROP TABLE workspace_deploy_to_unmigrated;
END IF;
END $$;
-- Resolve workspace-specific resources/variables over the fork lineage instead of the `deploy_to`
-- graph. Only the traversal changes; the per-workspace RLS fan-out below is unchanged.
--
-- Walks up through ancestors and down only to a dev workspace: descending into plain forks would
-- fan a root out over its whole live fork subtree, and each member costs an RLS switch and probe.
CREATE OR REPLACE FUNCTION list_ws_specific_versions(
seed_workspace TEXT,
user_email TEXT,
item_kind TEXT,
item_path TEXT
) RETURNS TABLE(ws VARCHAR) AS $$
DECLARE
rel RECORD;
usr_row RECORD;
user_perms TEXT[];
groups_csv TEXT;
pgroups_csv TEXT;
folders_read_csv TEXT;
folders_write_csv TEXT;
item_exists BOOLEAN;
is_super BOOLEAN;
BEGIN
IF item_kind NOT IN ('resource', 'variable') THEN
RAISE EXCEPTION 'Invalid kind: %', item_kind;
END IF;
SELECT COALESCE(super_admin, false) INTO is_super
FROM password WHERE email = user_email;
is_super := COALESCE(is_super, false);
BEGIN
FOR rel IN
WITH RECURSIVE related_workspaces(ws_id, depth, seen) AS (
SELECT seed_workspace::VARCHAR, 0, ARRAY[seed_workspace::VARCHAR]
UNION ALL
SELECT step.next_id, r.depth + 1, r.seen || step.next_id
FROM related_workspaces r
CROSS JOIN LATERAL (
SELECT CASE WHEN w.id = r.ws_id THEN w.parent_workspace_id ELSE w.id END
AS next_id
FROM workspace w
WHERE (w.id = r.ws_id AND w.parent_workspace_id IS NOT NULL)
OR (w.parent_workspace_id = r.ws_id
AND w.is_dev_workspace AND NOT w.deleted)
) step
-- The edges run both ways, so without this the walk bounces parent<->dev until it
-- hits the depth cap on every call regardless of how few workspaces are related.
WHERE r.depth < 32 AND NOT (step.next_id = ANY(r.seen))
)
SELECT DISTINCT r.ws_id
FROM related_workspaces r
INNER JOIN workspace w ON w.id = r.ws_id AND w.deleted = false
LOOP
SELECT u.username, u.is_admin
INTO usr_row
FROM usr u
WHERE u.email = user_email
AND u.workspace_id = rel.ws_id
AND u.disabled = false;
IF NOT FOUND AND NOT is_super THEN
CONTINUE;
END IF;
IF NOT FOUND THEN
-- super admin without a usr row in this workspace: synthesize an
-- admin identity so RLS is bypassed (windmill_admin role).
usr_row.username := user_email;
usr_row.is_admin := true;
groups_csv := '';
pgroups_csv := '';
folders_read_csv := '';
folders_write_csv := '';
ELSE
SELECT
COALESCE(string_agg(g, ','), ''),
COALESCE(string_agg('g/' || g, ','), '')
INTO groups_csv, pgroups_csv
FROM (
SELECT group_ AS g FROM usr_to_group
WHERE usr_to_group.usr = usr_row.username
AND usr_to_group.workspace_id = rel.ws_id
UNION ALL
SELECT igroup FROM email_to_igroup WHERE email = user_email
) gs;
user_perms := ARRAY['u/' || usr_row.username] || ARRAY(
SELECT 'g/' || g FROM (
SELECT group_ AS g FROM usr_to_group
WHERE usr = usr_row.username AND workspace_id = rel.ws_id
UNION ALL
SELECT igroup FROM email_to_igroup WHERE email = user_email
) gs2
);
-- folders_read: every folder the user can see (write implies read);
-- folders_write: only those granting write access.
WITH user_folders AS (
SELECT name, EXISTS (
SELECT 1 FROM jsonb_each_text(extra_perms) t
WHERE t.key = ANY(user_perms) AND t.value::boolean IS true
) AS is_write
FROM folder
WHERE extra_perms ?| user_perms AND folder.workspace_id = rel.ws_id
)
SELECT
COALESCE(string_agg(name, ','), ''),
COALESCE(string_agg(name, ',') FILTER (WHERE is_write), '')
INTO folders_read_csv, folders_write_csv
FROM user_folders;
IF is_super THEN
usr_row.is_admin := true;
END IF;
END IF;
PERFORM set_session_context(
usr_row.is_admin,
usr_row.username,
groups_csv,
pgroups_csv,
folders_read_csv,
folders_write_csv
);
EXECUTE format(
'SELECT EXISTS(SELECT 1 FROM %I WHERE workspace_id = $1 AND path = $2)',
item_kind
)
INTO item_exists
USING rel.ws_id, item_path;
IF item_exists THEN
ws := rel.ws_id;
RETURN NEXT;
END IF;
END LOOP;
EXCEPTION WHEN OTHERS THEN
-- Reset to a deny-default state before re-raising so a half-set
-- session context can't leak past the failed call.
PERFORM set_session_context(false, '', '', '', '', '');
RAISE;
END;
-- Reset to a deny-default state on the happy path too. SET LOCAL is
-- transaction-scoped so this also unwinds at transaction end, but
-- being explicit defends against the function being called inside a
-- longer outer transaction.
PERFORM set_session_context(false, '', '', '', '', '');
END;
$$ LANGUAGE plpgsql;
-- Dropping the column takes `workspace_settings_deploy_to_idx` with it; the new traversal is served
-- by `workspace_parent_idx`.
ALTER TABLE workspace_settings DROP COLUMN deploy_to;
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6272,7 +6272,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"proc-macro2",
"quote",
@@ -6284,7 +6284,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"convert_case",
"serde",
@@ -6293,7 +6293,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -6305,7 +6305,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"serde_json",
@@ -6317,7 +6317,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"gosyn",
@@ -6329,7 +6329,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -6341,7 +6341,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"serde_json",
@@ -6353,7 +6353,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"nu-parser",
@@ -6364,7 +6364,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6375,7 +6375,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6387,7 +6387,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6398,7 +6398,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"async-recursion",
@@ -6420,7 +6420,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"serde_json",
@@ -6432,7 +6432,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -6446,7 +6446,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"convert_case",
@@ -6463,7 +6463,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -6476,7 +6476,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"serde",
@@ -6488,7 +6488,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -6506,7 +6506,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6522,7 +6522,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6538,7 +6538,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6570,7 +6570,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"serde",
@@ -6581,7 +6581,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.775.1"
version = "1.775.2"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.775.1"
version = "1.775.2"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+4
View File
@@ -1711,6 +1711,10 @@ async fn process_notify_event(
);
windmill_common::variables::WORKSPACE_CRYPT_CACHE.remove(payload);
}
c if c == windmill_queue::tags::FORK_LINEAGE_CHANGE_CHANNEL => {
tracing::info!("Fork lineage change detected ({payload}), dropping tag workspace cache");
windmill_queue::tags::apply_fork_lineage_change(payload);
}
"notify_workspace_premium_change" => {
tracing::info!(
"Workspace premium change detected, invalidating workspace premium cache: {}",
+3 -1
View File
@@ -190,6 +190,8 @@ worker_ping: worker(char), worker_instance(char), ping_at(ts), started_at(ts), i
workspace: id(char), name(char), owner(char), deleted(bool), premium(bool), parent_workspace_id(char), is_dev_workspace(bool)
FK: (parent_workspace_id) -> workspace(id)
workspace_dependencies: id(bigint), name(char), content(text), language(script_lang), description(text), archived(bool), workspace_id(char), created_at(ts)
workspace_deploy_to_unmigrated: workspace_id(char), deploy_to(char), reason(text), recorded_at(timestamptz)
FK: (workspace_id) -> workspace(id)
workspace_diff: source_workspace_id(char), fork_workspace_id(char), path(char), kind(char), ahead(int), behind(int), has_changes(bool), exists_in_source(bool), exists_in_fork(bool)
workspace_env: workspace_id(char), name(char), value(char)
workspace_integrations: workspace_id(char), service_name(native_trigger_service), oauth_data(jsonb), created_at(ts), updated_at(ts), created_by(char)
@@ -202,7 +204,7 @@ workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_gr
FK: (workspace_id) -> workspace(id)
workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char)
FK: (app_path, workspace_id) -> app(path, workspace_id) | (flow_path, workspace_id) -> flow(path, workspace_id)
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), deploy_to(char), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int)
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int)
FK: (workspace_id) -> workspace(id)
zombie_job_counter: job_id(uuid), counter(int)
FK: (job_id) -> v2_job(id)
+68 -2
View File
@@ -6,6 +6,10 @@ pub mod openai;
pub mod openrouter;
pub mod other;
use std::time::{Duration, Instant};
use windmill_common::cache::Cache;
use crate::{
ai_providers::{AIPlatform, AIProvider},
credentials::ProviderCredentials,
@@ -29,7 +33,13 @@ pub fn create_query_builder(
) -> Box<dyn QueryBuilder> {
match credentials.provider {
AIProvider::GoogleAI => Box::new(GoogleAIQueryBuilder::new(credentials.platform.clone())),
AIProvider::OpenAI => Box::new(OpenAIQueryBuilder::new(credentials.provider.clone())),
// Azure OpenAI serves the same Responses API as OpenAI under `/openai/v1`, and
// newer deployments reject a reasoning effort combined with function tools on
// `/chat/completions`. This cannot be decided per model: on Azure the model name
// is a user-chosen deployment name, which says nothing about the model behind it.
AIProvider::OpenAI | AIProvider::AzureOpenAI => {
Box::new(OpenAIQueryBuilder::new(credentials.provider.clone()))
}
AIProvider::Anthropic => Box::new(AnthropicQueryBuilder::new(
credentials.provider.clone(),
credentials.platform.clone(),
@@ -42,6 +52,62 @@ pub fn create_query_builder(
}
}
/// The builder for the same resource on the OpenAI-compatible `/chat/completions`
/// surface, for a provider whose preferred surface it turned out not to serve
/// (`QueryBuilder::supports_chat_completions_fallback`).
pub fn create_chat_completions_query_builder(
credentials: &ProviderCredentials,
) -> Box<dyn QueryBuilder> {
Box::new(OtherQueryBuilder::new(credentials.provider.clone()))
}
lazy_static::lazy_static! {
/// Deployments that turned out not to serve the endpoint their provider prefers,
/// so the steps after the one that found out start on `/chat/completions` rather
/// than paying a rejected call each to learn the same thing. Keyed by endpoint and
/// deployment name, because Azure's Responses API support varies by both.
///
/// Only rejections are remembered: a surface that answers costs nothing to keep
/// using. The entry expires because a deployment can gain Responses support
/// (Azure rolls it out per model and region) without anything here changing.
static ref CHAT_COMPLETIONS_ONLY: Cache<(String, String), Instant> = Cache::new(500);
}
const CHAT_COMPLETIONS_ONLY_TTL: Duration = Duration::from_secs(3600);
/// Whether this deployment is known not to serve the endpoint its provider prefers.
pub fn is_chat_completions_only(base_url: &str, model: &str) -> bool {
CHAT_COMPLETIONS_ONLY
.get(&(base_url.to_string(), model.to_string()))
.is_some_and(|learned_at| learned_at.elapsed() < CHAT_COMPLETIONS_ONLY_TTL)
}
/// Record that this deployment rejected the endpoint its provider prefers.
pub fn remember_chat_completions_only(base_url: &str, model: &str) {
CHAT_COMPLETIONS_ONLY.insert((base_url.to_string(), model.to_string()), Instant::now());
}
#[cfg(test)]
mod chat_completions_only_tests {
use super::*;
/// Azure's Responses API support varies by deployment, so what one deployment of a
/// resource rejected says nothing about the next one.
#[test]
fn a_rejection_is_remembered_per_deployment() {
let base_url = "https://rejection-per-deployment.openai.azure.com/openai";
remember_chat_completions_only(base_url, "legacy-deployment");
assert!(is_chat_completions_only(base_url, "legacy-deployment"));
assert!(!is_chat_completions_only(base_url, "gpt-5-deployment"));
assert!(!is_chat_completions_only(
"https://other.openai.azure.com/openai",
"legacy-deployment"
));
}
}
/// The proxy (workspace/instance AI settings) and the query builder (AI agent step)
/// each derive the endpoint and the credential header for the same resource. They
/// must agree, or a resource authenticates in one and 401s in the other.
@@ -139,7 +205,7 @@ mod parity_tests {
AIProvider::AzureOpenAI,
"https://example.openai.azure.com/openai",
"gpt-4o",
"chat/completions",
"responses",
),
case(
AIProvider::OpenAI,
@@ -508,6 +508,10 @@ impl QueryBuilder for OpenAIQueryBuilder {
build_openai_compatible_proxy_request(args)
}
fn supports_chat_completions_fallback(&self, base_url: &str) -> bool {
self.provider_kind.is_azure(base_url)
}
async fn parse_streaming_response(
&self,
response: reqwest::Response,
+8
View File
@@ -77,6 +77,14 @@ pub trait QueryBuilder: Send + Sync {
false
}
/// Whether a rejected request may be retried on the `/chat/completions` surface of
/// the same resource. Azure serves the Responses API for only a subset of models and
/// regions, and an Azure model name is a user-chosen deployment name, so such a
/// resource can only find out by being asked.
fn supports_chat_completions_fallback(&self, _base_url: &str) -> bool {
false
}
/// Build a provider-specific request from an OpenAI-compatible proxy request.
fn build_proxy_request(&self, args: &ProxyBuildArgs<'_>) -> Result<ProxyRequest, Error> {
Err(Error::BadRequest(format!(
@@ -1206,6 +1206,118 @@ async fn test_compare_workspaces_rename_visibility_ee_e2e(
Ok(())
}
/// Regression test for #10401. The fork -> parent ("ahead") side of the tally
/// used to key off `workspace_settings.deploy_to` while the parent -> fork
/// ("behind") side keyed off `workspace.parent_workspace_id`, so a fork whose
/// `deploy_to` disagreed recorded no ahead change at all and its edits stayed
/// permanently absent from "Deploy to <parent>". Both sides now read the
/// lineage, which is the only key left.
///
/// Gated on `private` for the same reason as the rename test above: the OSS
/// `handle_deployment_metadata` is a no-op, so no rows would ever be written.
#[cfg(feature = "private")]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_fork_tally_ahead_against_parent(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base_url = format!("http://localhost:{port}/api");
let admin = windmill_api_client::create_client(
&format!("http://localhost:{port}"),
"SECRET_TOKEN".to_string(),
);
sqlx::query!("DELETE FROM skip_workspace_diff_tally")
.execute(&db)
.await?;
let resp = admin
.client()
.post(&format!(
"{base_url}/w/test-workspace/workspaces/create_fork"
))
.json(&json!({
"id": "wm-fork-tally",
"name": "Tally Fork",
}))
.send()
.await?;
assert!(
resp.status().is_success(),
"fork creation failed: {}",
resp.status()
);
// bash needs no lock generation, so `create_script` tallies inline instead of
// deferring to a dependency job (no worker runs in this test).
let deploy = async |path: &str| -> anyhow::Result<()> {
let resp = admin
.client()
.post(&format!("{base_url}/w/wm-fork-tally/scripts/create"))
.json(&json!({
"path": path,
"summary": "",
"description": "",
"content": "echo 1",
"language": "bash",
"schema": {"type": "object", "properties": {}, "required": []},
}))
.send()
.await?;
let status = resp.status();
assert!(
status.is_success(),
"script create failed: {} — {}",
status,
resp.text().await?
);
Ok(())
};
// The tally runs in a `tokio::spawn` inside `handle_deployment_metadata`.
// Stopping at the first non-NULL read would let a regression that tallies the
// same deploy against two upstream workspaces slip through: it shows `ahead = 1`
// between the upserts. So keep sampling after the row appears and return the
// settled value.
let ahead_for = async |path: &str| -> anyhow::Result<Option<i32>> {
let read = async || -> anyhow::Result<Option<i32>> {
Ok(sqlx::query_scalar!(
"SELECT ahead FROM workspace_diff
WHERE source_workspace_id = 'test-workspace'
AND fork_workspace_id = 'wm-fork-tally'
AND kind = 'script'
AND path = $1",
path
)
.fetch_optional(&db)
.await?)
};
let mut ahead = None;
for _ in 0..40 {
ahead = read().await?;
if ahead.is_some() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
for _ in 0..10 {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
ahead = read().await?;
}
Ok(ahead)
};
deploy("u/admin/tallied").await?;
assert_eq!(
ahead_for("u/admin/tallied").await?,
Some(1),
"a fork's change must record once against its parent"
);
Ok(())
}
/// Regression test for WIN-1975. A non-admin user creating a script in a fork-
/// only folder used to get the spurious
/// "this fork has changes not visible to your user" warning because
@@ -1235,8 +1347,7 @@ async fn test_compare_workspaces_fork_only_folder_visibility(
.execute(&db)
.await?;
// Create fork via the API so cloning + workspace_settings.deploy_to wiring
// matches what production sees.
// Create fork via the API so the cloning and lineage wiring matches what production sees.
let client_admin = windmill_api_client::create_client(
&format!("http://localhost:{port}"),
"SECRET_TOKEN".to_string(),
@@ -408,18 +408,6 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
resp.text().await?
);
// --- edit_deploy_to (EE-gated) ---
let resp = authed(client().post(format!("{base}/edit_deploy_to")))
.json(&json!({"deploy_to": null}))
.send()
.await
.unwrap();
assert!(
resp.status() == 200 || resp.status() == 400,
"edit_deploy_to: unexpected status {}",
resp.status()
);
// --- edit_large_file_storage_config ---
let resp = authed(client().post(format!("{base}/edit_large_file_storage_config")))
.json(&json!({"large_file_storage": null}))
@@ -112,7 +112,6 @@ pub fn workspaced_service() -> Router {
.route("/edit_webhook", post(edit_webhook))
.route("/edit_auto_invite", post(edit_auto_invite))
.route("/edit_instance_groups", post(edit_instance_groups))
.route("/edit_deploy_to", post(edit_deploy_to))
.route(
"/get_secondary_storage_names",
get(get_secondary_storage_names),
@@ -273,8 +272,6 @@ pub struct WorkspaceSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub webhook: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deploy_to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_config: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub large_file_storage: Option<serde_json::Value>,
@@ -392,12 +389,6 @@ struct RunSlackMessageTestJobResponse {
job_uuid: String,
}
#[cfg(feature = "enterprise")]
#[derive(Deserialize)]
struct EditDeployTo {
deploy_to: Option<String>,
}
#[allow(dead_code)]
#[derive(Deserialize)]
pub struct EditAutoInvite {
@@ -969,7 +960,6 @@ async fn get_settings(
customer_id,
plan,
webhook,
deploy_to,
ai_config,
large_file_storage,
datatable,
@@ -1393,23 +1383,26 @@ fn extract_instance_ai_model_summary(
struct DeployTo {
deploy_to: Option<String>,
}
/// The workspace this one deploys into: its fork parent, or nothing when it is a root. The response
/// field keeps the `deploy_to` name that predates the fork lineage, so existing clients (the deploy
/// drawer, the resource/variable editors) read it unchanged.
async fn get_deploy_to(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<DeployTo> {
let mut tx = user_db.begin(&authed).await?;
let settings = sqlx::query_as!(
DeployTo,
"SELECT deploy_to FROM workspace_settings WHERE workspace_id = $1",
let deploy_to = sqlx::query_scalar!(
"SELECT parent_workspace_id FROM workspace WHERE id = $1",
&w_id
)
.fetch_one(&mut *tx)
.fetch_optional(&mut *tx)
.await
.map_err(|e| Error::internal_err(format!("getting deploy_to: {e:#}")))?;
.map_err(|e| Error::internal_err(format!("getting deploy target: {e:#}")))?
.flatten();
tx.commit().await?;
Ok(Json(settings))
Ok(Json(DeployTo { deploy_to }))
}
async fn edit_slack_command(
@@ -1698,65 +1691,6 @@ async fn get_secondary_storage_names(
Ok(Json(result))
}
#[cfg(feature = "enterprise")]
async fn edit_deploy_to(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
ApiAuthed { is_admin, username, .. }: ApiAuthed,
Json(es): Json<EditDeployTo>,
) -> Result<String> {
require_admin(is_admin, &username)?;
let mut tx = db.begin().await?;
sqlx::query!(
"UPDATE workspace_settings SET deploy_to = $1 WHERE workspace_id = $2",
es.deploy_to,
&w_id
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"workspaces.edit_deploy_to",
ActionKind::Update,
&w_id,
Some(&authed.email),
Some(
[(
"script",
es.deploy_to.unwrap_or("NO_DEPLOY_TO".to_string()).as_str(),
)]
.into(),
),
)
.await?;
tx.commit().await?;
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Settings { setting_type: "deploy_to".to_string() },
None,
false,
None,
)
.await?;
Ok(format!("Edit deploy to for {}", &w_id))
}
#[cfg(not(feature = "enterprise"))]
async fn edit_deploy_to() -> Result<String> {
return Err(Error::BadRequest(
"Deploy to is only available on enterprise".to_string(),
));
}
pub const BANNED_DOMAINS: &str = include_str!("../../windmill-api/banned_domains.txt");
pub const MAX_CUSTOM_PROMPT_LENGTH: usize = 5000;
@@ -5632,7 +5566,6 @@ async fn update_workspace_settings(
r#"
UPDATE workspace_settings
SET
deploy_to = $1,
ai_config = source_ws.ai_config,
large_file_storage = source_ws.large_file_storage,
ducklake = source_ws.ducklake,
@@ -7103,7 +7036,7 @@ struct DetachDevWorkspace {
}
/// Pair an existing standalone workspace to this workspace ("prod") as its dev workspace, without
/// cloning any data (both already exist). Sets the dev's parent + deploy_to to prod and, optionally,
/// cloning any data (both already exist). Sets the dev's parent to prod and, optionally,
/// locks prod against direct deployment.
async fn attach_dev_workspace(
authed: ApiAuthed,
@@ -7236,8 +7169,7 @@ async fn attach_dev_workspace(
// `true` would survive a later detach and would make the settings page submit a value the API
// rejects on a fork.
sqlx::query!(
"UPDATE workspace_settings SET deploy_to = $1, error_handler_fallback_to_instance_alerts = false WHERE workspace_id = $2",
&prod_w_id,
"UPDATE workspace_settings SET error_handler_fallback_to_instance_alerts = false WHERE workspace_id = $1",
&dev_w_id
)
.execute(&mut *tx)
@@ -7304,9 +7236,19 @@ async fn attach_dev_workspace(
.await?;
tx.commit().await?;
// The dev workspace's parent just changed (none -> prod); drop its cached fork->parent mapping
// so per-workspace job tags route to the prod family immediately rather than after the TTL.
// The dev workspace's lineage just changed (none -> prod); drop its cached tag workspace so
// per-workspace job tags route to the prod family immediately rather than after the TTL. Tag
// resolution walks ancestors, so its own forks resolve through it and must be dropped too.
windmill_queue::tags::invalidate_fork_parent_cache(&dev_w_id);
for id in windmill_common::workspaces::list_fork_descendants(&db, &dev_w_id)
.await
.unwrap_or_default()
{
windmill_queue::tags::invalidate_fork_parent_cache(&id);
}
if let Err(e) = windmill_queue::tags::notify_fork_lineage_reset(&db).await {
tracing::warn!("failed to broadcast fork lineage change: {e:#}");
}
// Best-effort: the hooks captured before the strip above are unreachable now
// (their auto_pull is gone), so remove them from GitHub.
#[cfg(all(feature = "enterprise", feature = "private"))]
@@ -7329,7 +7271,7 @@ async fn attach_dev_workspace(
// Same reparent invalidates the billing-workspace mapping so its usage meters to prod at once. The
// candidate can bring its own fork subtree, whose descendants had resolved their (now-stale) root
// to the candidate's old family; invalidate them too so they meter to prod without waiting out the
// 60s TTL. Their immediate fork->parent links don't move, so the tag-routing cache needs no change.
// 60s TTL. The tag cache is swept above: resolution walks the whole chain, not the immediate link.
#[cfg(feature = "cloud")]
{
windmill_common::workspaces::invalidate_billing_workspace_cache(&dev_w_id);
@@ -7419,6 +7361,12 @@ async fn detach_dev_workspace(
windmill_common::workspaces::invalidate_fork_ancestor_chain_cache(&dev_w_id);
for id in windmill_common::workspaces::list_fork_descendants(&db, &dev_w_id).await? {
windmill_common::workspaces::invalidate_fork_ancestor_chain_cache(&id);
// Tag resolution walks ancestors, so a descendant's cached tag workspace resolved through
// the workspace whose dev flag just changed.
windmill_queue::tags::invalidate_fork_parent_cache(&id);
}
if let Err(e) = windmill_queue::tags::notify_fork_lineage_reset(&db).await {
tracing::warn!("failed to broadcast fork lineage change: {e:#}");
}
#[cfg(feature = "cloud")]
{
@@ -7659,6 +7607,19 @@ async fn archive_workspace(
if let Some(prod) = dev_lock_parent {
windmill_common::workspaces::invalidate_protection_rules_cache(&prod);
// The teardown above cleared `is_dev_workspace`, which is what let this workspace keep its
// own id for tag routing; it and every fork resolving through it now land on an ancestor.
// Only a dev workspace reaches here, so archiving anything else needs no sweep.
windmill_queue::tags::invalidate_fork_parent_cache(&w_id);
for id in windmill_common::workspaces::list_fork_descendants(&db, &w_id)
.await
.unwrap_or_default()
{
windmill_queue::tags::invalidate_fork_parent_cache(&id);
}
if let Err(e) = windmill_queue::tags::notify_fork_lineage_reset(&db).await {
tracing::warn!("failed to broadcast fork lineage change: {e:#}");
}
}
Ok(format!(
@@ -108,7 +108,7 @@ pub(crate) async fn change_workspace_id(
// Duplicate workspace settings (keep copy in old workspace for reference)
info!("Duplicating workspace_settings table");
sqlx::query!(
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, deploy_to, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, deploy_to, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts FROM workspace_settings WHERE workspace_id = $2",
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts FROM workspace_settings WHERE workspace_id = $2",
&rw.new_id,
&old_id
)
@@ -462,16 +462,6 @@ pub(crate) async fn change_workspace_id(
.fetch_all(&mut *tx)
.await?;
// A dev/fork's `deploy_to` points at the prod root, so it must follow the rename too — otherwise
// the child re-parents to the new id but still deploys to the soft-deleted old shell.
sqlx::query!(
"UPDATE workspace_settings SET deploy_to = $1 WHERE deploy_to = $2",
&rw.new_id,
&old_id
)
.execute(&mut *tx)
.await?;
info!("Updating workspace_protection_rule table");
sqlx::query!(
"UPDATE workspace_protection_rule SET workspace_id = $1 WHERE workspace_id = $2",
@@ -814,21 +804,31 @@ pub(crate) async fn change_workspace_id(
}
}
// A rename changes which workspace each id denotes. Workspace ids are reclaimable, so the new
// id may still carry a previous occupant's cached resolution, and the old id now names an
// archived row. Drop both rather than reason about which cached answers are still true.
windmill_queue::tags::invalidate_fork_parent_cache(&rw.new_id);
windmill_queue::tags::invalidate_fork_parent_cache(&old_id);
if let Err(e) = windmill_queue::tags::notify_fork_lineage_reset(&db).await {
tracing::warn!("failed to broadcast fork lineage change: {e:#}");
}
// The children's parent_workspace_id changed (old root -> new root); invalidate their fork-parent
// routing cache and their billing-workspace mapping so jobs route + meter under the renamed root
// rather than the old (archived) one, instead of waiting for the caches' TTLs. Deeper descendants
// (fork-of-fork) self-heal via the 60s billing-cache TTL.
// rather than the old (archived) one, instead of waiting for the caches' TTLs.
for child in &reparented_children {
windmill_queue::tags::invalidate_fork_parent_cache(child);
windmill_common::workspaces::invalidate_fork_ancestor_chain_cache(child);
// Grandchildren's cached chains contain the old (renamed-away) ancestor id; a stale
// chain drops all defer ancestors in the ducklake resolver, so sweep the subtree
// rather than letting it wait out the TTL.
// chain drops all defer ancestors in the ducklake resolver, and tag resolution walks to
// the nearest servable ancestor, so a nested fork would be tagged for the old root and
// nothing would serve it. Sweep the subtree rather than letting it wait out the TTL.
for id in windmill_common::workspaces::list_fork_descendants(&db, child)
.await
.unwrap_or_default()
{
windmill_common::workspaces::invalidate_fork_ancestor_chain_cache(&id);
windmill_queue::tags::invalidate_fork_parent_cache(&id);
}
#[cfg(feature = "cloud")]
windmill_common::workspaces::invalidate_billing_workspace_cache(child);
@@ -1193,19 +1193,33 @@ pub(crate) async fn delete_workspace(
windmill_common::workspaces::invalidate_team_plan_cache(id);
}
}
// Deeper descendants' cached ancestor CHAINS still contain the deleted workspace; unlike
// the sibling caches (which self-heal harmlessly via TTL), a stale chain makes the
// ducklake resolver drop all defer ancestors (all-or-nothing on broken links) — a visible
// defer/chips outage for up to the TTL. Anchor at the orphaned children: the deleted row
// is gone, but their subtrees are intact.
// Deeper descendants' cached ancestor CHAINS still contain the deleted workspace: a stale
// chain makes the ducklake resolver drop all defer ancestors (all-or-nothing on broken
// links), and tag resolution walks to the nearest servable ancestor, so a nested fork would
// keep a tag naming the deleted workspace that nothing serves. Anchor at the orphaned
// children: the deleted row is gone, but their subtrees are intact.
for child in orphaned_children.iter() {
windmill_queue::tags::invalidate_fork_parent_cache(child);
for id in windmill_common::workspaces::list_fork_descendants(&db, child)
.await
.unwrap_or_default()
{
windmill_common::workspaces::invalidate_fork_ancestor_chain_cache(&id);
windmill_queue::tags::invalidate_fork_parent_cache(&id);
}
}
// The id is reclaimable, so its cached mapping must not outlive it anywhere: it can be claimed
// again under a different parent well inside the TTL. That is a one-entry drop, cheap enough for
// the ephemeral fork churn this endpoint sees. Orphaning descendants reshapes the tree instead,
// which no single id identifies.
let broadcast = if orphaned_children.is_empty() {
windmill_queue::tags::notify_fork_lineage_change(&db, &w_id).await
} else {
windmill_queue::tags::notify_fork_lineage_reset(&db).await
};
if let Err(e) = broadcast {
tracing::warn!("failed to broadcast fork lineage change: {e:#}");
}
Ok(format!("Deleted workspace {}", &w_id))
}
+1 -45
View File
@@ -5273,9 +5273,6 @@
"webhook": {
"type": "string"
},
"deploy_to": {
"type": "string"
},
"ai_config": {
"$ref": "#/components/schemas/AIConfig"
},
@@ -6209,47 +6206,6 @@
}
}
},
"/w/{workspace}/workspaces/edit_deploy_to": {
"post": {
"summary": "edit deploy to",
"operationId": "editDeployTo",
"tags": [
"workspace"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"deploy_to": {
"type": "string"
}
}
}
}
}
},
"responses": {
"200": {
"description": "status",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/w/{workspace}/workspaces/edit_auto_invite": {
"post": {
"summary": "edit auto invite",
@@ -48496,4 +48452,4 @@
}
}
}
}
}
-29
View File
@@ -5002,8 +5002,6 @@ paths:
type: string
webhook:
type: string
deploy_to:
type: string
ai_config:
type: object
properties: &ref_50
@@ -5858,33 +5856,6 @@ paths:
properties:
job_uuid:
type: string
/w/{workspace}/workspaces/edit_deploy_to:
post:
summary: edit deploy to
operationId: editDeployTo
tags:
- workspace
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
deploy_to:
type: string
responses:
'200':
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/edit_auto_invite:
post:
summary: edit auto invite
+2 -30
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.775.1
version: 1.775.2
title: Windmill API
contact:
@@ -3618,8 +3618,6 @@ paths:
type: string
webhook:
type: string
deploy_to:
type: string
ai_config:
$ref: "#/components/schemas/AIConfig"
error_handler:
@@ -3655,7 +3653,7 @@ paths:
/w/{workspace}/workspaces/get_deploy_to:
get:
summary: get deploy to
summary: get the workspace this one deploys into (its fork parent)
operationId: getDeployTo
tags:
- workspace
@@ -4214,32 +4212,6 @@ paths:
job_uuid:
type: string
/w/{workspace}/workspaces/edit_deploy_to:
post:
summary: edit deploy to
operationId: editDeployTo
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
deploy_to:
type: string
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/edit_auto_invite:
post:
summary: edit auto invite
+25 -2
View File
@@ -9257,6 +9257,7 @@ pub fn start_job_update_sse_stream(
// Send initial update immediately
let mut running = running;
let mut mem_peak = 0;
let mut get_progress_m: bool = false;
match get_job_update_data(
&opt_authed,
@@ -9266,7 +9267,7 @@ pub fn start_job_update_sse_stream(
&job_id,
log_offset,
stream_offset,
false,
get_progress.unwrap_or(false),
running,
true,
true,
@@ -9282,6 +9283,9 @@ pub fn start_job_update_sse_stream(
.await
{
Ok(mut update) => {
if update.progress.is_some() {
get_progress_m = true;
}
last_update_hash = Some(update.hash_str());
let completion_sent = update.completed.unwrap_or(false);
if running.is_some() && update.running.is_some_and(|x| x) {
@@ -9327,7 +9331,6 @@ pub fn start_job_update_sse_stream(
}
}
let mut get_progress_m: bool = false;
// Poll for updates every 1 second
let mut i = 0;
let start = Instant::now();
@@ -9420,6 +9423,26 @@ pub fn start_job_update_sse_stream(
}
}
// A job that finishes in between two throttled progress checks would
// otherwise report no progress at all, leaving the caller unable to tell
// at which progress it ended.
if get_progress.unwrap_or(false)
&& update.progress.is_none()
&& update.completed.unwrap_or(false)
{
match sqlx::query_scalar!(
"SELECT (scalar_int)::int FROM job_stats WHERE job_id = $1 AND workspace_id = $2 AND metric_id = 'progress_perc'",
job_id, &w_id)
.fetch_optional(&db)
.await
{
Ok(progress) => update.progress = progress.flatten(),
Err(e) => tracing::warn!(
"Failed to get final progress for job {job_id}: {e:#}"
),
}
}
// if !only_result.unwrap_or(false) {
// tracing::error!("update {:?}", update);
// }
@@ -449,8 +449,6 @@ struct SimplifiedSettings {
auto_invite: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
webhook: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
deploy_to: Option<String>,
// Always serialize (including as `null`) so that `wmill sync pull` emits
// these fields in settings.yaml unconditionally. Makes round-trip
// bijective: YAML is the source of truth, absence/null = "clear remote",
@@ -496,8 +494,6 @@ struct SimplifiedSettingsLegacy {
#[serde(skip_serializing_if = "Option::is_none")]
webhook: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
deploy_to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error_handler: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error_handler_extra_args: Option<Value>,
@@ -534,7 +530,6 @@ struct SimplifiedSettingsLegacy {
struct SettingsRow {
auto_invite: Option<Value>,
webhook: Option<String>,
deploy_to: Option<String>,
error_handler: Option<Value>,
success_handler: Option<Value>,
ai_config: Option<serde_json::Value>,
@@ -1428,7 +1423,6 @@ pub(crate) async fn tarball_workspace(
r#"SELECT
auto_invite,
webhook,
deploy_to,
error_handler,
success_handler,
ai_config,
@@ -1488,7 +1482,6 @@ pub(crate) async fn tarball_workspace(
let settings = SimplifiedSettings {
auto_invite: row.auto_invite,
webhook: row.webhook,
deploy_to: row.deploy_to,
error_handler: row.error_handler,
success_handler: row.success_handler,
ai_config: row.ai_config,
@@ -1558,7 +1551,6 @@ pub(crate) async fn tarball_workspace(
auto_invite_as,
auto_invite_mode,
webhook: row.webhook,
deploy_to: row.deploy_to,
error_handler,
error_handler_extra_args,
error_handler_muted_on_cancel,
@@ -0,0 +1,66 @@
//! Executes `list_ws_specific_versions`, the workspace-specific resource/variable resolver.
//!
//! Nothing in the repo calls this function, and plpgsql defers everything past a raw parse to the
//! first call, so without a test that invokes it a broken body ships looking healthy.
//!
//! Run with:
//! cargo test -p windmill-common --test ws_specific_versions
use sqlx::{Pool, Postgres};
async fn insert_ws(db: &Pool<Postgres>, id: &str) {
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ($1, $1, 'test-user')")
.bind(id)
.execute(db)
.await
.expect("insert workspace");
sqlx::query(
"INSERT INTO resource (workspace_id, path, value, resource_type) \
VALUES ($1, 'u/admin/shared', '{}'::jsonb, 'postgresql')",
)
.bind(id)
.execute(db)
.await
.expect("seed resource");
}
/// The traversal a prod/dev pair needs: each finds the other, while a plain fork under the same
/// prod stays out of the prod's result even though it holds the same path. Descending into plain
/// forks would fan a root out over its whole live fork subtree.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn pairs_prod_and_dev_without_plain_forks(db: Pool<Postgres>) {
for id in ["lwp", "lwd", "lwf"] {
insert_ws(&db, id).await;
}
sqlx::query(
"UPDATE workspace SET parent_workspace_id = 'lwp', is_dev_workspace = true WHERE id = 'lwd'",
)
.execute(&db)
.await
.expect("attach dev");
sqlx::query("UPDATE workspace SET parent_workspace_id = 'lwp' WHERE id = 'lwf'")
.execute(&db)
.await
.expect("attach fork");
// The base fixture's caller is a superadmin, so the function synthesises an admin identity and
// the RLS probe runs unrestricted without per-workspace `usr` rows.
let versions = |seed: &'static str| {
let db = db.clone();
async move {
let mut rows: Vec<String> = sqlx::query_scalar(
"SELECT ws FROM list_ws_specific_versions($1, 'test@windmill.dev', 'resource', 'u/admin/shared')",
)
.bind(seed)
.fetch_all(&db)
.await
.expect("call list_ws_specific_versions");
rows.sort();
rows
}
};
assert_eq!(versions("lwp").await, vec!["lwd", "lwp"]);
assert_eq!(versions("lwd").await, vec!["lwd", "lwp"]);
assert_eq!(versions("lwf").await, vec!["lwd", "lwf", "lwp"]);
}
@@ -23,6 +23,17 @@ pub async fn handle_deployment_metadata<'c>(
return Ok(());
}
#[cfg(not(feature = "private"))]
pub async fn tally_deployed_object_changes(
_w_id: &str,
_obj: &DeployedObject,
_db: &DB,
_renamed_from: Option<&str>,
) -> Result<()> {
// Workspace forks are an enterprise feature and not part of the open-source version
return Ok(());
}
#[cfg(not(feature = "private"))]
pub async fn handle_fork_branch_creation<'c>(
_email: &str,
+2
View File
@@ -17,11 +17,13 @@ pub use git_sync_ee::{
enqueue_git_pull_dry_run, enqueue_git_pull_job, handle_deployment_metadata,
handle_deployment_metadata_batch, handle_fork_branch_creation, persist_auto_pull_state,
reconcile_and_enqueue_pull, reconcile_fork_branch_pull, record_auto_pull_failure,
tally_deployed_object_changes,
};
#[cfg(not(feature = "private"))]
pub use git_sync_oss::{
handle_deployment_metadata, handle_deployment_metadata_batch, handle_fork_branch_creation,
tally_deployed_object_changes,
};
#[derive(Clone, Debug)]
+11 -1
View File
@@ -6420,7 +6420,17 @@ async fn push_inner<'c, 'd>(
tag = None;
}
let interpolated_tag = tag.map(|x| interpolate_args(x, &args, workspace_id));
// `$workspace` must resolve the same way the default tags below do, or an explicit tag and
// a default tag from the same workspace address two different worker pools. Resolving costs
// a lookup, so pay it only for tags that actually interpolate `$workspace`.
let interpolated_tag = match tag {
None => None,
Some(x) if x.contains("$workspace") => {
let tag_ws = crate::tags::tag_workspace_id(&workspace_id, db).await;
Some(interpolate_args(x, &args, &tag_ws))
}
Some(x) => Some(interpolate_args(x, &args, workspace_id)),
};
let effective_ws = per_workspace_tag(&workspace_id, db).await;
let default = || {
+144 -45
View File
@@ -6,96 +6,195 @@ use windmill_common::workspaces::WM_FORK_PREFIX;
const FORK_PARENT_CACHE_TTL_SECS: u64 = 300;
#[derive(Clone)]
struct TagWorkspace {
/// The workspace id embedded in this workspace's tags.
id: String,
/// Whether the `-fork` suffix may apply: the workspace shares an ancestor's id, or carries the
/// generated prefix itself.
is_fork: bool,
}
lazy_static::lazy_static! {
// Cache of fork workspace id -> (parent_workspace_id, cached_at).
// `parent_workspace_id` is stable for the lifetime of a fork EXCEPT across attach/detach of a
// dev workspace, which set/keep it; those paths call `invalidate_fork_parent_cache` so routing
// doesn't lag. `None` means the lookup found no parent (or the DB call failed); we still cache
// it briefly so that forks missing a parent do not hammer the DB.
static ref FORK_PARENT_CACHE: quick_cache::sync::Cache<String, (Option<String>, std::time::Instant)> =
// Cache of workspace id -> (resolved tag workspace, cached_at).
// The resolution depends on the whole ancestor chain, so attach/detach of a dev workspace
// invalidates the mutated workspace AND its descendants; a parentless result is still cached
// briefly so non-forks (the common case) do not hammer the DB.
static ref FORK_PARENT_CACHE: quick_cache::sync::Cache<String, (TagWorkspace, std::time::Instant)> =
quick_cache::sync::Cache::new(500);
}
/// Drop the cached fork->parent mapping for a workspace. Call after mutating `parent_workspace_id`
/// (attaching/detaching a dev workspace) so per-workspace job tags resolve to the new parent
/// immediately instead of after the cache TTL.
/// Channel on which a lineage change is broadcast to every server and worker process.
pub const FORK_LINEAGE_CHANGE_CHANNEL: &str = "notify_fork_lineage_change";
/// Payload meaning "drop every entry". A change to the shape of the tree invalidates an unbounded
/// set of descendants, which is not worth enumerating; a change to what a single id denotes is.
const CLEAR_ALL: &str = "*";
/// Drop one cached tag workspace in THIS process only. Resolution walks ancestors, so a mutation
/// also invalidates every descendant; sweep them here as well. Replicas need a broadcast to match:
/// [`notify_fork_lineage_reset`] when a subtree moved (attach, detach, archive, rename, a delete
/// that orphans), or [`notify_fork_lineage_change`] when a single id changed what it denotes.
pub fn invalidate_fork_parent_cache(workspace_id: &str) {
FORK_PARENT_CACHE.remove(workspace_id);
}
/// Apply a broadcast lineage change to this process's cache.
pub fn apply_fork_lineage_change(payload: &str) {
if payload == CLEAR_ALL {
FORK_PARENT_CACHE.clear();
} else {
FORK_PARENT_CACHE.remove(payload);
}
}
/// Broadcast that one workspace id now denotes something else, so every process drops just that
/// entry. Ids are reclaimable, so a deleted fork's mapping must not outlive it: the id can be
/// claimed again under a different parent well inside the cache TTL.
pub async fn notify_fork_lineage_change<'e>(
executor: impl sqlx::Executor<'e, Database = Postgres>,
workspace_id: &str,
) -> Result<(), sqlx::Error> {
broadcast(executor, workspace_id).await
}
/// Broadcast that the shape of the tree changed, so every process drops every entry. Used where a
/// mutation moves an unbounded set of descendants; these are rare admin actions.
pub async fn notify_fork_lineage_reset<'e>(
executor: impl sqlx::Executor<'e, Database = Postgres>,
) -> Result<(), sqlx::Error> {
broadcast(executor, CLEAR_ALL).await
}
/// Called after the mutation commits, alongside the local invalidation: a lost event only means
/// replicas wait out the cache TTL, which is what they did before the broadcast existed.
async fn broadcast<'e>(
executor: impl sqlx::Executor<'e, Database = Postgres>,
payload: &str,
) -> Result<(), sqlx::Error> {
sqlx::query("INSERT INTO notify_event (channel, payload) VALUES ($1, $2)")
.bind(FORK_LINEAGE_CHANGE_CHANNEL)
.bind(payload)
.execute(executor)
.await?;
Ok(())
}
/// The workspace id embedded in a job's tags, for both the default `{lang}-{ws}` tags and explicit
/// tags containing `$workspace`.
///
/// Resolves to the nearest ancestor (possibly the workspace itself) whose id an admin would
/// actually provision workers for. An ephemeral fork is skipped: forks are created and destroyed
/// continuously under generated `wm-fork-*` ids, so a fork-scoped tag would never be served. A dev
/// workspace stops the walk and keeps its own id — it is long-lived and may have had its own
/// workers before it was attached — unless it too carries the generated prefix.
///
/// Unauthenticated helper: reads workspace hierarchy for any `workspace_id`, so callers must
/// already be authorized for that workspace (or run in trusted server-side code).
pub async fn tag_workspace_id(workspace_id: &str, db: &Pool<Postgres>) -> String {
lookup_tag_workspace(workspace_id, db).await.id
}
/// Returns `Some(effective_workspace_tag_id)` if jobs of `workspace_id` should use workspace-
/// specific tags, where `effective_workspace_tag_id` is the string embedded in the tag. For forks,
/// this is always the parent workspace id, optionally suffixed with `-fork` (controlled by the
/// specific default tags. Returns `None` when default (non-workspaced) tags should be used.
///
/// The id is [`tag_workspace_id`], optionally suffixed with `-fork` (controlled by the
/// `FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX` instance setting) so admins can route fork jobs to
/// dedicated workers. Returns `None` when default (non-workspaced) tags should be used.
/// dedicated workers.
pub async fn per_workspace_tag(workspace_id: &str, db: &Pool<Postgres>) -> Option<String> {
// Fast path: global toggle off -> no workspacing at all.
if !DEFAULT_TAGS_PER_WORKSPACE.load(std::sync::atomic::Ordering::Relaxed) {
return None;
}
// Resolve to the parent workspace id when the workspace is a fork or dev workspace (both set
// parent_workspace_id). The lookup caches its `None` result, so non-forks stay cheap after warmup
// (and the common case is already short-circuited by the global toggle above).
let parent = lookup_fork_parent(workspace_id, db).await;
// A `wm-fork-` workspace can outlive its parent (the FK is `ON DELETE SET NULL`), so keep
// treating the prefix as fork-ness for the `-fork` suffix even when the parent link is gone.
let is_fork = parent.is_some() || workspace_id.starts_with(WM_FORK_PREFIX);
let effective_ws_id: String = parent.unwrap_or_else(|| workspace_id.to_string());
let resolved = lookup_tag_workspace(workspace_id, db).await;
// Whitelist check is against the resolved (parent) id so that including a parent in the
// whitelist transparently covers all of its forks.
// Whitelist check is against the resolved id so that including a workspace in the whitelist
// transparently covers every fork that borrows its id.
let per_workspace_workspaces = DEFAULT_TAGS_WORKSPACES.load();
let whitelisted = per_workspace_workspaces.is_none()
|| (**per_workspace_workspaces)
.as_ref()
.unwrap()
.contains(&effective_ws_id);
.contains(&resolved.id);
if !whitelisted {
return None;
}
// For forks, optionally append a `-fork` suffix so all forks of a parent share a common
// dedicated tag (e.g. `python3-{parent_id}-fork`).
let append_fork_suffix =
is_fork && FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX.load(std::sync::atomic::Ordering::Relaxed);
// Only meaningful for workspaces sharing an ancestor's id: the suffix is what separates their
// jobs from that ancestor's own (e.g. `python3-{parent_id}-fork`).
let append_fork_suffix = resolved.is_fork
&& FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX.load(std::sync::atomic::Ordering::Relaxed);
Some(if append_fork_suffix {
format!("{}-fork", effective_ws_id)
format!("{}-fork", resolved.id)
} else {
effective_ws_id
resolved.id
})
}
/// Returns the parent workspace id for a fork, or `None` if the fork has no parent set. Backed by a
/// short-TTL cache to avoid a DB round-trip per job push. A transient DB error returns `None` for
/// this call but is NOT cached, so the next push retries instead of misrouting a (prefix-less) dev
/// workspace's jobs for the whole TTL.
async fn lookup_fork_parent(fork_id: &str, db: &Pool<Postgres>) -> Option<String> {
if let Some((parent, cached_at)) = FORK_PARENT_CACHE.get(fork_id) {
/// Backed by a short-TTL cache to avoid a DB round-trip per job push. A transient DB error resolves
/// to the workspace's own id for this call but is NOT cached, so the next push retries instead of
/// misrouting for the whole TTL.
async fn lookup_tag_workspace(workspace_id: &str, db: &Pool<Postgres>) -> TagWorkspace {
if let Some((resolved, cached_at)) = FORK_PARENT_CACHE.get(workspace_id) {
if cached_at.elapsed().as_secs() < FORK_PARENT_CACHE_TTL_SECS {
return parent;
return resolved;
}
}
let parent = match sqlx::query_scalar!(
"SELECT parent_workspace_id FROM workspace WHERE id = $1",
fork_id
// Walk up to the first ancestor that keeps its own id: a root, or a dev workspace that is not
// itself under a generated fork id. The depth bound mirrors the other chain walkers as a
// cycle-safety backstop. A `wm-fork-` workspace orphaned by `ON DELETE SET NULL` has no parent
// left to borrow from, so it ends the walk on itself.
let resolved = match sqlx::query_scalar!(
r#"
WITH RECURSIVE chain AS (
SELECT id, parent_workspace_id, is_dev_workspace, 0 AS depth
FROM workspace WHERE id = $1
UNION ALL
SELECT w.id, w.parent_workspace_id, w.is_dev_workspace, chain.depth + 1
FROM workspace w
JOIN chain ON w.id = chain.parent_workspace_id
WHERE chain.depth < 20
)
SELECT id AS "id!" FROM chain
WHERE parent_workspace_id IS NULL
OR (is_dev_workspace AND id NOT LIKE 'wm-fork-%')
ORDER BY depth LIMIT 1
"#,
workspace_id
)
.fetch_optional(db)
.await
{
Ok(opt) => opt.flatten(),
// No row means the workspace does not exist yet, or its chain is broken: both are "we
// cannot resolve this", so fall back to its own id for this call WITHOUT caching. Caching
// it would pin a fork to its own unserved id for the whole TTL — a rename resolves the new
// id before the row lands, and every job pushed until the TTL expires would queue.
Ok(None) => {
return TagWorkspace {
id: workspace_id.to_string(),
is_fork: workspace_id.starts_with(WM_FORK_PREFIX),
}
}
Ok(Some(id)) => {
let is_fork = id != workspace_id || workspace_id.starts_with(WM_FORK_PREFIX);
TagWorkspace { id, is_fork }
}
Err(e) => {
tracing::warn!("failed to look up fork parent for {fork_id}: {e:#}");
return None;
tracing::warn!("failed to resolve tag workspace for {workspace_id}: {e:#}");
return TagWorkspace {
id: workspace_id.to_string(),
is_fork: workspace_id.starts_with(WM_FORK_PREFIX),
};
}
};
FORK_PARENT_CACHE.insert(
fork_id.to_string(),
(parent.clone(), std::time::Instant::now()),
workspace_id.to_string(),
(resolved.clone(), std::time::Instant::now()),
);
parent
resolved
}
@@ -0,0 +1,84 @@
//! Regression guard for the workspace id that job tags resolve to.
//!
//! An ephemeral fork borrows its parent's id (no worker is ever provisioned for a `wm-fork-*` id),
//! but a dev workspace keeps its own — it is long-lived and may already have dedicated workers, so
//! attaching one must not move its jobs onto the parent's pool.
//!
//! Run with:
//! cargo test -p windmill-queue --test tag_workspace_test
use sqlx::{Pool, Postgres};
use windmill_queue::tags::{
apply_fork_lineage_change, invalidate_fork_parent_cache, tag_workspace_id,
};
async fn insert_ws(db: &Pool<Postgres>, id: &str, parent: Option<&str>, is_dev: bool) {
sqlx::query(
"INSERT INTO workspace (id, name, owner, parent_workspace_id, is_dev_workspace)
VALUES ($1, $1, 'test-user', $2, $3)",
)
.bind(id)
.bind(parent)
.bind(is_dev)
.execute(db)
.await
.expect("insert workspace");
// The resolver caches per id in a process-global cache shared across tests.
invalidate_fork_parent_cache(id);
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn tag_workspace_id_resolves_to_the_nearest_servable_ancestor(db: Pool<Postgres>) {
insert_ws(&db, "twt-prod", None, false).await;
insert_ws(&db, "wm-fork-twt", Some("twt-prod"), false).await;
insert_ws(&db, "twt-dev", Some("twt-prod"), true).await;
// A fork of a fork: the intermediate id is generated too, so it cannot end the walk.
insert_ws(&db, "wm-fork-nested", Some("wm-fork-twt"), false).await;
// A generated-id workspace re-designated as a dev workspace: still nothing serves its own id.
// Under its own root, since only one dev workspace is allowed per parent.
insert_ws(&db, "twt-prod2", None, false).await;
insert_ws(&db, "wm-fork-asdev", Some("twt-prod2"), true).await;
// A fork under a genuine dev workspace stops there — that workspace has its own workers.
insert_ws(&db, "wm-fork-underdev", Some("twt-dev"), false).await;
assert_eq!(tag_workspace_id("twt-prod", &db).await, "twt-prod");
assert_eq!(tag_workspace_id("wm-fork-twt", &db).await, "twt-prod");
assert_eq!(tag_workspace_id("twt-dev", &db).await, "twt-dev");
assert_eq!(tag_workspace_id("wm-fork-nested", &db).await, "twt-prod");
assert_eq!(tag_workspace_id("wm-fork-asdev", &db).await, "twt-prod2");
assert_eq!(tag_workspace_id("wm-fork-underdev", &db).await, "twt-dev");
}
/// Fork ids are reclaimable, so a deleted fork's cached mapping must not outlive it. The deleting
/// process invalidates locally, but every other replica only learns through the broadcast; without
/// it, a job pushed in the recreated fork routes to the previous parent's tag for the whole TTL.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn reclaimed_fork_id_follows_its_new_parent(db: Pool<Postgres>) {
insert_ws(&db, "rc-first", None, false).await;
insert_ws(&db, "rc-second", None, false).await;
insert_ws(&db, "wm-fork-rc", Some("rc-first"), false).await;
// Warm the mapping the way a job push would.
assert_eq!(tag_workspace_id("wm-fork-rc", &db).await, "rc-first");
// The id is freed and claimed again under a different parent. No local invalidation here: this
// stands in for a replica that did not handle the delete.
sqlx::query("DELETE FROM workspace WHERE id = 'wm-fork-rc'")
.execute(&db)
.await
.expect("delete fork");
sqlx::query(
"INSERT INTO workspace (id, name, owner, parent_workspace_id) \
VALUES ('wm-fork-rc', 'wm-fork-rc', 'test-user', 'rc-second')",
)
.execute(&db)
.await
.expect("reclaim fork id");
// Until the broadcast lands the stale ancestor survives — that is the bug the broadcast exists
// to close, so pin it rather than leave the mechanism untested.
assert_eq!(tag_workspace_id("wm-fork-rc", &db).await, "rc-first");
apply_fork_lineage_change("wm-fork-rc");
assert_eq!(tag_workspace_id("wm-fork-rc", &db).await, "rc-second");
}
@@ -4759,25 +4759,6 @@ var WorkspaceService = class {
mediaType: "application/json",
});
}
/**
* edit deploy to
* @param data The data for the request.
* @param data.workspace
* @param data.requestBody
* @returns string status
* @throws ApiError
*/
static editDeployTo(data) {
return request(OpenAPI, {
method: "POST",
url: "/w/{workspace}/workspaces/edit_deploy_to",
path: {
workspace: data.workspace,
},
body: data.requestBody,
mediaType: "application/json",
});
}
/**
* edit auto invite
* @param data The data for the request.
+51 -5
View File
@@ -369,6 +369,8 @@ async fn execute_windmill_tool(
tool_call_args.insert(key.clone(), result);
}
let is_ai_agent_tool = matches!(tool_value, FlowModuleValue::AIAgent { .. });
let job_payload = match tool_value {
FlowModuleValue::Script { path: script_path, hash: script_hash, tag_override, .. } => {
script_to_payload(
@@ -611,6 +613,7 @@ async fn execute_windmill_tool(
tool_module,
job_id,
outcome.is_success(),
is_ai_agent_tool,
inner_job_completed_rx,
messages,
final_events_str,
@@ -682,6 +685,15 @@ async fn handle_tool_execution_error(
Ok(())
}
/// Extract the `output` field of an `AIAgentResult` envelope, serialized back to JSON.
/// Returns `None` if the result is not a JSON object carrying an `output` field.
fn extract_ai_agent_output(result: &RawValue) -> Option<String> {
serde_json::from_str::<HashMap<String, &RawValue>>(result.get())
.ok()?
.get("output")
.map(|output| output.get().to_string())
}
/// Handle tool execution success
async fn handle_tool_execution_success(
ctx: &mut ToolExecutionContext<'_>,
@@ -689,14 +701,16 @@ async fn handle_tool_execution_success(
tool_module: &windmill_common::flows::FlowModule,
job_id: Uuid,
success: bool,
is_ai_agent_tool: bool,
inner_job_completed_rx: JobCompletedReceiver,
messages: &mut Vec<OpenAIMessage>,
final_events_str: &mut String,
) -> Result<(), Error> {
let send_result = inner_job_completed_rx.bounded_rx.try_recv().ok();
let result = if let Some(SendResult {
result: SendResultPayload::JobCompleted(ref jc), ..
let (result, job_success) = if let Some(SendResult {
result: SendResultPayload::JobCompleted(ref jc),
..
}) = send_result
{
let result = jc.result.clone();
@@ -738,16 +752,26 @@ async fn handle_tool_execution_success(
.await
.map_err(|e| Error::internal_err(format!("Failed to add completed job error: {e}")))?;
}
result
(result, jc.success)
} else {
return Err(Error::internal_err(
"Tool job completed but no result".to_string(),
));
};
// A nested agent returns the whole `AIAgentResult` envelope: on top of `output` it carries
// the child's entire message history, stream log and token usage. Feeding that back would
// grow the caller's context by the child's full transcript on every call, so the caller only
// sees `output`. The envelope stays intact in the tool job's completed row.
let tool_result = if is_ai_agent_tool && job_success {
extract_ai_agent_output(&result).unwrap_or_else(|| result.get().to_string())
} else {
result.get().to_string()
};
messages.push(OpenAIMessage {
role: "tool".to_string(),
content: Some(OpenAIContent::Text(result.get().to_string())),
content: Some(OpenAIContent::Text(tool_result.clone())),
tool_call_id: Some(tool_call.id.clone()),
agent_action: Some(AgentAction::ToolCall {
job_id,
@@ -762,7 +786,7 @@ async fn handle_tool_execution_success(
let tool_result_event = StreamingEvent::ToolResult {
call_id: tool_call.id.clone(),
function_name: tool_call.function.name.clone(),
result: result.get().to_string(),
result: tool_result,
success: true,
};
stream_event_processor
@@ -840,3 +864,25 @@ async fn add_tool_message_to_chat(
}
}
}
#[cfg(test)]
mod tests {
use super::extract_ai_agent_output;
use serde_json::value::RawValue;
#[test]
fn extracts_only_the_output_of_an_agent_result() {
let envelope = RawValue::from_string(
r#"{"output":{"answer":"42"},"messages":[{"role":"user","content":"hi"}],"wm_stream":"...","usage":{"total_tokens":10}}"#
.to_string(),
)
.unwrap();
assert_eq!(
extract_ai_agent_output(&envelope).as_deref(),
Some(r#"{"answer":"42"}"#)
);
let not_an_envelope = RawValue::from_string(r#"["a"]"#.to_string()).unwrap();
assert_eq!(extract_ai_agent_output(&not_an_envelope), None);
}
}
+108 -73
View File
@@ -23,7 +23,10 @@ use crate::ai::tools::McpClientStub as McpClient;
use windmill_ai::{
ai_providers::AIProvider,
image_handler::upload_image_to_s3,
providers::create_query_builder,
providers::{
create_chat_completions_query_builder, create_query_builder, is_chat_completions_only,
remember_chat_completions_only,
},
proxy::{
common_outbound_headers, needs_unavailable_oauth_exchange, retain_effective_credentials,
},
@@ -834,7 +837,15 @@ pub async fn run_agent(
let api_key = credentials.api_key.as_deref().unwrap_or("");
// Create the query builder for the provider
let query_builder = create_query_builder(&credentials, args.provider.get_model());
let mut query_builder = create_query_builder(&credentials, args.provider.get_model());
if query_builder.supports_chat_completions_fallback(base_url)
&& is_chat_completions_only(base_url, args.provider.get_model())
{
query_builder = create_chat_completions_query_builder(&credentials);
}
// Both outlive the iteration that discovers them: a request shape or a route the
// endpoint rejected once stays rejected for the whole step.
let mut include_usage = true;
// Initialize messages
let mut messages =
@@ -1149,20 +1160,13 @@ pub async fn run_agent(
has_websearch,
};
let request_body = query_builder
.build_request(&build_args, client, &job.workspace_id)
.await?;
let endpoint =
query_builder.get_endpoint(base_url, args.provider.get_model(), output_type);
let auth_headers = query_builder.get_auth_headers(api_key, base_url, output_type);
// A worker cannot run the client credentials exchange, so an OAuth resource
// has no token here: the request would carry an empty credential and come
// back 401.
if needs_unavailable_oauth_exchange(
&credentials,
args.provider.resource.token_url.as_deref(),
&auth_headers,
&query_builder.get_auth_headers(api_key, base_url, output_type),
) {
return Err(Error::ExecutionErr(format!(
"The {:?} resource authenticates with OAuth, which AI agent steps do not \
@@ -1171,7 +1175,6 @@ pub async fn run_agent(
credentials.provider
)));
}
let auth_headers = retain_effective_credentials(&credentials, auth_headers);
let timeout = resolve_job_timeout(conn, &job.workspace_id, job.id, job.timeout)
.await
@@ -1185,75 +1188,107 @@ pub async fn run_agent(
let pinned_ai_client = pinned_ai_client_for(base_url).await?;
// Helper to build HTTP request with headers
let build_http_request = |body: String| {
let mut req = pinned_ai_client
.post(&endpoint)
.timeout(timeout)
.header("Content-Type", "application/json");
let build_http_request =
|endpoint: &str, auth_headers: &[(&'static str, String)], body: String| {
let mut req = pinned_ai_client
.post(endpoint)
.timeout(timeout)
.header("Content-Type", "application/json");
for (header_name, header_value) in &auth_headers {
req = req.header(*header_name, header_value.clone());
}
for (header_name, header_value) in auth_headers {
req = req.header(*header_name, header_value.clone());
}
for (header_name, header_value) in &trailing_headers {
req = req.header(header_name.as_str(), header_value.as_str());
}
for (header_name, header_value) in &trailing_headers {
req = req.header(header_name.as_str(), header_value.as_str());
}
req.body(body)
};
req.body(body)
};
let resp = build_http_request(request_body.clone())
.send()
.await
.map_err(|e| Error::internal_err(format!("Failed to call API: {}", e)))?;
// An endpoint can reject the request shape rather than the model:
// `stream_options`, which not every OpenAI-compatible provider accepts, and
// the route itself, when an Azure resource is outside the Responses API's
// model/region matrix. Each is retried once with that part dropped.
// Set where the route is found to be absent, and read once the fallback has
// answered: a rejection it did not resolve says nothing about the deployment.
let mut rerouted_by_a_route_rejection = false;
let resp = loop {
let request_body = if include_usage {
query_builder
.build_request(&build_args, client, &job.workspace_id)
.await?
} else {
query_builder
.build_request_without_usage(&build_args, client, &job.workspace_id)
.await?
};
let endpoint =
query_builder.get_endpoint(base_url, args.provider.get_model(), output_type);
let auth_headers = retain_effective_credentials(
&credentials,
query_builder.get_auth_headers(api_key, base_url, output_type),
);
// Check if request failed and we should retry without stream_options
let resp = match resp.error_for_status_ref() {
Ok(_) => resp,
Err(e) => {
let status = resp.status();
let text = resp
.text()
.await
.unwrap_or_else(|_| "<failed to read body>".to_string());
let resp = build_http_request(&endpoint, &auth_headers, request_body)
.send()
.await
.map_err(|e| Error::internal_err(format!("Failed to call API: {}", e)))?;
// Retry without stream_options if provider supports it and error suggests incompatibility
// Common error patterns: 400 Bad Request with mentions of stream_options or include_usage
let should_retry = query_builder.supports_retry_without_usage()
&& status.as_u16() == 400
&& (text.contains("stream_options")
|| text.contains("include_usage")
|| text.contains("Additional properties are not allowed"));
if should_retry {
tracing::info!(
"Retrying request without stream_options due to provider incompatibility"
);
let retry_body = query_builder
.build_request_without_usage(&build_args, client, &job.workspace_id)
.await?;
let retry_resp =
build_http_request(retry_body).send().await.map_err(|e| {
Error::internal_err(format!("Failed to call API on retry: {}", e))
})?;
match retry_resp.error_for_status_ref() {
Ok(_) => retry_resp,
Err(retry_e) => {
let retry_text = retry_resp
.text()
.await
.unwrap_or_else(|_| "<failed to read body>".to_string());
return Err(Error::internal_err(format!(
"API error on retry: {} - {}",
retry_e, retry_text
)));
}
match resp.error_for_status_ref() {
Ok(_) => {
if rerouted_by_a_route_rejection {
remember_chat_completions_only(base_url, args.provider.get_model());
}
break resp;
}
Err(e) => {
let status = resp.status();
let text = resp
.text()
.await
.unwrap_or_else(|_| "<failed to read body>".to_string());
// Common error patterns: 400 Bad Request with mentions of stream_options or include_usage
let rejects_usage_tracking = include_usage
&& query_builder.supports_retry_without_usage()
&& status.as_u16() == 400
&& (text.contains("stream_options")
|| text.contains("include_usage")
|| text.contains("Additional properties are not allowed"));
// Only the first call of the step may re-route: an endpoint that
// does not serve this API rejects that one already, whereas a
// rejection once the conversation is under way is about the
// conversation (context length, content filter, tool schema).
let route_unserved = i == 0
&& query_builder.supports_chat_completions_fallback(base_url)
&& matches!(status.as_u16(), 400 | 404)
&& *output_type == OutputType::Text;
if rejects_usage_tracking {
tracing::info!(
"Retrying request without stream_options due to provider incompatibility"
);
include_usage = false;
} else if route_unserved {
tracing::info!(
"Endpoint rejected the request ({}), falling back to chat/completions",
status
);
// Only a 404 says the route is absent. A 400 is ambiguous —
// a deployment that does serve the route rejects tool
// schemas, blocked hosted tools and filtered content the
// same way — so it re-routes this step and nothing more.
rerouted_by_a_route_rejection = status.as_u16() == 404;
query_builder = create_chat_completions_query_builder(&credentials);
include_usage = true;
} else {
return Err(Error::internal_err(format!(
"API error calling {}: {} - {}",
endpoint, e, text
)));
}
} else {
return Err(Error::internal_err(format!("API error: {} - {}", e, text)));
}
}
};
@@ -409,6 +409,14 @@ pub fn start_background_processor(
.warn_after_seconds(10)
.await;
// Resolved here rather than from the main loop's outcome because `wm_failure`
// can flip an exit-zero init script to a failure, and dedicated workers must
// not install against a host it failed to prepare. An agent server relays other
// workers' init scripts, so it has no say over its own gate.
if is_init_script && !is_agent_server {
crate::worker::init_script_finished(final_success);
}
if is_init_script && !final_success {
if is_agent_server {
// The failed init script belongs to a remote agent
@@ -681,8 +689,52 @@ pub async fn handle_receive_completed_job(
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
#[cfg(feature = "benchmark")] bench: &mut BenchmarkIter,
) -> Option<Arc<MiniPulledJob>> {
let token = jc.token.clone();
let workspace = jc.job.workspace_id.clone();
// The client built here drives post-completion orchestration (the next step's input
// transforms fetch prior step results), which outlives the finished step. The step's own
// token has a `SCRIPT_TOKEN_EXPIRY` lifetime, so reusing it would fail that orchestration
// once the step itself ran longer than the token lives; refresh it when the step is old enough.
let token_maybe_expired = jc
.duration
.is_some_and(|d| d as u64 >= *windmill_common::worker::SCRIPT_TOKEN_EXPIRY * 1000 / 2);
let token = if jc.job.is_flow_step() && token_maybe_expired {
// Mirror `create_token`'s label so run-on-behalf-of flows keep their end-user override.
let label = if jc.job.permissioned_as != format!("u/{}", jc.job.created_by)
&& jc.job.permissioned_as != jc.job.created_by
{
format!("ephemeral-script-end-user-{}", jc.job.created_by)
} else {
"ephemeral-script".to_string()
};
match windmill_common::auth::create_token_for_owner(
db,
&jc.job.workspace_id,
&jc.job.permissioned_as,
&label,
*windmill_common::worker::SCRIPT_TOKEN_EXPIRY,
&jc.job.permissioned_as_email,
&jc.job.id,
None,
Some(format!(
"job-span-{}",
jc.job.flow_innermost_root_job.unwrap_or(jc.job.id)
)),
)
.warn_after_seconds(5)
.await
{
Ok(t) => t,
Err(e) => {
tracing::warn!(
"could not mint fresh flow-orchestration token for job {}, reusing step token: {e:#}",
jc.job.id
);
jc.token.clone()
}
}
} else {
jc.token.clone()
};
let client = AuthedClient::new(base_internal_url.to_string(), workspace, token, None);
let job = jc.job.clone();
let mem_peak = jc.mem_peak.clone();
+124 -18
View File
@@ -152,6 +152,7 @@ use crate::{
worker_flow::handle_flow,
worker_lockfiles::{
handle_app_dependency_job, handle_dependency_job, handle_flow_dependency_job,
tally_unfinished_dependency_deploy,
},
worker_utils::{insert_ping, queue_vacuum, update_worker_ping_full},
};
@@ -2349,6 +2350,27 @@ pub async fn run_worker(
);
}
// Dedicated workers wait for the init script before installing their dependencies, so it has to
// be queued before they are spawned.
if i_worker == 1 {
// Initialize runtime asset inserter for batched database inserts
if let Connection::Sql(db) = conn {
init_runtime_asset_loop(db.clone(), killpill_rx.resubscribe());
}
if let Err(e) = queue_init_bash_maybe(conn, same_worker_tx.clone(), &worker_name).await {
resolve_init_script(InitScriptState::Aborted);
killpill_tx.send();
tracing::error!(worker = %worker_name, hostname = %hostname, "Error queuing init bash script for worker {worker_name}: {e:#}");
return;
}
spawn_periodic_script_task(
worker_name.clone(),
conn.clone(),
same_worker_tx.clone(),
killpill_rx.resubscribe(),
);
}
// (dedi_path, dedicated_worker_tx, dedicated_worker_handle)
// Option<Sender<Arc<QueuedJob>>>,
// Option<JoinHandle<()>>,
@@ -2379,24 +2401,6 @@ pub async fn run_worker(
Vec<JoinHandle<()>>,
) = (HashMap::new(), vec![]);
if i_worker == 1 {
// Initialize runtime asset inserter for batched database inserts
if let Connection::Sql(db) = conn {
init_runtime_asset_loop(db.clone(), killpill_rx.resubscribe());
}
if let Err(e) = queue_init_bash_maybe(conn, same_worker_tx.clone(), &worker_name).await {
killpill_tx.send();
tracing::error!(worker = %worker_name, hostname = %hostname, "Error queuing init bash script for worker {worker_name}: {e:#}");
return;
}
spawn_periodic_script_task(
worker_name.clone(),
conn.clone(),
same_worker_tx.clone(),
killpill_rx.resubscribe(),
);
}
#[cfg(feature = "prometheus")]
let _worker_dedicated_channel_queue_send_duration = {
if is_dedicated_worker
@@ -3314,6 +3318,12 @@ pub async fn run_worker(
tracing::info!(worker = %worker_name, hostname = %hostname, "worker {} exiting", worker_name);
// Only this worker runs the init job, so if its loop exited before doing so, nothing ever will:
// release whoever waits on it, otherwise joining the dedicated worker handles below hangs.
if i_worker == 1 {
resolve_init_script(InitScriptState::Aborted);
}
#[cfg(feature = "enterprise")]
{
let valid_key = LICENSE_KEY_VALID.load(std::sync::atomic::Ordering::Relaxed);
@@ -3371,6 +3381,81 @@ pub async fn run_worker(
tracing::info!(worker = %worker_name, hostname = %hostname, "number of jobs executed: {}", jobs_executed);
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum InitScriptState {
Pending,
Completed,
Aborted,
}
lazy_static::lazy_static! {
/// State of the INIT_SCRIPT job, which is the documented hook to prepare the host (CA
/// certificates, proxies, mounts), so anything reaching the network at startup waits on it. The
/// init job is executed by the main loop, which only starts once dedicated workers have been
/// spawned, hence a gate rather than plain ordering. Every path that gives up on running it
/// MUST resolve the gate, or waiters park forever and worker teardown hangs joining them.
static ref INIT_SCRIPT_STATE: tokio::sync::watch::Sender<InitScriptState> =
tokio::sync::watch::channel(InitScriptState::Pending).0;
}
/// Called with the post-processing verdict, which is the only one that accounts for `wm_failure`.
pub(crate) fn init_script_finished(success: bool) {
resolve_init_script(if success {
InitScriptState::Completed
} else {
InitScriptState::Aborted
});
}
fn resolve_init_script(state: InitScriptState) {
INIT_SCRIPT_STATE.send_if_modified(|current| {
if *current == InitScriptState::Pending {
*current = state;
true
} else {
false
}
});
}
/// Returns false when the init script will never succeed (it failed, or the worker is shutting
/// down), in which case the caller must give up instead of preparing anything.
// Only called from the dedicated worker paths, which are gated behind the `private` feature.
#[allow(dead_code)]
pub(crate) async fn wait_for_init_script_completed(
killpill_rx: &mut tokio::sync::broadcast::Receiver<()>,
) -> bool {
let mut rx = INIT_SCRIPT_STATE.subscribe();
let state = *rx.borrow_and_update();
if state != InitScriptState::Pending {
return state == InitScriptState::Completed;
}
tracing::info!("waiting for init script to complete before installing dependencies");
// recv() is cancel-safe, so losing the select does not consume the killpill the caller still
// needs.
tokio::select! {
_ = rx.changed() => *rx.borrow_and_update() == InitScriptState::Completed,
_ = killpill_rx.recv() => false,
}
}
#[cfg(test)]
mod init_script_gate_tests {
use super::*;
// The gate is a process-global whose first resolution wins, so this must stay the only test
// that resolves it.
#[tokio::test]
async fn aborting_the_init_script_releases_waiters_for_good() {
let (_killpill_tx, mut killpill_rx) = tokio::sync::broadcast::channel(1);
resolve_init_script(InitScriptState::Aborted);
// Parking here instead would hang worker teardown on the dedicated worker handles.
assert!(!wait_for_init_script_completed(&mut killpill_rx).await);
resolve_init_script(InitScriptState::Completed);
assert!(!wait_for_init_script_completed(&mut killpill_rx).await);
}
}
async fn queue_init_bash_maybe<'c>(
conn: &Connection,
same_worker_tx: SameWorkerSender,
@@ -3383,6 +3468,7 @@ async fn queue_init_bash_maybe<'c>(
};
Some((uuid, content))
} else {
resolve_init_script(InitScriptState::Completed);
None
};
if let Some((uuid, content)) = uuid_content {
@@ -3958,6 +4044,9 @@ pub async fn handle_queued_job(
} else {
None
};
// Set by the dependency handlers once they reach `handle_deployment_metadata`,
// so the fallback tally below never counts the same deploy twice.
let mut deployment_tallied = false;
// Box::pin all async branches to prevent large match enum on stack
let result = match job.kind {
JobKind::Dependencies => match conn {
@@ -3975,6 +4064,7 @@ pub async fn handle_queued_job(
&client.token,
occupancy_metrics,
raw_workspace_dependencies_o,
&mut deployment_tallied,
))
.await
}
@@ -3999,6 +4089,7 @@ pub async fn handle_queued_job(
&client.token,
occupancy_metrics,
raw_workspace_dependencies_o,
&mut deployment_tallied,
))
.await
}
@@ -4021,6 +4112,7 @@ pub async fn handle_queued_job(
&client.token,
occupancy_metrics,
raw_workspace_dependencies_o,
&mut deployment_tallied,
))
.await
.map(|()| serde_json::from_str("{}").unwrap()),
@@ -4107,6 +4199,20 @@ pub async fn handle_queued_job(
}
};
// A lock generation that failed or was cancelled still leaves the deployed
// version live in the workspace, so its fork/parent change must be tallied.
// `AlreadyCompleted` is not such a failure — another worker owns the job.
if job.kind.is_dependency()
&& (result
.as_ref()
.is_err_and(|err| !matches!(err, &Error::AlreadyCompleted(_)))
|| canceled_by.is_some())
{
if let Connection::Sql(db) = conn {
tally_unfinished_dependency_deploy(db, job.as_ref(), &mut deployment_tallied).await;
}
}
let cjob = MiniCompletedJob::from(job.to_owned());
drop(job);
//it's a test job, no need to update the db
@@ -1522,6 +1522,14 @@ pub async fn update_flow_status_after_job_completion_internal(
});
let require_args = concurrency_requires_args || has_debouncing;
let mut tag = tag_and_concurrency_key.as_ref().and_then(|x| x.tag.clone());
// `$workspace` does not depend on the preprocessor's output, so it has to resolve even
// when nothing forced us to fetch args. Leaving it to the `$args` branch below writes a
// `$workspace`-only tag back verbatim, naming a queue no worker serves.
if let Some(t) = tag.as_ref().filter(|t| t.contains("$workspace")) {
let tag_ws =
windmill_queue::tags::tag_workspace_id(&flow_job.workspace_id, db).await;
tag = Some(t.replace("$workspace", &tag_ws));
}
let concurrency_key = tag_and_concurrency_key
.as_ref()
.and_then(|x| x.concurrency_key.clone());
@@ -1570,6 +1578,7 @@ pub async fn update_flow_status_after_job_completion_internal(
.await?;
}
if let Some(t) = tag {
// `$workspace` is already resolved above; this fills in `$args`.
tag = Some(interpolate_args(t, &args, &flow_job.workspace_id));
}
} else if concurrent_limit.is_some() {
@@ -17,6 +17,7 @@ use windmill_common::assets::{
use windmill_common::error::Error;
use windmill_common::error::Result;
use windmill_common::flows::{FlowModule, FlowModuleValue, FlowNodeId};
use windmill_common::jobs::JobKind;
use windmill_common::min_version::MIN_VERSION_SUPPORTS_DEBOUNCING_V2;
use windmill_common::scripts::ScriptHash;
#[cfg(feature = "python")]
@@ -40,7 +41,9 @@ use windmill_common::{
pub use windmill_dep_map::{
extract_referenced_paths, extract_relative_imports, process_relative_imports,
};
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
use windmill_git_sync::{
handle_deployment_metadata, tally_deployed_object_changes, DeployedObject,
};
use windmill_queue::{
append_logs, CanceledBy, MiniPulledJob, WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT,
};
@@ -90,6 +93,7 @@ pub async fn handle_dependency_job(
token: &str,
occupancy_metrics: &mut OccupancyMetrics,
raw_workspace_dependencies_o: Option<RawWorkspaceDependencies>,
deployment_tallied: &mut bool,
) -> error::Result<Box<RawValue>> {
// Processing a dependency job - these jobs handle lockfile generation and dependency updates
// for scripts, flows, and apps when their dependencies or imported scripts change
@@ -285,6 +289,9 @@ pub async fn handle_dependency_job(
{
tracing::error!(%e, "error handling deployment metadata");
}
// The tally is part of what `handle_deployment_metadata` does; anything
// that fails below must not make the caller record it a second time.
*deployment_tallied = true;
process_relative_imports(
db,
@@ -378,6 +385,7 @@ pub async fn handle_flow_dependency_job(
token: &str,
occupancy_metrics: &mut OccupancyMetrics,
raw_workspace_dependencies_o: Option<RawWorkspaceDependencies>,
deployment_tallied: &mut bool,
) -> error::Result<Box<serde_json::value::RawValue>> {
tracing::debug!("Processing flow dependency job");
tracing::trace!("Job details: {:?}", &job);
@@ -662,6 +670,7 @@ pub async fn handle_flow_dependency_job(
}) {
// Skip phase 3. Phase 1's deletes are committed; flow is left with no
// deps and the previous `flow.value`. Self-healing on next dep job.
tally_unfinished_dependency_deploy(db, &job, deployment_tallied).await;
return Ok(to_raw_value_owned(json!({
"status": "Flow lock generation was canceled",
})));
@@ -811,6 +820,7 @@ pub async fn handle_flow_dependency_job(
{
tracing::error!(%e, "error handling deployment metadata");
}
*deployment_tallied = true;
}
Ok(to_raw_value_owned(json!({
@@ -845,6 +855,77 @@ fn get_deployment_msg_and_parent_path_from_args(
(deployment_message, parent_path)
}
/// Record the fork/parent change tally for a dependency job that did not reach its
/// success path.
///
/// The new script/flow/app version is committed before the dependency job is even
/// pushed, so a failed or cancelled lock generation still leaves a deployed item in
/// the workspace. The tally otherwise only runs from `handle_deployment_metadata` on
/// the success path, and nothing ever re-scans `workspace_diff` — so without this the
/// change stays invisible in the fork's "Compare & Deploy" list forever, with no way
/// to surface it short of redeploying the item.
///
/// Sets `tallied` so the caller does not tally the same deploy twice: the tally
/// increments `ahead`, and a dependency handler can fail *after* having reached
/// `handle_deployment_metadata`.
pub(crate) async fn tally_unfinished_dependency_deploy(
db: &DB,
job: &MiniPulledJob,
tallied: &mut bool,
) {
if *tallied {
return;
}
*tallied = true;
// `runnable_id` is what distinguishes a deploy from a one-off preview lock job,
// which has nothing saved to tally.
let (Some(path), Some(version)) = (job.runnable_path.clone(), job.runnable_id.map(|h| h.0))
else {
return;
};
let (_, parent_path) = get_deployment_msg_and_parent_path_from_args(job.args.clone());
// `parent_path` is the previous path, set whether or not the deploy renamed the
// item. Only an actual rename is a second (now removed) path to tally.
let renamed_from = parent_path.clone().filter(|p| *p != path);
let obj = match job.kind {
JobKind::Dependencies => {
DeployedObject::Script { hash: ScriptHash(version), path, parent_path }
}
JobKind::FlowDependencies => DeployedObject::Flow { path, parent_path, version },
JobKind::AppDependencies => {
// `raw_app` decides the diff kind ("app" vs "raw_app"), so it has to be
// read back rather than guessed — a wrong kind writes a row nothing reads.
let raw_app = match sqlx::query_scalar!(
"SELECT raw_app FROM app_version WHERE id = $1",
version
)
.fetch_optional(db)
.await
{
Ok(v) => v.unwrap_or(false),
Err(e) => {
tracing::error!(%e, "could not read app_version {version} to tally fork changes");
return;
}
};
if raw_app {
DeployedObject::RawApp { path, version, parent_path }
} else {
DeployedObject::App { path, version, parent_path }
}
}
_ => return,
};
if let Err(e) =
tally_deployed_object_changes(&job.workspace_id, &obj, db, renamed_from.as_deref()).await
{
tracing::error!(%e, "error tallying fork changes for unfinished dependency job {}", job.id);
}
}
struct LockModuleError {
id: String,
error: Error,
@@ -2013,6 +2094,7 @@ pub async fn handle_app_dependency_job(
token: &str,
occupancy_metrics: &mut OccupancyMetrics,
raw_workspace_dependencies_o: Option<RawWorkspaceDependencies>,
deployment_tallied: &mut bool,
) -> error::Result<()> {
let job_path = job.runnable_path.clone().ok_or_else(|| {
error::Error::internal_err(
@@ -2161,6 +2243,7 @@ pub async fn handle_app_dependency_job(
tracing::error!(%job.id, %err, "error checking cancelation for job {0}: {err}", job.id);
false
}) {
tally_unfinished_dependency_deploy(db, &job, deployment_tallied).await;
return Ok(());
}
@@ -2206,6 +2289,7 @@ pub async fn handle_app_dependency_job(
{
tracing::error!(%e, "error handling deployment metadata");
}
*deployment_tallied = true;
// tx = PushIsolationLevel::Transaction(new_tx);
// tx = handle_deployment_metadata(
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.775.1";
export const VERSION = "v1.775.2";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+6 -6
View File
@@ -17,7 +17,7 @@
"minimatch": "^10.0.0",
"open": "^10.0.0",
"pg-gateway": "0.3.0-beta.4",
"svelte": "^5.45.2",
"svelte": "^5.56.8",
"tar-stream": "^3.1.7",
"windmill-parser-wasm-asset": "1.753.0",
"windmill-parser-wasm-csharp": "1.510.1",
@@ -156,7 +156,7 @@
"@stoplight/yaml-ast-parser": ["@stoplight/yaml-ast-parser@0.0.50", "", {}, "sha512-Pb6M8TDO9DtSVla9yXSTAxmo9GVEouq5P40DWXdOie69bXogZTkgvopCq+yEvTMA0F6PEvdJmbtTV3ccIp11VQ=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.11", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
@@ -180,7 +180,7 @@
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
@@ -206,7 +206,7 @@
"define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
"devalue": ["devalue@5.6.3", "", {}, "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg=="],
"devalue": ["devalue@5.8.2", "", {}, "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA=="],
"diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="],
@@ -214,7 +214,7 @@
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"esrap": ["esrap@2.2.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ=="],
"esrap": ["esrap@2.3.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng=="],
"events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="],
@@ -274,7 +274,7 @@
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"svelte": ["svelte@5.53.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.3", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-yGONuIrcl/BMmqbm6/52Q/NYzfkta7uVlos5NSzGTfNJTTFtPPzra6rAQoQIwAqupeM3s9uuTf5PvioeiCdg9g=="],
"svelte": ["svelte@5.56.8", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg=="],
"tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
+1 -1
View File
@@ -26,7 +26,7 @@
"minimatch": "^10.0.0",
"open": "^10.0.0",
"pg-gateway": "0.3.0-beta.4",
"svelte": "^5.45.2",
"svelte": "^5.56.8",
"tar-stream": "^3.1.7",
"windmill-parser-wasm-asset": "1.753.0",
"windmill-parser-wasm-csharp": "1.510.1",
+88 -19
View File
@@ -2,6 +2,8 @@ import * as fs from "node:fs";
import * as path from "node:path";
import process from "node:process";
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
import * as log from "../../core/log.ts";
import { colors } from "@cliffy/ansi/colors";
import * as windmillUtils from "@windmill-labs/shared-utils";
@@ -78,38 +80,70 @@ export function detectFrameworks(appDir: string): { svelte: boolean; vue: boolea
}
}
/**
* Loads the Svelte compiler out of the *app's* node_modules.
*
* The app brings its own Svelte runtime via package.json, and compiler and
* runtime have to agree on internals: Svelte 5.52.0 moved delegated event
* handlers off `element.__click` onto a Symbol-keyed map, so an older
* compiler's `onclick` output is silently ignored by a newer runtime the app
* builds and renders with every handler dead. A bare `import("svelte/compiler")`
* resolves against this CLI instead, whose own svelte floats independently of
* the app's, which is exactly how the two drift apart.
*
* Falls back to the CLI's own compiler when the app has none resolvable.
*/
export async function loadSvelteCompiler(appDir: string): Promise<any> {
try {
const requireFromApp = createRequire(
path.join(path.resolve(appDir), "package.json")
);
const entry = requireFromApp.resolve("svelte/compiler");
return await import(pathToFileURL(entry).href);
} catch {
return await import("svelte/compiler");
}
}
/**
* Creates a Svelte esbuild plugin
* Uses the svelte compiler from the project's node_modules
*/
function createSveltePlugin(appDir: string): any {
// Resolved once per build, not per file.
let compilerPromise: Promise<any> | undefined;
const svelteCompiler = () =>
(compilerPromise ??= loadSvelteCompiler(appDir));
// This converts a message in Svelte's format to esbuild's format
const messageConverter =
(source: string, filename: string) =>
({ message, start, end }: any) => {
let location;
if (start && end) {
const lineText = source.split(/\r\n|\r|\n/g)[start.line - 1];
const lineEnd = start.line === end.line ? end.column : lineText.length;
location = {
file: filename,
line: start.line,
column: start.column,
length: lineEnd - start.column,
lineText,
};
}
return { text: message, location };
};
return {
name: "svelte",
setup(build: any) {
build.onLoad({ filter: /\.svelte$/ }, async (args: any) => {
// Import svelte compiler from the project's node_modules
const svelte = await import("svelte/compiler");
const svelte = await svelteCompiler();
// Load the file from the file system
const source = await readTextFile(args.path);
const filename = path.relative(process.cwd(), args.path);
// This converts a message in Svelte's format to esbuild's format
const convertMessage = ({ message, start, end }: any) => {
let location;
if (start && end) {
const lineText = source.split(/\r\n|\r|\n/g)[start.line - 1];
const lineEnd = start.line === end.line ? end.column : lineText.length;
location = {
file: filename,
line: start.line,
column: start.column,
length: lineEnd - start.column,
lineText,
};
}
return { text: message, location };
};
const convertMessage = messageConverter(source, filename);
// Convert Svelte syntax to JavaScript
try {
@@ -120,6 +154,41 @@ function createSveltePlugin(appDir: string): any {
return { errors: [convertMessage(e)] };
}
});
// `lib.svelte.ts` / `lib.svelte.js` are plain modules that may use runes.
// They need `compileModule`, otherwise `$state`/`$derived` sail through
// esbuild untouched and the bundle throws "$state is not defined".
build.onLoad({ filter: /\.svelte\.[jt]s$/ }, async (args: any) => {
const svelte = await svelteCompiler();
const source = await readTextFile(args.path);
const filename = path.relative(process.cwd(), args.path);
const convertMessage = messageConverter(source, filename);
try {
// `compileModule` parses with plain acorn and chokes on TypeScript, so
// types have to come off first (vite-plugin-svelte gets this for free
// by running after Vite's esbuild transform).
const code = filename.endsWith(".ts")
? (
await build.esbuild.transform(source, {
loader: "ts",
sourcefile: filename,
})
).code
: source;
const { js, warnings } = svelte.compileModule(code, { filename });
const contents = js.code + `//# sourceMappingURL=` + js.map.toUrl();
return {
contents,
loader: "js",
warnings: warnings.map(convertMessage),
};
} catch (e: any) {
return { errors: [convertMessage(e)] };
}
});
},
};
}
+1 -1
View File
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
// dependency (main → workspace → utils → main) that triggers a TDZ.
// Re-exported from main.ts for backwards compatibility.
export const VERSION = "1.775.1";
export const VERSION = "1.775.2";
-14
View File
@@ -43,7 +43,6 @@ export interface SimplifiedSettings {
// Other fields
webhook?: string;
deploy_to?: string;
ai_config?: AIConfig;
large_file_storage?: any;
git_sync?: any;
@@ -73,7 +72,6 @@ interface LegacySimplifiedSettings {
success_handler_extra_args?: any;
// Other fields same as SimplifiedSettings
webhook?: string;
deploy_to?: string;
ai_config?: AIConfig;
large_file_storage?: any;
git_sync?: any;
@@ -96,7 +94,6 @@ export function migrateToGroupedFormat(settings: any): SimplifiedSettings {
// Copy non-legacy fields
if (settings.webhook !== undefined) result.webhook = settings.webhook;
if (settings.deploy_to !== undefined) result.deploy_to = settings.deploy_to;
if (settings.ai_config !== undefined) result.ai_config = settings.ai_config;
if (settings.large_file_storage !== undefined) result.large_file_storage = settings.large_file_storage;
if (settings.git_sync !== undefined) result.git_sync = settings.git_sync;
@@ -196,7 +193,6 @@ export async function pushWorkspaceSettings(
error_handler: remoteSettings.error_handler as ErrorHandlerConfig | undefined,
success_handler: remoteSettings.success_handler as SuccessHandlerConfig | undefined,
webhook: remoteSettings.webhook,
deploy_to: remoteSettings.deploy_to,
ai_config: remoteSettings.ai_config,
large_file_storage: remoteSettings.large_file_storage,
git_sync: remoteSettings.git_sync,
@@ -315,16 +311,6 @@ export async function pushWorkspaceSettings(
});
}
if (localSettings.deploy_to != settings.deploy_to) {
log.debug(`Updating deploy to...`);
await wmill.editDeployTo({
workspace,
requestBody: {
deploy_to: localSettings.deploy_to,
},
});
}
if (
!deepEqual(localSettings.large_file_storage, settings.large_file_storage)
) {
@@ -24,7 +24,6 @@ mock.module("../gen/services.gen.ts", () => ({
editAutoInvite: async () => {},
editErrorHandler: async () => {},
editSuccessHandler: async () => {},
editDeployTo: async () => {},
editCopilotConfig: async () => {},
editLargeFileStorageConfig: async () => {},
editWorkspaceGitSyncConfig: async () => {},
@@ -0,0 +1,89 @@
/**
* A raw app brings its own Svelte *runtime* via package.json, so it has to be
* compiled with its own *compiler* too the two share internals that change
* between versions. Svelte 5.52.0 moved delegated event handlers off
* `element.__click` onto a Symbol-keyed map, so compiling with a version on the
* other side of that line produces an app that builds, renders, and has every
* `onclick` silently dead.
*
* `import("svelte/compiler")` resolves against the CLI, whose svelte floats
* independently of the app's. These cover the app-local resolution that avoids
* that drift.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { loadSvelteCompiler } from "../src/commands/app/bundle.ts";
let tempDir: string;
/** A stand-in svelte install, identifiable by a version the real one never has. */
function installStubSvelte(dir: string, version: string) {
const pkgDir = path.join(dir, "node_modules", "svelte");
fs.mkdirSync(pkgDir, { recursive: true });
fs.writeFileSync(
path.join(pkgDir, "package.json"),
JSON.stringify({
name: "svelte",
version,
type: "module",
exports: { "./compiler": "./compiler.js" },
}),
"utf-8",
);
fs.writeFileSync(
path.join(pkgDir, "compiler.js"),
`export const VERSION = ${JSON.stringify(version)};\n` +
`export function compile() { return { js: { code: "", map: null }, warnings: [] }; }\n` +
`export function compileModule() { return { js: { code: "", map: null }, warnings: [] }; }\n`,
"utf-8",
);
}
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "raw-app-svelte-compiler-"));
fs.writeFileSync(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "app", private: true, dependencies: { svelte: "*" } }),
"utf-8",
);
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe("loadSvelteCompiler", () => {
test("uses the app's own compiler rather than the CLI's", async () => {
installStubSvelte(tempDir, "0.0.0-app-local");
const compiler = await loadSvelteCompiler(tempDir);
expect(compiler.VERSION).toBe("0.0.0-app-local");
});
test("resolves against the app even when given a relative dir", async () => {
installStubSvelte(tempDir, "0.0.0-relative");
const cwd = process.cwd();
try {
process.chdir(path.dirname(tempDir));
const compiler = await loadSvelteCompiler(path.basename(tempDir));
expect(compiler.VERSION).toBe("0.0.0-relative");
} finally {
process.chdir(cwd);
}
});
test("falls back to the CLI's compiler when the app has none", async () => {
const compiler = await loadSvelteCompiler(tempDir);
// The real thing, not a stub — and new enough to emit the Symbol-map form
// of delegated handlers that a modern runtime reads.
expect(typeof compiler.compile).toBe("function");
expect(compiler.VERSION).not.toContain("app-local");
const [major, minor] = compiler.VERSION.split(".").map(Number);
expect(major > 5 || (major === 5 && minor >= 52)).toBe(true);
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* `lib.svelte.ts` / `lib.svelte.js` modules are plain modules that may use
* runes. They need `svelte.compileModule`; without it esbuild happily bundles
* `$state(...)` as an ordinary call and the app dies at runtime with
* "ReferenceError: $state is not defined".
*/
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { createBundle } from "../src/commands/app/bundle.ts";
let tempDir: string;
let originalCwd: string;
// Runes used as bare calls, ignoring the `$state(...)` mentions inside Svelte's
// own warning message template literals.
function bareRuneCalls(js: string): string[] {
return [...js.matchAll(/(^|[^.\w$`])\$(state|derived|effect|props)\s*\(/g)].map(
(m) => m[0]
);
}
function writeApp(files: Record<string, string>) {
for (const [name, content] of Object.entries(files)) {
fs.writeFileSync(path.join(tempDir, name), content, "utf-8");
}
}
async function bundle(entry: string): Promise<string> {
const { js } = await createBundle({
entryPoint: path.join(tempDir, entry),
minify: false,
sourcemap: false,
});
return js;
}
beforeAll(() => {
originalCwd = process.cwd();
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "raw-app-svelte-module-"));
fs.writeFileSync(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "app", private: true, dependencies: { svelte: "*" } }),
"utf-8"
);
// Reuse the CLI's own svelte install instead of paying for an npm install;
// `ensureNodeModules` only checks that the directory is there.
fs.symlinkSync(
path.join(originalCwd, "node_modules"),
path.join(tempDir, "node_modules")
);
process.chdir(tempDir);
});
afterAll(() => {
process.chdir(originalCwd);
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe("svelte plugin: .svelte.ts modules", () => {
test("compiles runes in a TypeScript rune module and the bundle runs", async () => {
writeApp({
"lib.svelte.ts": `export class Cycle<T> {
#options: T[] = [];
#index = $state(0);
current = $derived(this.#options[this.#index]);
constructor(options: T[]) {
this.#options = options;
}
next() {
this.#index = (this.#index + 1) % this.#options.length;
}
}
`,
"ts_entry.ts": `import { Cycle } from './lib.svelte';
const cycle = new Cycle(['a', 'b', 'c']);
cycle.next();
(globalThis as any).__cycleResult = cycle.current;
`,
});
const js = await bundle("ts_entry.ts");
expect(bareRuneCalls(js)).toEqual([]);
// Runtime is the real check: unfixed, this throws
// "ReferenceError: $state is not defined".
new Function(js)();
expect((globalThis as any).__cycleResult).toBe("b");
});
test("compiles runes in a JavaScript rune module", async () => {
writeApp({
"counter.svelte.js": `export const counter = $state({ n: 0 });
export function bump() {
counter.n += 1;
}
`,
"js_entry.ts": `import { counter, bump } from './counter.svelte.js';
bump();
bump();
(globalThis as any).__counterResult = counter.n;
`,
});
const js = await bundle("js_entry.ts");
expect(bareRuneCalls(js)).toEqual([]);
new Function(js)();
expect((globalThis as any).__counterResult).toBe(2);
});
});
+2 -1
View File
@@ -125,6 +125,7 @@ describe("migrateToGroupedFormat", () => {
const settings = {
name: "my-workspace",
webhook: "https://example.com/hook",
// Older settings files still carry deploy_to; it is no longer a setting and must be dropped.
deploy_to: "staging",
default_app: "u/admin/dashboard",
mute_critical_alerts: true,
@@ -133,7 +134,7 @@ describe("migrateToGroupedFormat", () => {
const result = migrateToGroupedFormat(settings);
expect(result.name).toBe("my-workspace");
expect(result.webhook).toBe("https://example.com/hook");
expect(result.deploy_to).toBe("staging");
expect("deploy_to" in result).toBe(false);
expect(result.default_app).toBe("u/admin/dashboard");
expect(result.mute_critical_alerts).toBe(true);
expect(result.color).toBe("#ff0000");
+13 -4
View File
@@ -24,10 +24,19 @@ COPY --from=ghcr.io/nushell/nushell:0.101.0-bookworm /usr/bin/nu /usr/bin/nu
# Java
RUN apt-get -y update && apt-get install -y default-jdk
RUN curl -fLo coursier https://github.com/coursier/coursier/releases/download/v2.1.24/coursier \
&& mv ./coursier /usr/bin/coursier \
&& chmod +x /usr/bin/coursier
RUN /usr/bin/java -jar /usr/bin/coursier about
ARG COURSIER_VERSION=2.1.24
# The released coursier launcher downloads its own JARs from Maven Central on first run, so java
# jobs break on air-gapped networks; a build-time cache pre-warm cannot fix that because the cache
# the worker reads lives under WINDMILL_DIR, which deployments mount over. Hence a self-contained
# assembly, smoke-tested below: `about` must succeed and leave the cache it is given empty.
RUN curl -fLo /tmp/coursier-launcher https://github.com/coursier/coursier/releases/download/v${COURSIER_VERSION}/coursier \
&& COURSIER_CACHE=/tmp/coursier-build-cache /usr/bin/java -jar /tmp/coursier-launcher \
bootstrap io.get-coursier:coursier-cli_2.13:${COURSIER_VERSION} --assembly -o /usr/bin/coursier \
&& chmod +x /usr/bin/coursier \
&& mkdir -p /tmp/coursier-verify \
&& COURSIER_CACHE=/tmp/coursier-verify /usr/bin/java -jar /usr/bin/coursier about \
&& [ -z "$(ls -A /tmp/coursier-verify)" ] \
&& rm -rf /tmp/coursier-launcher /tmp/coursier-build-cache /tmp/coursier-verify /root/.cache/coursier
# Ruby
RUN apt-get install -y ruby ruby-bundler
+13 -4
View File
@@ -51,10 +51,19 @@ COPY --from=ghcr.io/nushell/nushell:0.101.0-bookworm /usr/bin/nu /usr/bin/nu
# Java
RUN apt-get -y update && apt-get install -y default-jdk
RUN curl -fLo coursier https://github.com/coursier/coursier/releases/download/v2.1.24/coursier \
&& mv ./coursier /usr/bin/coursier \
&& chmod +x /usr/bin/coursier
RUN /usr/bin/java -jar /usr/bin/coursier about
ARG COURSIER_VERSION=2.1.24
# The released coursier launcher downloads its own JARs from Maven Central on first run, so java
# jobs break on air-gapped networks; a build-time cache pre-warm cannot fix that because the cache
# the worker reads lives under WINDMILL_DIR, which deployments mount over. Hence a self-contained
# assembly, smoke-tested below: `about` must succeed and leave the cache it is given empty.
RUN curl -fLo /tmp/coursier-launcher https://github.com/coursier/coursier/releases/download/v${COURSIER_VERSION}/coursier \
&& COURSIER_CACHE=/tmp/coursier-build-cache /usr/bin/java -jar /tmp/coursier-launcher \
bootstrap io.get-coursier:coursier-cli_2.13:${COURSIER_VERSION} --assembly -o /usr/bin/coursier \
&& chmod +x /usr/bin/coursier \
&& mkdir -p /tmp/coursier-verify \
&& COURSIER_CACHE=/tmp/coursier-verify /usr/bin/java -jar /usr/bin/coursier about \
&& [ -z "$(ls -A /tmp/coursier-verify)" ] \
&& rm -rf /tmp/coursier-launcher /tmp/coursier-build-cache /tmp/coursier-verify /root/.cache/coursier
# Ruby
RUN apt-get install -y ruby ruby-bundler
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.775.1",
"version": "1.775.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.775.1",
"version": "1.775.2",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.775.1",
"version": "1.775.2",
"scripts": {
"dev": "vite dev",
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
+2 -2
View File
@@ -1,5 +1,5 @@
{
"baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev",
"version": "76ee616",
"sha256": "1d696d38a152179ef6e1cada5df302d078cc677b6dbc244b5c42335e11feabac"
"version": "2471791",
"sha256": "04048791fb6b2ebca5cc30447e163826cdb143d65cce07a629252e131ac83618"
}
@@ -1,22 +1,18 @@
<script lang="ts">
import { WorkspaceService } from '$lib/gen'
import { enterpriseLicense, usersWorkspaceStore, workspaceStore } from '$lib/stores'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { fade } from 'svelte/transition'
import Tooltip from './Tooltip.svelte'
import { Plus, X } from 'lucide-svelte'
import { Button } from './common'
import TextInput from './text_input/TextInput.svelte'
import Toggle from './Toggle.svelte'
import { emptyString } from '$lib/utils'
import { validateDeployPathFilters } from '$lib/validators/workspaceSettings'
import Alert from './common/alert/Alert.svelte'
import SettingsFooter from './workspaceSettings/SettingsFooter.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
import Select from './select/Select.svelte'
let deployableWorkspaces = $derived(
$usersWorkspaceStore?.workspaces.map((w) => w.id).filter((w) => w != $workspaceStore)
)
type DeployUITypeMap = {
scripts: boolean
@@ -40,25 +36,27 @@
}
let {
workspaceToDeployTo = $bindable(),
deployUiSettings = $bindable({
include_path: [],
include_type: all_ok
}),
hasUnsavedChanges = false,
parentWorkspaceId,
isDevWorkspace = false,
onSave,
onDiscard,
onWorkspaceToDeployToSave
onDiscard
}: {
workspaceToDeployTo: string | undefined
deployUiSettings: {
include_path: string[]
include_type: DeployUITypeMap
}
hasUnsavedChanges?: boolean
/** Set on a fork or dev workspace, where the deploy target is the parent and not a choice. */
parentWorkspaceId?: string
/** Dev workspaces and plain forks both deploy into the parent but are not the same thing. */
isDevWorkspace?: boolean
onSave?: () => void
onDiscard: () => void
onWorkspaceToDeployToSave?: (workspaceToDeployTo: string | undefined) => void
} = $props()
// Validation state
@@ -101,25 +99,6 @@
return result
}
async function editWorkspaceToDeployTo() {
try {
await WorkspaceService.editDeployTo({
workspace: $workspaceStore ?? '',
requestBody: { deploy_to: workspaceToDeployTo === '' ? undefined : workspaceToDeployTo }
})
if (workspaceToDeployTo === '' || workspaceToDeployTo === undefined) {
sendUserToast('Disabled setting deployable workspace')
onWorkspaceToDeployToSave?.(undefined)
} else {
sendUserToast('Set deployable workspace to ' + workspaceToDeployTo)
onWorkspaceToDeployToSave?.(workspaceToDeployTo)
}
} catch (error) {
sendUserToast(`Failed to save workspace deployment setting: ${error}`, true)
}
}
async function editWindmillDeploymentUISettings() {
// Validate before saving
const validationResult = validateDeployPathFilters(deployUiSettings.include_path)
@@ -132,10 +111,6 @@
let include_type = deployUITypeMapToArray(deployUiSettings.include_type, true)
try {
// Save workspace to deploy to first
await editWorkspaceToDeployTo()
// Then save deployment UI settings
await WorkspaceService.editWorkspaceDeployUiSettings({
workspace: $workspaceStore!,
requestBody: {
@@ -153,17 +128,12 @@
}
</script>
<SettingCard label="Workspace to link to" class="mt-6">
<Select
items={[
{ label: 'Disable deployment', value: '' },
...(deployableWorkspaces ?? []).map((w) => ({ label: w, value: w }))
]}
bind:value={workspaceToDeployTo}
placeholder={deployableWorkspaces?.length === 0
? 'No workspace deployable to'
: 'Select workspace'}
/>
<SettingCard label="Workspace this one deploys into" class="mt-6">
<TextInput value={parentWorkspaceId ?? ''} inputProps={{ disabled: true }} />
<div class="text-xs text-secondary mt-2">
This workspace is the {isDevWorkspace ? 'dev workspace' : 'fork'} of <b>{parentWorkspaceId}</b> and
deploys there. Pairing is managed by the workspace lineage, not by this page.
</div>
</SettingCard>
<SettingCard
label="Deployable items"
@@ -284,7 +254,7 @@
onSave={editWindmillDeploymentUISettings}
{onDiscard}
saveLabel="Save deployment UI"
disabled={workspaceToDeployTo == undefined || hasValidationErrors}
disabled={hasValidationErrors}
class="mt-8"
/>
{/if}
@@ -21,6 +21,11 @@
item: ToggleGroupElements['item']
value: ToggleGroupItemProps
class?: string
// Fires on activation, including on the already-selected button — which the
// group's onSelected never reports (melt treats it as a deselection and the
// group keeps the current value). Keyboard activation needs its own keydown
// branch below: melt preventDefaults Enter/Space, so no click is synthesized.
onActivate?: (e: Event) => void
}
let {
@@ -38,7 +43,8 @@
id = undefined,
item,
value,
class: className = ''
class: className = '',
onActivate
}: Props = $props()
// Handle backward compatibility: small prop maps to size="sm"
@@ -74,6 +80,12 @@
)}
use:melt={$item(value)}
style={selectedColor ? `--selected-color: ${selectedColor}` : ''}
onclick={onActivate}
onkeydown={(e) => {
// `repeat` filtered out: a held key keeps firing keydown, and one activation
// must mean one call however the consumer's side effect behaves.
if (!e.repeat && (e.key === 'Enter' || e.key === ' ')) onActivate?.(e)
}}
>
{#if icon}
{@const SvelteComponent = icon}
@@ -26,19 +26,23 @@
let subLength: number = $state(100)
let length = $state(1)
let nextInProgress = false
let isCanceled = $state(false)
let isScheduled = $state(false)
let progressBar: ProgressBar | undefined = $state(undefined)
let lastJobId = $state()
function updateJobProgress(job: Job) {
if (!job['running'] && !job['success']) {
error = 0
} else {
error = undefined
}
// Anything that is success automatically gets 100% progress
if (job['success'] && scriptProgress)
((index = 1), (subLength = 0), (subIndex = 0), (scriptProgress = 100))
const completed = job.type === 'CompletedJob'
isCanceled = job['canceled'] ?? false
// A job still in the queue simply has not started: not running is only a failure
// signal once the job is completed.
isScheduled = !completed && !job['running']
error = completed && !job['success'] && !isCanceled ? 0 : undefined
// Anything that is success automatically gets 100% progress. Not gated on
// `scriptProgress`: a job can complete without ever reporting progress, and the bar
// would then stay on `Running` forever. A canceled job keeps the progress it reached.
if (job['success']) ((index = 1), (subLength = 0), (subIndex = 0), (scriptProgress = 100))
}
export function reset() {
@@ -48,6 +52,8 @@
subLength = 100
length = 1
index = 0
isCanceled = false
isScheduled = false
scriptProgress = undefined
}
@@ -77,4 +83,6 @@
class={className}
{compact}
{hideStepTitle}
{isCanceled}
{isScheduled}
/>
@@ -137,7 +137,7 @@ export const svelte5Template = {
'/index.css': indexCss,
'/package.json': `{
"dependencies": {
"svelte": "^5.55.5",
"svelte": "^5.56.8",
"windmill-client": "^1"
}
}`
@@ -1,8 +1,11 @@
<script lang="ts">
import { House, MessagesSquare } from 'lucide-svelte'
import { Building, MessagesSquare } from 'lucide-svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { enterSessionMode, exitSessionMode } from './sessionSwitch.svelte'
import { goto } from '$lib/navigation'
import { page } from '$app/state'
import { base } from '$lib/base'
// Which side of the switch is active. `nav` = workspace navigation (the classic
// app), `session` = the sessions sidebar + chat + preview.
@@ -20,6 +23,18 @@
if (next === 'session') void enterSessionMode()
else void exitSessionMode()
}
// Pressing the already-active "Workspace" side goes home, so the toggle doubles
// as the home button when there is no mode to switch to. `onToggle` is
// deliberately not fired: this is a plain in-mode navigation, so the mobile menu
// drawer should dismiss like it does for any other nav link.
function onNavActivate() {
if (mode !== 'nav') return
// `goto` has no same-URL short-circuit, so navigating from home would push a
// duplicate history entry and make the next Back press look broken.
if (page.url.pathname === `${base}/`) return
void goto('/')
}
</script>
<!-- Each ToggleButton renders inside a Tooltip wrapper, which is the actual flex
@@ -34,12 +49,13 @@
<ToggleButton
{item}
value="nav"
icon={isCollapsed ? House : undefined}
icon={isCollapsed ? Building : undefined}
label="Workspace"
iconOnly={isCollapsed}
tooltip={isCollapsed ? 'Workspace' : undefined}
size="sm"
class="w-full justify-center"
onActivate={onNavActivate}
/>
<ToggleButton
{item}
@@ -173,7 +173,6 @@
let plan: string | undefined = $state(undefined)
let customer_id: string | undefined = $state(undefined)
let webhook: string | undefined = $state(undefined)
let workspaceToDeployTo: string | undefined = $state(undefined)
let errorHandlerSelected: ErrorHandler = $state('slack')
let errorHandlerScriptPath: string | undefined = $state(undefined)
let errorHandlerItemKind: 'flow' | 'script' = $state('script')
@@ -193,7 +192,6 @@
let aiSettingsComponent: AISettings | undefined = $state(undefined)
let hasAiSettingsChanges = $state(false)
// Track initial deploy settings for unsaved changes detection
let initialWorkspaceToDeployTo: string | undefined = $state(undefined)
let initialDeployUiSettings: {
include_path: string[]
include_type: {
@@ -341,6 +339,10 @@
}
})
const currentWorkspace = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
// The Deployment UI tab configures what may be promoted into the parent, so it only means
// something for a fork. A root workspace deploys nowhere.
const showDeployToTab = $derived(Boolean(currentWorkspace?.parent_workspace_id))
const canAdmin = $derived(($userStore?.is_admin ?? false) || Boolean($superadmin))
// The creator of a fork gets the fork members screen even when they are not an admin of it:
// their `usr` row is copied from the parent, so forking as an ordinary developer leaves them
@@ -349,9 +351,7 @@
// The instance channels are not a valid destination on cloud or on a fork. Never select a tab
// the group does not render: saving would submit a value the API rejects, locking the whole
// error handler behind a 400.
const canUseInstanceAlerts = $derived(
!isCloudHosted() && !currentWorkspace?.parent_workspace_id
)
const canUseInstanceAlerts = $derived(!isCloudHosted() && !currentWorkspace?.parent_workspace_id)
const isForkOwner = $derived(
Boolean(currentWorkspace?.parent_workspace_id) &&
currentWorkspace?.created_by === $userStore?.email
@@ -394,6 +394,11 @@
if (selectedTab === 'success_handler') {
return 'error_handler'
}
// A root workspace deploys nowhere, so this tab has no parent to render and its filters
// could never apply. The nav item is hidden, but the URL is still reachable directly.
if (selectedTab === 'deploy_to' && !showDeployToTab) {
return 'general'
}
return selectedTab || 'users'
})
@@ -592,7 +597,6 @@
teamsInitialPath = teamsScriptPath
plan = settings.plan
customer_id = settings.customer_id
workspaceToDeployTo = settings.deploy_to
webhook = settings.webhook
aiInitialConfig = settings.ai_config ?? {}
@@ -660,7 +664,6 @@
}
// Store initial deploy settings state for unsaved changes detection
initialWorkspaceToDeployTo = workspaceToDeployTo
initialDeployUiSettings = clone(deployUiSettings)
// Store initial webhook state for unsaved changes detection
@@ -950,26 +953,14 @@
// Function to check if there are unsaved changes in deploy settings
function getDeploySettingsInitialAndModifiedValues() {
// Normalize empty strings to undefined for consistent comparison
const normalizeWorkspaceValue = (value: string | undefined) =>
value === '' ? undefined : value
const savedValue = {
workspaceToDeployTo: normalizeWorkspaceValue(initialWorkspaceToDeployTo),
deployUiSettings: initialDeployUiSettings
return {
savedValue: { deployUiSettings: initialDeployUiSettings },
modifiedValue: { deployUiSettings: deployUiSettings }
}
const modifiedValue = {
workspaceToDeployTo: normalizeWorkspaceValue(workspaceToDeployTo),
deployUiSettings: deployUiSettings
}
return { savedValue, modifiedValue }
}
// Function to discard unsaved deploy settings changes
function discardDeploySettingsChanges() {
workspaceToDeployTo = initialWorkspaceToDeployTo
deployUiSettings = clone(initialDeployUiSettings)
}
@@ -1167,6 +1158,7 @@
!currentWorkspace?.parent_workspace_id || (currentWorkspace?.is_dev_workspace ?? false)
)
// Navigation groups for sidebar
const adminNavigationGroups = $derived([
{
@@ -1208,13 +1200,17 @@
aiDescription: 'Git sync workspace settings',
isEE: true
},
{
id: 'deploy_to',
label: 'Deployment UI',
aiId: 'workspace-settings-deploy-to',
aiDescription: 'Deployment UI workspace settings',
isEE: true
},
...(showDeployToTab
? [
{
id: 'deploy_to',
label: 'Deployment UI',
aiId: 'workspace-settings-deploy-to',
aiDescription: 'Deployment UI workspace settings',
isEE: true
}
]
: []),
...(showDevWorkspaceTab
? [
{
@@ -1409,25 +1405,20 @@
{/if}
{:else if tab == 'deploy_to'}
<SettingsPageHeader
title="Link this workspace to another staging / prod workspace"
description="Connecting this workspace with another staging/production workspace enables web-based deployment to that workspace."
title="Deploying into {currentWorkspace?.parent_workspace_id}"
description="This workspace deploys into its parent. Choose which items the deploy UI may promote."
link="https://www.windmill.dev/docs/core_concepts/staging_prod"
/>
{#if $enterpriseLicense}
<DeployToSetting
bind:workspaceToDeployTo
bind:deployUiSettings
hasUnsavedChanges={hasDeploySettingsChanges}
parentWorkspaceId={currentWorkspace?.parent_workspace_id ?? undefined}
isDevWorkspace={currentWorkspace?.is_dev_workspace ?? false}
onSave={() => {
// Update initial state after successful save
initialWorkspaceToDeployTo = workspaceToDeployTo
initialDeployUiSettings = clone(deployUiSettings)
}}
onDiscard={discardDeploySettingsChanges}
onWorkspaceToDeployToSave={(newWorkspaceToDeployTo) => {
// Update initial state after workspace to deploy to is saved
initialWorkspaceToDeployTo = newWorkspaceToDeployTo
}}
/>
{:else}
<div class="my-2"
+1 -1
View File
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.775.1"
wmill = ">=1.775.2"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.775.1
version: 1.775.2
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.775.1'
ModuleVersion = '1.775.2'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.775.1"
version = "1.775.2"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill/windmill",
"version": "1.775.1",
"version": "1.775.2",
"exports": "./src/index.ts",
"publish": {
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-client",
"description": "Windmill SDK client for browsers and Node.js",
"version": "1.775.1",
"version": "1.775.2",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme",
+1 -1
View File
@@ -1 +1 @@
1.775.1
1.775.2