diff --git a/CHANGELOG.md b/CHANGELOG.md index ac51063de9..a69d7b0389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/backend/.sqlx/query-042c42957221352891f7433f2ec0d6b7f686d9c7d505590eec7ba3bfeeb406ca.json b/backend/.sqlx/query-042c42957221352891f7433f2ec0d6b7f686d9c7d505590eec7ba3bfeeb406ca.json deleted file mode 100644 index 9574582d5f..0000000000 --- a/backend/.sqlx/query-042c42957221352891f7433f2ec0d6b7f686d9c7d505590eec7ba3bfeeb406ca.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-0621faf69b1ef866a95f6310c9651875df409a2d2d72ada629bd71e8abdbbf8b.json b/backend/.sqlx/query-0621faf69b1ef866a95f6310c9651875df409a2d2d72ada629bd71e8abdbbf8b.json deleted file mode 100644 index 036e8e4b57..0000000000 --- a/backend/.sqlx/query-0621faf69b1ef866a95f6310c9651875df409a2d2d72ada629bd71e8abdbbf8b.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-0c5558686b4e5a9bacc73c7b6709ad4e3e6e69582605eeb84a93c29d629b94be.json b/backend/.sqlx/query-0c5558686b4e5a9bacc73c7b6709ad4e3e6e69582605eeb84a93c29d629b94be.json new file mode 100644 index 0000000000..0974be4709 --- /dev/null +++ b/backend/.sqlx/query-0c5558686b4e5a9bacc73c7b6709ad4e3e6e69582605eeb84a93c29d629b94be.json @@ -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" +} diff --git a/backend/.sqlx/query-2213dc4b594d27b3788c48b19e13c600224681e11c016eea5cee3086a7bea8d0.json b/backend/.sqlx/query-2213dc4b594d27b3788c48b19e13c600224681e11c016eea5cee3086a7bea8d0.json new file mode 100644 index 0000000000..14f17d871e --- /dev/null +++ b/backend/.sqlx/query-2213dc4b594d27b3788c48b19e13c600224681e11c016eea5cee3086a7bea8d0.json @@ -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" +} diff --git a/backend/.sqlx/query-2dc5a74c4e614b43148925cdbac234f6a2f295a9c46897f5e9aab8f7298ff0d7.json b/backend/.sqlx/query-2dc5a74c4e614b43148925cdbac234f6a2f295a9c46897f5e9aab8f7298ff0d7.json deleted file mode 100644 index 8714711595..0000000000 --- a/backend/.sqlx/query-2dc5a74c4e614b43148925cdbac234f6a2f295a9c46897f5e9aab8f7298ff0d7.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-5ae9ad14effe923f1952d0e1d1f67ad00ff6c13918d46dd474cc48824b12ccaf.json b/backend/.sqlx/query-5ae9ad14effe923f1952d0e1d1f67ad00ff6c13918d46dd474cc48824b12ccaf.json deleted file mode 100644 index 353920fdeb..0000000000 --- a/backend/.sqlx/query-5ae9ad14effe923f1952d0e1d1f67ad00ff6c13918d46dd474cc48824b12ccaf.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-620c9efca071cb5fb8b33857129e63954f39b291f525e36e6ece916b98b4d2c9.json b/backend/.sqlx/query-620c9efca071cb5fb8b33857129e63954f39b291f525e36e6ece916b98b4d2c9.json deleted file mode 100644 index 243c3f5fa7..0000000000 --- a/backend/.sqlx/query-620c9efca071cb5fb8b33857129e63954f39b291f525e36e6ece916b98b4d2c9.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-6ade445fab717d2dadbebfb5747db8b49c98709cfc99a6ee25c29a230bf9ec46.json b/backend/.sqlx/query-6ade445fab717d2dadbebfb5747db8b49c98709cfc99a6ee25c29a230bf9ec46.json new file mode 100644 index 0000000000..0f39dca9d2 --- /dev/null +++ b/backend/.sqlx/query-6ade445fab717d2dadbebfb5747db8b49c98709cfc99a6ee25c29a230bf9ec46.json @@ -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" +} diff --git a/backend/.sqlx/query-7c45f8d05a10ccf538c1b63aa1337e6d0491a2e8d04fe87eddb5a573de00d125.json b/backend/.sqlx/query-7c45f8d05a10ccf538c1b63aa1337e6d0491a2e8d04fe87eddb5a573de00d125.json deleted file mode 100644 index f2bc7955da..0000000000 --- a/backend/.sqlx/query-7c45f8d05a10ccf538c1b63aa1337e6d0491a2e8d04fe87eddb5a573de00d125.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-8058042d5564a934534517e9c0446c5fcc0c3654746c5e65994123ffb4d05f78.json b/backend/.sqlx/query-8058042d5564a934534517e9c0446c5fcc0c3654746c5e65994123ffb4d05f78.json new file mode 100644 index 0000000000..5453ec6899 --- /dev/null +++ b/backend/.sqlx/query-8058042d5564a934534517e9c0446c5fcc0c3654746c5e65994123ffb4d05f78.json @@ -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" +} diff --git a/backend/.sqlx/query-a479cd371fb5d1f52e7c727730cf48ab229e63b8dfe377975d48dcd223251e7c.json b/backend/.sqlx/query-88a6a86285fcfaa778e9716d5072f555dff175feb704c3d825d6100defe91bce.json similarity index 83% rename from backend/.sqlx/query-a479cd371fb5d1f52e7c727730cf48ab229e63b8dfe377975d48dcd223251e7c.json rename to backend/.sqlx/query-88a6a86285fcfaa778e9716d5072f555dff175feb704c3d825d6100defe91bce.json index 6f75beab08..9097eb8584 100644 --- a/backend/.sqlx/query-a479cd371fb5d1f52e7c727730cf48ab229e63b8dfe377975d48dcd223251e7c.json +++ b/backend/.sqlx/query-88a6a86285fcfaa778e9716d5072f555dff175feb704c3d825d6100defe91bce.json @@ -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" } diff --git a/backend/.sqlx/query-897a5b32e0f5890d68017c0c1225475568b02f4f4472dacf3d61e57b56335b69.json b/backend/.sqlx/query-897a5b32e0f5890d68017c0c1225475568b02f4f4472dacf3d61e57b56335b69.json deleted file mode 100644 index bc7de546fe..0000000000 --- a/backend/.sqlx/query-897a5b32e0f5890d68017c0c1225475568b02f4f4472dacf3d61e57b56335b69.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-9e19f8203f97e1159928f9afe36d1c5190b6168ded757adf1791fbb9312b41a3.json b/backend/.sqlx/query-9e19f8203f97e1159928f9afe36d1c5190b6168ded757adf1791fbb9312b41a3.json new file mode 100644 index 0000000000..529b2da15d --- /dev/null +++ b/backend/.sqlx/query-9e19f8203f97e1159928f9afe36d1c5190b6168ded757adf1791fbb9312b41a3.json @@ -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" +} diff --git a/backend/.sqlx/query-d20a717824d89e89969e94b024b11d425cb1dbdd132f3fba16c5fec72dd31490.json b/backend/.sqlx/query-d20a717824d89e89969e94b024b11d425cb1dbdd132f3fba16c5fec72dd31490.json new file mode 100644 index 0000000000..9d389a76c8 --- /dev/null +++ b/backend/.sqlx/query-d20a717824d89e89969e94b024b11d425cb1dbdd132f3fba16c5fec72dd31490.json @@ -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" +} diff --git a/backend/.sqlx/query-e30be7c812aa294df6899892531083221146338193e38ef7268bacff58270bf8.json b/backend/.sqlx/query-e30be7c812aa294df6899892531083221146338193e38ef7268bacff58270bf8.json new file mode 100644 index 0000000000..172054980e --- /dev/null +++ b/backend/.sqlx/query-e30be7c812aa294df6899892531083221146338193e38ef7268bacff58270bf8.json @@ -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" +} diff --git a/backend/.sqlx/query-f2f42aded6f1a400c84e2575abb551dfcd9260714eef333a85b1e5d7d5c9f67e.json b/backend/.sqlx/query-f2f42aded6f1a400c84e2575abb551dfcd9260714eef333a85b1e5d7d5c9f67e.json deleted file mode 100644 index 762a7c81e2..0000000000 --- a/backend/.sqlx/query-f2f42aded6f1a400c84e2575abb551dfcd9260714eef333a85b1e5d7d5c9f67e.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-ff89e9f0941507d5cbfbb27e7c3b1ebad6b2b0a836334a5e20f151711fc11370.json b/backend/.sqlx/query-ff89e9f0941507d5cbfbb27e7c3b1ebad6b2b0a836334a5e20f151711fc11370.json deleted file mode 100644 index 9012e34322..0000000000 --- a/backend/.sqlx/query-ff89e9f0941507d5cbfbb27e7c3b1ebad6b2b0a836334a5e20f151711fc11370.json +++ /dev/null @@ -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" -} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 11167f13cf..7dd3c795aa 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -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", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 49beac0b8a..a6fffe33f4 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -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 "] edition = "2021" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 0dbe521608..1571329708 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -02e02bb245cce368e9366e548f1407a45f2034d8 +9dd772f32775ba8ec21b82897c66d86271495cfd diff --git a/backend/migrations/20260730080304_unify_deploy_to_into_parent.down.sql b/backend/migrations/20260730080304_unify_deploy_to_into_parent.down.sql new file mode 100644 index 0000000000..17d7c46835 --- /dev/null +++ b/backend/migrations/20260730080304_unify_deploy_to_into_parent.down.sql @@ -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; diff --git a/backend/migrations/20260730080304_unify_deploy_to_into_parent.up.sql b/backend/migrations/20260730080304_unify_deploy_to_into_parent.up.sql new file mode 100644 index 0000000000..eca5b95d79 --- /dev/null +++ b/backend/migrations/20260730080304_unify_deploy_to_into_parent.up.sql @@ -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; diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index e3639e30c2..0e4e12eb89 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -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", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 9ee10c7fbb..1cb5004022 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.775.1" +version = "1.775.2" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/src/main.rs b/backend/src/main.rs index aa895df3e7..969fd4e94d 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -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: {}", diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index aeffbc78a1..b47a6556cd 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -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) diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index 9bfb47a36b..094cc71490 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -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 { 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 { + 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, diff --git a/backend/windmill-ai/src/providers/openai.rs b/backend/windmill-ai/src/providers/openai.rs index f33c5728ea..fd28281864 100644 --- a/backend/windmill-ai/src/providers/openai.rs +++ b/backend/windmill-ai/src/providers/openai.rs @@ -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, diff --git a/backend/windmill-ai/src/query_builder.rs b/backend/windmill-ai/src/query_builder.rs index 79fca4bd7d..5a4cecac87 100644 --- a/backend/windmill-ai/src/query_builder.rs +++ b/backend/windmill-ai/src/query_builder.rs @@ -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 { Err(Error::BadRequest(format!( diff --git a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs index e98ee07a61..65a55ba7bc 100644 --- a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs +++ b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs @@ -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 ". 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) -> 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> { + let read = async || -> anyhow::Result> { + 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(), diff --git a/backend/windmill-api-integration-tests/tests/workspaces.rs b/backend/windmill-api-integration-tests/tests/workspaces.rs index a681fd982e..851dcbb4cd 100644 --- a/backend/windmill-api-integration-tests/tests/workspaces.rs +++ b/backend/windmill-api-integration-tests/tests/workspaces.rs @@ -408,18 +408,6 @@ async fn test_workspace_endpoints(db: Pool) -> 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})) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 9501ed96f9..101f772b3d 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -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, #[serde(skip_serializing_if = "Option::is_none")] - pub deploy_to: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub ai_config: Option, #[serde(skip_serializing_if = "Option::is_none")] pub large_file_storage: Option, @@ -392,12 +389,6 @@ struct RunSlackMessageTestJobResponse { job_uuid: String, } -#[cfg(feature = "enterprise")] -#[derive(Deserialize)] -struct EditDeployTo { - deploy_to: Option, -} - #[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, } +/// 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, Extension(user_db): Extension, ) -> JsonResult { 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, - Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, - Json(es): Json, -) -> Result { - 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 { - 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!( diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 8755bc0a05..e2e5df7dbf 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -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)) } diff --git a/backend/windmill-api/openapi-deref.json b/backend/windmill-api/openapi-deref.json index e511578821..fb23829156 100644 --- a/backend/windmill-api/openapi-deref.json +++ b/backend/windmill-api/openapi-deref.json @@ -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 @@ } } } -} \ No newline at end of file +} diff --git a/backend/windmill-api/openapi-deref.yaml b/backend/windmill-api/openapi-deref.yaml index e17c38efaf..afbc03a6c0 100644 --- a/backend/windmill-api/openapi-deref.yaml +++ b/backend/windmill-api/openapi-deref.yaml @@ -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 diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 519bcab57a..40199a1b0d 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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 diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 3621ea0564..1bd8ec536f 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -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); // } diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index c869d87780..a7458e617a 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -449,8 +449,6 @@ struct SimplifiedSettings { auto_invite: Option, #[serde(skip_serializing_if = "Option::is_none")] webhook: Option, - #[serde(skip_serializing_if = "Option::is_none")] - deploy_to: Option, // 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, #[serde(skip_serializing_if = "Option::is_none")] - deploy_to: Option, - #[serde(skip_serializing_if = "Option::is_none")] error_handler: Option, #[serde(skip_serializing_if = "Option::is_none")] error_handler_extra_args: Option, @@ -534,7 +530,6 @@ struct SimplifiedSettingsLegacy { struct SettingsRow { auto_invite: Option, webhook: Option, - deploy_to: Option, error_handler: Option, success_handler: Option, ai_config: Option, @@ -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, diff --git a/backend/windmill-common/tests/ws_specific_versions.rs b/backend/windmill-common/tests/ws_specific_versions.rs new file mode 100644 index 0000000000..23a5df3a92 --- /dev/null +++ b/backend/windmill-common/tests/ws_specific_versions.rs @@ -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, 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) { + 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 = 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"]); +} diff --git a/backend/windmill-git-sync/src/git_sync_oss.rs b/backend/windmill-git-sync/src/git_sync_oss.rs index a4f82aba2e..5cf1995525 100644 --- a/backend/windmill-git-sync/src/git_sync_oss.rs +++ b/backend/windmill-git-sync/src/git_sync_oss.rs @@ -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, diff --git a/backend/windmill-git-sync/src/lib.rs b/backend/windmill-git-sync/src/lib.rs index cf000244e5..1f6e8f5811 100644 --- a/backend/windmill-git-sync/src/lib.rs +++ b/backend/windmill-git-sync/src/lib.rs @@ -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)] diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index c84e58f2a1..c4f83f4c4b 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -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 = || { diff --git a/backend/windmill-queue/src/tags.rs b/backend/windmill-queue/src/tags.rs index d80e50d8b2..46e5d333d4 100644 --- a/backend/windmill-queue/src/tags.rs +++ b/backend/windmill-queue/src/tags.rs @@ -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, 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 = 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) -> 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) -> Option { // 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) -> Option { - 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) -> 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 } diff --git a/backend/windmill-queue/tests/tag_workspace_test.rs b/backend/windmill-queue/tests/tag_workspace_test.rs new file mode 100644 index 0000000000..ca8a84d58d --- /dev/null +++ b/backend/windmill-queue/tests/tag_workspace_test.rs @@ -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, 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) { + 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) { + 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"); +} diff --git a/backend/windmill-runtime-nativets/src/windmill-client.js b/backend/windmill-runtime-nativets/src/windmill-client.js index 432a6e4b7f..7c7d649042 100644 --- a/backend/windmill-runtime-nativets/src/windmill-client.js +++ b/backend/windmill-runtime-nativets/src/windmill-client.js @@ -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. diff --git a/backend/windmill-worker/src/ai/tools.rs b/backend/windmill-worker/src/ai/tools.rs index 1e965ccb3f..8adc3bb180 100644 --- a/backend/windmill-worker/src/ai/tools.rs +++ b/backend/windmill-worker/src/ai/tools.rs @@ -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 { + serde_json::from_str::>(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, 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(¬_an_envelope), None); + } +} diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 2c078e3dd4..ec470fb966 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -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(|_| "".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(|_| "".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(|_| "".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))); } } }; diff --git a/backend/windmill-worker/src/result_processor.rs b/backend/windmill-worker/src/result_processor.rs index 1709e4a150..bf3993bfe6 100644 --- a/backend/windmill-worker/src/result_processor.rs +++ b/backend/windmill-worker/src/result_processor.rs @@ -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> { - 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(); diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index ab7051af08..65b17c21aa 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -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>>, // Option>, @@ -2379,24 +2401,6 @@ pub async fn run_worker( Vec>, ) = (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 = + 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 diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 0793aff053..6bd468e42e 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -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() { diff --git a/backend/windmill-worker/src/worker_lockfiles.rs b/backend/windmill-worker/src/worker_lockfiles.rs index e882bd0c4e..6c6f8ffd77 100644 --- a/backend/windmill-worker/src/worker_lockfiles.rs +++ b/backend/windmill-worker/src/worker_lockfiles.rs @@ -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, + deployment_tallied: &mut bool, ) -> error::Result> { // 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, + deployment_tallied: &mut bool, ) -> error::Result> { 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, + 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( diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index e77bfb67ba..57c877b441 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -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 { return await windmill.UserService.login({ diff --git a/cli/bun.lock b/cli/bun.lock index c783e411c7..c48e0f3f22 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -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=="], diff --git a/cli/package.json b/cli/package.json index 548588a402..12f8e6158f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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", diff --git a/cli/src/commands/app/bundle.ts b/cli/src/commands/app/bundle.ts index c515e1e86e..9987713e4f 100644 --- a/cli/src/commands/app/bundle.ts +++ b/cli/src/commands/app/bundle.ts @@ -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 { + 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 | 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)] }; + } + }); }, }; } diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index bd5add7ecd..59de21e2bf 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -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"; diff --git a/cli/src/core/settings.ts b/cli/src/core/settings.ts index 54e52564d0..66fc77d230 100644 --- a/cli/src/core/settings.ts +++ b/cli/src/core/settings.ts @@ -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) ) { diff --git a/cli/test/push_workspace_settings_name_unit.test.ts b/cli/test/push_workspace_settings_name_unit.test.ts index 22bdf7c677..ef76ab7aec 100644 --- a/cli/test/push_workspace_settings_name_unit.test.ts +++ b/cli/test/push_workspace_settings_name_unit.test.ts @@ -24,7 +24,6 @@ mock.module("../gen/services.gen.ts", () => ({ editAutoInvite: async () => {}, editErrorHandler: async () => {}, editSuccessHandler: async () => {}, - editDeployTo: async () => {}, editCopilotConfig: async () => {}, editLargeFileStorageConfig: async () => {}, editWorkspaceGitSyncConfig: async () => {}, diff --git a/cli/test/raw_app_svelte_compiler_resolution_unit.test.ts b/cli/test/raw_app_svelte_compiler_resolution_unit.test.ts new file mode 100644 index 0000000000..5fbf05384e --- /dev/null +++ b/cli/test/raw_app_svelte_compiler_resolution_unit.test.ts @@ -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); + }); +}); diff --git a/cli/test/raw_app_svelte_module_unit.test.ts b/cli/test/raw_app_svelte_module_unit.test.ts new file mode 100644 index 0000000000..736e048100 --- /dev/null +++ b/cli/test/raw_app_svelte_module_unit.test.ts @@ -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) { + for (const [name, content] of Object.entries(files)) { + fs.writeFileSync(path.join(tempDir, name), content, "utf-8"); + } +} + +async function bundle(entry: string): Promise { + 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 { + #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); + }); +}); diff --git a/cli/test/settings_unit.test.ts b/cli/test/settings_unit.test.ts index bc498964ba..fb5271aa19 100644 --- a/cli/test/settings_unit.test.ts +++ b/cli/test/settings_unit.test.ts @@ -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"); diff --git a/docker/DockerfileFull b/docker/DockerfileFull index 41a9091ae3..6e3f218fd6 100644 --- a/docker/DockerfileFull +++ b/docker/DockerfileFull @@ -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 diff --git a/docker/DockerfileFullEe b/docker/DockerfileFullEe index c53642ca4c..14bfceca50 100644 --- a/docker/DockerfileFullEe +++ b/docker/DockerfileFullEe @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ad6838fa37..4ab6621c30 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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": { diff --git a/frontend/package.json b/frontend/package.json index 4bf61cee55..01e68d2acf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json index 01cfbb2888..dcba3214de 100644 --- a/frontend/scripts/ui_builder_artifact.json +++ b/frontend/scripts/ui_builder_artifact.json @@ -1,5 +1,5 @@ { "baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev", - "version": "76ee616", - "sha256": "1d696d38a152179ef6e1cada5df302d078cc677b6dbc244b5c42335e11feabac" + "version": "2471791", + "sha256": "04048791fb6b2ebca5cc30447e163826cdb143d65cce07a629252e131ac83618" } diff --git a/frontend/src/lib/components/DeployToSetting.svelte b/frontend/src/lib/components/DeployToSetting.svelte index c66f868c31..576027e448 100644 --- a/frontend/src/lib/components/DeployToSetting.svelte +++ b/frontend/src/lib/components/DeployToSetting.svelte @@ -1,22 +1,18 @@ - -