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