mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-30 16:17:25 +00:00
Merge remote-tracking branch 'origin/main' into migrate-session-to-fork
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.851.0"
|
||||
".": "1.853.0"
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ Open-source platform for internal tools, workflows, API integrations, background
|
||||
|
||||
- **Validation**: `docs/validation.md` — what checks to run based on what you changed
|
||||
- **Unreleased SDK changes**: `docs/wac-sdk-e2e.md` — exercising a client change on a real worker
|
||||
- **Performance work**: `docs/perf-profiling.md` — the in-process profiler for the job path
|
||||
(allocation bytes, allocation sites and SQL statements per job), how to A/B a change with it, and
|
||||
its measurement caveats. A perf change ships with before/after numbers from it.
|
||||
- **Agent workers**: `docs/agent-worker-e2e.md` — building and running one locally. An agent
|
||||
reaches the DB only through the API, so `Connection::Http` paths are never taken by a plain
|
||||
`cargo run`; a normal build cannot start one at all.
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
# Changelog
|
||||
|
||||
## [1.853.0](https://github.com/windmill-labs/windmill/compare/v1.852.0...v1.853.0) (2026-10-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* agent to flow, tabbed agent page, one webhook panel with an AI prompt ([#11903](https://github.com/windmill-labs/windmill/issues/11903)) ([2e4c078](https://github.com/windmill-labs/windmill/commit/2e4c0789a25a51ec9fdb05ea6f57e1e70c73e591))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* borrow cached script modules instead of cloning per job ([#12087](https://github.com/windmill-labs/windmill/issues/12087)) ([d03142b](https://github.com/windmill-labs/windmill/commit/d03142ba39f0ad2247ecd867da74890a7681cf85))
|
||||
* build log-mask automaton as a contiguous nfa ([#12088](https://github.com/windmill-labs/windmill/issues/12088)) ([97de072](https://github.com/windmill-labs/windmill/commit/97de072e7ff5f91d5b5494b78c409ce31bfde3b1))
|
||||
|
||||
## [1.852.0](https://github.com/windmill-labs/windmill/compare/v1.851.0...v1.852.0) (2026-10-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* accept the google_service_account resource type alongside gcp_service_account ([#12070](https://github.com/windmill-labs/windmill/issues/12070)) ([e480501](https://github.com/windmill-labs/windmill/commit/e480501af9573c62bbec58d6edef5fc7ffb46433))
|
||||
* add ai review to drafts, draft diffs and session review ([#12063](https://github.com/windmill-labs/windmill/issues/12063)) ([77c0a26](https://github.com/windmill-labs/windmill/commit/77c0a26515bf4405b2e0918910aa8a84eb1a55ea))
|
||||
* gate guest apps by their mode and the protection rule alone ([#12061](https://github.com/windmill-labs/windmill/issues/12061)) ([d3869fc](https://github.com/windmill-labs/windmill/commit/d3869fcdb28a7053644582afe822f3d52d6c2ace))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* check session admin rights in the session's workspace ([#12071](https://github.com/windmill-labs/windmill/issues/12071)) ([09ddf1e](https://github.com/windmill-labs/windmill/commit/09ddf1e0c76fc2a1e488dfdd3be147495c2c37f0))
|
||||
* **cli:** flow preview hub steps and windmill paths, trigger templates ([#12075](https://github.com/windmill-labs/windmill/issues/12075)) ([65f3c5e](https://github.com/windmill-labs/windmill/commit/65f3c5ee5cf0da6a22f770763cf77d5a7bff830d))
|
||||
* give GLM models a 32K output budget, including through Custom AI ([#12068](https://github.com/windmill-labs/windmill/issues/12068)) ([80a49f9](https://github.com/windmill-labs/windmill/commit/80a49f9bd3e8797f0f55aa9482e5ca9ec277cd07))
|
||||
* honor a step's timeout when it is the only step of a loop ([#12058](https://github.com/windmill-labs/windmill/issues/12058)) ([499c756](https://github.com/windmill-labs/windmill/commit/499c7569ffd49131cb6de9588b8691f062c5d91e))
|
||||
* keep a deleted session's data in its components until they unmount ([#12062](https://github.com/windmill-labs/windmill/issues/12062)) ([9c6c201](https://github.com/windmill-labs/windmill/commit/9c6c2018a4fa76cd080744e7f3defaa4bed04d74))
|
||||
* keep a new app's AI session unsaved until it is started ([#12065](https://github.com/windmill-labs/windmill/issues/12065)) ([f46b3a7](https://github.com/windmill-labs/windmill/commit/f46b3a7505158a35794f7ca186aed0e944a909da))
|
||||
* keep retry and branch settings intact when a flow is reserialized ([#12077](https://github.com/windmill-labs/windmill/issues/12077)) ([20def22](https://github.com/windmill-labs/windmill/commit/20def2289753320361ed6c7ab177f36880720499))
|
||||
* rebuild service log index without a hash dedupe that cannot spill ([#12080](https://github.com/windmill-labs/windmill/issues/12080)) ([26888e8](https://github.com/windmill-labs/windmill/commit/26888e834d5ac99d9af26a119bc233ab1bb95a0f))
|
||||
* scope rename's group membership update to the user's workspace ([#12043](https://github.com/windmill-labs/windmill/issues/12043)) ([ad571c6](https://github.com/windmill-labs/windmill/commit/ad571c62262dad9b2211f442d9e94c0a044ac167))
|
||||
* show not deployed yet for undeployed apps in session preview tabs ([#12076](https://github.com/windmill-labs/windmill/issues/12076)) ([bda59e6](https://github.com/windmill-labs/windmill/commit/bda59e6d23eb521b07d3769c54a59a80c7accd0f))
|
||||
* show the server's error when user offboarding fails ([#12069](https://github.com/windmill-labs/windmill/issues/12069)) ([7765913](https://github.com/windmill-labs/windmill/commit/77659130640c28a59f65989d83ab8be7f44e01c3))
|
||||
* wait for access before opening a draft-only item from the picker ([#12064](https://github.com/windmill-labs/windmill/issues/12064)) ([bfd31df](https://github.com/windmill-labs/windmill/commit/bfd31df5d5c96f6187ee5c4efb86a4d9caafafab))
|
||||
|
||||
## [1.851.0](https://github.com/windmill-labs/windmill/compare/v1.850.0...v1.851.0) (2026-10-28)
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n (SELECT MIN(day) FROM guest_activity) AS since,\n (SELECT COUNT(DISTINCT email) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_count!\",\n (SELECT COUNT(DISTINCT email) FROM guest_activity\n WHERE jwt_entry AND day > CURRENT_DATE - 30)::INT AS \"guest_jwt_count!\",\n (SELECT COUNT(DISTINCT workspace_id) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_workspace_count!\",\n (SELECT COUNT(*) FROM workspace_settings ws JOIN workspace w ON w.id = ws.workspace_id\n WHERE ws.guest_access_enabled AND NOT w.deleted)::INT AS \"guest_enabled_workspace_count!\",\n (SELECT COUNT(*) FROM workspace WHERE NOT deleted)::INT AS \"workspace_count!\"\n ",
|
||||
"query": "\n SELECT\n (SELECT MIN(day) FROM guest_activity) AS since,\n (SELECT COUNT(DISTINCT email) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_count!\",\n (SELECT COUNT(DISTINCT email) FROM guest_activity\n WHERE jwt_entry AND day > CURRENT_DATE - 30)::INT AS \"guest_jwt_count!\",\n (SELECT COUNT(DISTINCT workspace_id) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_workspace_count!\",\n (SELECT COUNT(DISTINCT a.workspace_id) FROM app a JOIN workspace w ON w.id = a.workspace_id\n WHERE a.policy->>'execution_mode' = 'guest' AND NOT w.deleted)::INT AS \"guest_enabled_workspace_count!\",\n (SELECT COUNT(*) FROM workspace WHERE NOT deleted)::INT AS \"workspace_count!\"\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -46,5 +46,5 @@
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "06af616fe4fc61a3b0dcd996a2a1e0e9ac63fc8756aeafac8d279841db117eac"
|
||||
"hash": "27aed1f0ee2c6f0ec4f2d7e64a67ce61a985c7c2eed72544acd05316bc002234"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE usr_to_group SET usr = $1 WHERE usr = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2f0de5417088df098a200a67197155e10e909bd02c91082341dd73bb1d6d62e1"
|
||||
}
|
||||
+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, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler) 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, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler FROM workspace_settings WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3ab4e59067a97b74636320f6e237e9481881fd99c5be43a4e58ce95a3baefeb6"
|
||||
}
|
||||
+4
-10
@@ -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 ai_config,\n dbt_warehouses,\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 variable_expiration_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url,\n add_admins_and_developers_to_forks\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 dbt_warehouses,\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 variable_expiration_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_jwt_public_key,\n guest_jwt_jwks_url,\n add_admins_and_developers_to_forks\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -170,21 +170,16 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 33,
|
||||
"name": "guest_access_enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 34,
|
||||
"name": "guest_jwt_public_key",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 35,
|
||||
"ordinal": 34,
|
||||
"name": "guest_jwt_jwks_url",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 36,
|
||||
"ordinal": 35,
|
||||
"name": "add_admins_and_developers_to_forks",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
@@ -228,11 +223,10 @@
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "af37e5579b729a1aedcd63ab70d189c7e0376ca1f0071ae8360183ed1300d2bb"
|
||||
"hash": "3b98b506c544162e630641147490539dc7e9c440cd1625054d6ee1b67a785418"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings SET guest_access_enabled = $1 WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bool",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "76c0331b18eed478a50572e35642909be7dc7eb9b6deac7ea439eb637d728477"
|
||||
}
|
||||
-232
@@ -1,232 +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 ai_config,\n dbt_warehouses,\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 guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url,\n add_admins_and_developers_to_forks\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": "ai_config",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "dbt_warehouses",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"ordinal": 32,
|
||||
"name": "guest_access_enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 33,
|
||||
"name": "guest_jwt_public_key",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 34,
|
||||
"name": "guest_jwt_jwks_url",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 35,
|
||||
"name": "add_admins_and_developers_to_forks",
|
||||
"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,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "8ebe054b41793f1a7b85f1f8d29cd21d12fa1207b58bfc46249c250cdcdb5363"
|
||||
}
|
||||
-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, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler) 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, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler FROM workspace_settings WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9dabc87e05f72fbd4b586c80d9c7f41c3a72838c5d4c5f795638506084561894"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE usr_to_group SET usr = $1 WHERE usr = $2 AND workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "af0bcc6aaf455f75cdf44a503eed708f200a52aaf55c7cf40f7b210f97688c15"
|
||||
}
|
||||
+5
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n guest_access_enabled,\n add_admins_and_developers_to_forks,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n add_admins_and_developers_to_forks,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -40,26 +40,21 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "guest_access_enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "add_admins_and_developers_to_forks",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"ordinal": 8,
|
||||
"name": "deploy_ui",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"ordinal": 9,
|
||||
"name": "large_file_storage",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"ordinal": 10,
|
||||
"name": "datatable",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
@@ -78,11 +73,10 @@
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e6e31fdf705896c81f9a0f45d47c1b93db0406aaf278bccba07be00e9f937e2a"
|
||||
"hash": "d8b38538f21b533eccc147be0a24fdddbfdb63c021a4ec93c371c1ae38cb72be"
|
||||
}
|
||||
-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, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) 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, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f5022831ab610ab22ef1af1441753aba57d5e8f19a316f76e01922559425d81b"
|
||||
}
|
||||
Generated
+86
-86
@@ -1029,11 +1029,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.18.2"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13"
|
||||
checksum = "048006790404103c488e6c5616b3e7e702a71292aadda08e8f7698a9e0d2eb53"
|
||||
dependencies = [
|
||||
"aws-lc-sys 0.46.0",
|
||||
"aws-lc-sys 0.47.0",
|
||||
"untrusted 0.7.1",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -1052,9 +1052,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.46.0"
|
||||
version = "0.47.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc"
|
||||
checksum = "8bfbe32ac36b3c428696ce315464181bc4029d689fd58e3f2a4e58c252242f52"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
@@ -7808,9 +7808,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libz-sys"
|
||||
version = "1.1.30"
|
||||
version = "1.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f710a23e6dbf193214fd46ca56a9d6864e550abe86202184532ae7275e46de19"
|
||||
checksum = "ff20308ecabd635a2dc1dd5834a86c462cca20efba1d5d3ddab1f8aea3455cb2"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -15487,7 +15487,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15577,7 +15577,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-ai"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -15612,7 +15612,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -15625,7 +15625,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -15768,7 +15768,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-agent-workers"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -15792,7 +15792,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -15809,7 +15809,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -15835,7 +15835,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -15845,7 +15845,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -15862,7 +15862,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -15884,7 +15884,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-embeddings"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -15907,7 +15907,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -15923,7 +15923,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -15945,7 +15945,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -15967,7 +15967,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -15981,7 +15981,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -16017,7 +16017,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -16043,7 +16043,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -16071,7 +16071,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-openapi"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -16093,7 +16093,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-schedule"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -16113,7 +16113,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -16155,7 +16155,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -16185,7 +16185,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -16197,7 +16197,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum",
|
||||
@@ -16221,7 +16221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -16236,7 +16236,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -16275,7 +16275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -16290,7 +16290,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -16309,7 +16309,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@@ -16427,7 +16427,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -16447,7 +16447,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -16464,7 +16464,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -16491,7 +16491,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -16508,7 +16508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -16524,7 +16524,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16546,7 +16546,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16577,7 +16577,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -16602,7 +16602,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -16638,7 +16638,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -16657,7 +16657,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -16666,7 +16666,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16678,7 +16678,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16690,7 +16690,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -16702,7 +16702,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16714,7 +16714,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16726,7 +16726,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -16737,7 +16737,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16748,7 +16748,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16760,7 +16760,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ruff_python_ast",
|
||||
@@ -16771,7 +16771,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16794,7 +16794,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16806,7 +16806,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16820,7 +16820,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -16837,7 +16837,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16850,7 +16850,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -16862,7 +16862,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16880,7 +16880,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -16896,7 +16896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ruff_python_ast",
|
||||
@@ -16913,7 +16913,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16927,7 +16927,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16966,7 +16966,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -17004,7 +17004,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"wasm-bindgen",
|
||||
@@ -17015,7 +17015,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -17050,7 +17050,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-test-utils"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17074,7 +17074,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17107,7 +17107,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-amqp"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17134,7 +17134,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-azure"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17167,7 +17167,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17187,7 +17187,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17221,7 +17221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17258,7 +17258,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17281,7 +17281,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17305,7 +17305,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -17329,7 +17329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17364,7 +17364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17392,7 +17392,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17417,7 +17417,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.2",
|
||||
@@ -17436,7 +17436,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
@@ -17554,7 +17554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker-volumes"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -88,7 +88,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
1443dd8902e3baf3271c089edfb31808f594ba6c
|
||||
e7c9f267da4343dde009759d9c8330c795b9028f
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Which apps were moved to `publisher` is not recorded, so there is nothing to restore.
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Guest apps are now gated by their execution mode alone. An app stored as `guest` where the
|
||||
-- workspace switch was off, or the instance switch was set, admitted no guest; leave it
|
||||
-- admitting none by moving it to `publisher`, which runs the same way for members.
|
||||
UPDATE app
|
||||
SET policy = jsonb_set(policy, '{execution_mode}', '"publisher"')
|
||||
FROM workspace_settings ws
|
||||
WHERE ws.workspace_id = app.workspace_id
|
||||
AND app.policy->>'execution_mode' = 'guest'
|
||||
AND (
|
||||
NOT ws.guest_access_enabled
|
||||
OR COALESCE(
|
||||
(SELECT (value #>> '{}')::boolean FROM global_settings WHERE name = 'guest_access_disabled'),
|
||||
false
|
||||
)
|
||||
);
|
||||
+24
-24
@@ -6340,7 +6340,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@@ -6430,7 +6430,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6442,7 +6442,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6451,7 +6451,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6463,7 +6463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6475,7 +6475,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6487,7 +6487,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6499,7 +6499,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6511,7 +6511,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6522,7 +6522,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6533,7 +6533,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6545,7 +6545,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ruff_python_ast",
|
||||
@@ -6556,7 +6556,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6579,7 +6579,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6591,7 +6591,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6605,7 +6605,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6622,7 +6622,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6635,7 +6635,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6647,7 +6647,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6665,7 +6665,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6681,7 +6681,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ruff_python_ast",
|
||||
@@ -6698,7 +6698,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6730,7 +6730,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6744,7 +6744,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.851.0"
|
||||
version = "1.853.0"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
@@ -57,13 +57,6 @@ async fn the_allowance_caps_strangers_and_meters_an_enterprise_plan(
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_access")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "guest_access_enabled": true }))
|
||||
.send()
|
||||
.await?;
|
||||
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
|
||||
.json(&json!({
|
||||
"path": APP_PATH,
|
||||
|
||||
@@ -32,20 +32,6 @@ async fn the_cloud_admits_no_guest(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
// The workspace switch cannot be turned on, so no policy can lean on it.
|
||||
let resp = authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_access")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "guest_access_enabled": true }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400);
|
||||
assert!(
|
||||
resp.text().await?.contains("self-hosted"),
|
||||
"the refusal must name what guests need"
|
||||
);
|
||||
|
||||
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
|
||||
.json(&json!({
|
||||
"path": APP_PATH,
|
||||
@@ -56,6 +42,10 @@ async fn the_cloud_admits_no_guest(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400, "an app cannot be deployed to guests");
|
||||
assert!(
|
||||
resp.text().await?.contains("self-hosted"),
|
||||
"the refusal must name what guests need"
|
||||
);
|
||||
|
||||
// Nor can a key be configured for the JWT way in — the refusal lands before the
|
||||
// outbound JWKS fetch it would otherwise make.
|
||||
@@ -96,9 +86,6 @@ async fn the_cloud_admits_no_guest(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.bind(APP_PATH)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query("UPDATE workspace_settings SET guest_access_enabled = true WHERE workspace_id = 'test-workspace'")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Deploying it again is not refused: only widening an app into guests is, so a
|
||||
// git-sync push of one already stored that way keeps working (and keeps being inert).
|
||||
|
||||
@@ -35,19 +35,6 @@ fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuil
|
||||
builder.header("Authorization", format!("Bearer {}", token))
|
||||
}
|
||||
|
||||
async fn enable_guests(port: u16, ws: &str) -> anyhow::Result<()> {
|
||||
authed(
|
||||
client().post(format!(
|
||||
"http://localhost:{port}/api/w/{ws}/workspaces/edit_guest_access"
|
||||
)),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "guest_access_enabled": true }))
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn guest_scopes() -> Vec<String> {
|
||||
vec![
|
||||
"guest".to_string(),
|
||||
@@ -86,7 +73,6 @@ async fn guest_session_is_confined_to_its_app(db: Pool<Postgres>) -> anyhow::Res
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
enable_guests(port, "test-workspace").await?;
|
||||
insert_guest_token(&db, "test-workspace").await?;
|
||||
|
||||
// Its own identity resolves, and reports the role rather than falling through to
|
||||
@@ -166,9 +152,7 @@ async fn guest_token_does_not_cross_workspaces(db: Pool<Postgres>) -> anyhow::Re
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn guest_entry_needs_both_the_app_mode_and_the_workspace_switch(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
async fn guest_entry_follows_the_app_mode(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
@@ -194,25 +178,6 @@ async fn guest_entry_needs_both_the_app_mode_and_the_workspace_switch(
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
// The app says guest, the workspace has not opted in: inert.
|
||||
let resp = client()
|
||||
.get(format!("{ws}/apps_u/guest_entry/{secret}"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
404,
|
||||
"a guest app in a workspace that has not enabled guests must not advertise entry"
|
||||
);
|
||||
|
||||
authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_access")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "guest_access_enabled": true }))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Unauthenticated on purpose: this is what a signed-out visitor reads.
|
||||
let resp = client()
|
||||
.get(format!("{ws}/apps_u/guest_entry/{secret}"))
|
||||
@@ -222,15 +187,15 @@ async fn guest_entry_needs_both_the_app_mode_and_the_workspace_switch(
|
||||
let entry: serde_json::Value = resp.json().await?;
|
||||
assert_eq!(entry["app_path"], json!(APP_PATH));
|
||||
|
||||
// Turning the switch back off closes the door again even though the app's own
|
||||
// policy is unchanged.
|
||||
authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_access")),
|
||||
// Back to members only: the door closes.
|
||||
let resp = authed(
|
||||
client().post(format!("{ws}/apps/update/{APP_PATH}")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "guest_access_enabled": false }))
|
||||
.json(&json!({ "policy": { "execution_mode": "publisher", "triggerables": {} } }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
let resp = client()
|
||||
.get(format!("{ws}/apps_u/guest_entry/{secret}"))
|
||||
.send()
|
||||
@@ -238,7 +203,7 @@ async fn guest_entry_needs_both_the_app_mode_and_the_workspace_switch(
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
404,
|
||||
"turning guests off must stop advertising entry for an app already set to guest"
|
||||
"an app taken out of guest mode must stop advertising entry"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -363,12 +328,11 @@ fn execute(port: u16, ws: &str, app: &str, token: &str) -> reqwest::RequestBuild
|
||||
}))
|
||||
}
|
||||
|
||||
/// The workspace switch is enforced at the auth door for every guest request, not
|
||||
/// remembered per handler. This is what stands between a `guest` policy pushed by
|
||||
/// git-sync and execution once an admin has turned guests off — and it closes the
|
||||
/// app to sessions already issued.
|
||||
/// Taking an app out of guest mode is what closes it to guests, sessions already issued
|
||||
/// included: the mode is re-read on every run, not when the session was minted. This is
|
||||
/// what stands between a guest and an app an admin has closed again.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn the_door_re_checks_the_workspace_switch(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
async fn leaving_guest_mode_closes_issued_sessions(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
@@ -381,38 +345,40 @@ async fn the_door_re_checks_the_workspace_switch(db: Pool<Postgres>) -> anyhow::
|
||||
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
|
||||
insert_guest_token(&db, "test-workspace").await?;
|
||||
|
||||
// Switch off: the session does not authenticate at all, even though the app's
|
||||
// policy says guest and the session was (in this fixture) issued regardless. On
|
||||
// the authed route that is a 401; on the optional-auth run route the rejected
|
||||
// token reads as no token, and a guest-mode app then refuses the anonymous
|
||||
// caller — a denial either way.
|
||||
let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"a guest must not authenticate while guests are off"
|
||||
);
|
||||
let resp = execute(port, "test-workspace", APP_PATH, GUEST_TOKEN)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_client_error() && resp.status() != 404,
|
||||
"a guest must not run while guests are off, got {}",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
// Switch on: through the door. What follows the run is the runnable lookup,
|
||||
// which fails on the nonexistent script — the point is that it is no longer a
|
||||
// denial.
|
||||
enable_guests(port, "test-workspace").await?;
|
||||
// In guest mode: through the door. What follows the run is the runnable lookup,
|
||||
// which fails on the nonexistent script; the point is that it is not a denial.
|
||||
let resp = execute(port, "test-workspace", APP_PATH, GUEST_TOKEN)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status() != 401 && resp.status() != 403,
|
||||
"with guests on, the door must let the run through: {}",
|
||||
"a guest must reach its guest-mode app: {}",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
let resp = authed(
|
||||
client().post(format!("{ws}/apps/update/{APP_PATH}")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&app_with_runnable(APP_PATH, "publisher", false))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
// Evict the cached policy as the server's notify poller would.
|
||||
for e in windmill_common::notify_events::poll_notify_events(&db, 0).await? {
|
||||
if e.channel == "notify_app_policy_change" {
|
||||
if let Some((ws, path)) = e.payload.split_once(':') {
|
||||
windmill_api::invalidate_app_policy_cache(ws, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resp = execute(port, "test-workspace", APP_PATH, GUEST_TOKEN)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status() == 401 || resp.status() == 403,
|
||||
"an issued guest session must not run an app taken out of guest mode: {}",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
@@ -428,13 +394,6 @@ async fn guest_cannot_run_another_guest_app(db: Pool<Postgres>) -> anyhow::Resul
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_access")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "guest_access_enabled": true }))
|
||||
.send()
|
||||
.await?;
|
||||
let other = "u/test-user/other_guest_app";
|
||||
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
|
||||
.json(&guest_app_with_runnable(other, false))
|
||||
@@ -510,7 +469,6 @@ async fn a_guest_cannot_read_a_job_it_did_not_launch(db: Pool<Postgres>) -> anyh
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
enable_guests(port, "test-workspace").await?;
|
||||
insert_guest_token(&db, "test-workspace").await?;
|
||||
let resp = authed(client().post(format!("{ws}/scripts/create")), ADMIN_TOKEN)
|
||||
.json(&json!({
|
||||
@@ -611,15 +569,14 @@ async fn guests_mode_needs_a_scopable_path(db: Pool<Postgres>) -> anyhow::Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Renaming a workspace copies its settings; the guest switch and the guest JWT key must
|
||||
/// travel with them, or the rename silently shuts every guest app or drops the key.
|
||||
/// Renaming a workspace copies its settings; the guest JWT key must travel with them,
|
||||
/// or the rename silently drops the key.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn a_workspace_rename_keeps_the_guest_switch(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
async fn a_workspace_rename_keeps_the_guest_jwt_key(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
enable_guests(port, "test-workspace").await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO guest_activity (email, workspace_id, day)
|
||||
VALUES ('guest@example.com', 'test-workspace', CURRENT_DATE)",
|
||||
@@ -641,12 +598,6 @@ async fn a_workspace_rename_keeps_the_guest_switch(db: Pool<Postgres>) -> anyhow
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
let enabled: bool = sqlx::query_scalar(
|
||||
"SELECT guest_access_enabled FROM workspace_settings WHERE workspace_id = 'test-workspace-2'",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(enabled, "the guest switch travels with the workspace");
|
||||
let jwt_key: Option<String> = sqlx::query_scalar(
|
||||
"SELECT guest_jwt_public_key FROM workspace_settings WHERE workspace_id = 'test-workspace-2'",
|
||||
)
|
||||
@@ -668,70 +619,6 @@ async fn a_workspace_rename_keeps_the_guest_switch(db: Pool<Postgres>) -> anyhow
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The superadmin switch sits above every workspace's: off, no guest session stands and
|
||||
/// no app discovers as open, whatever the workspace and the app say.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn the_instance_switch_closes_every_workspace(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
enable_guests(port, "test-workspace").await?;
|
||||
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
|
||||
.json(&guest_app_with_runnable(APP_PATH, false))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
|
||||
insert_guest_token(&db, "test-workspace").await?;
|
||||
let set_instance_switch = |disabled: bool| {
|
||||
authed(
|
||||
client().post(format!(
|
||||
"http://localhost:{port}/api/settings/global/guest_access_disabled"
|
||||
)),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "value": disabled }))
|
||||
.send()
|
||||
};
|
||||
|
||||
let secret: String = authed(
|
||||
client().get(format!("{ws}/apps/secret_of/{APP_PATH}")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
set_instance_switch(true).await?.error_for_status()?;
|
||||
let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"the instance switch closes an issued session"
|
||||
);
|
||||
let resp = client()
|
||||
.get(format!("{ws}/apps_u/guest_entry/{secret}"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
404,
|
||||
"and nothing discovers as open to guests"
|
||||
);
|
||||
|
||||
set_instance_switch(false).await?.error_for_status()?;
|
||||
let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "back on, the session stands again");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An account holder is never a guest, and that holds after the mint too: a session
|
||||
/// minted before the account existed ends at the door the moment one does, so an
|
||||
/// account provisioned in a race with the mint cannot outlive the rule.
|
||||
@@ -742,7 +629,6 @@ async fn an_account_ends_the_guest_session(db: Pool<Postgres>) -> anyhow::Result
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
enable_guests(port, "test-workspace").await?;
|
||||
insert_guest_token(&db, "test-workspace").await?;
|
||||
let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN)
|
||||
.send()
|
||||
@@ -778,7 +664,6 @@ async fn a_guest_cannot_upload_outside_a_policy(db: Pool<Postgres>) -> anyhow::R
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
enable_guests(port, "test-workspace").await?;
|
||||
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
|
||||
.json(&guest_app_with_runnable(APP_PATH, false)) // no `s3_inputs`
|
||||
.send()
|
||||
@@ -825,7 +710,6 @@ async fn a_guest_uses_an_anonymous_app_as_itself(db: Pool<Postgres>) -> anyhow::
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
enable_guests(port, "test-workspace").await?;
|
||||
let resp = authed(client().post(format!("{ws}/scripts/create")), ADMIN_TOKEN)
|
||||
.json(&json!({
|
||||
"path": "u/test-user/noop",
|
||||
@@ -878,7 +762,6 @@ async fn a_guest_minted_embed_token_stays_a_guest(db: Pool<Postgres>) -> anyhow:
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
enable_guests(port, "test-workspace").await?;
|
||||
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
|
||||
.json(&guest_app_with_runnable(APP_PATH, true))
|
||||
.send()
|
||||
@@ -937,13 +820,13 @@ async fn a_guest_minted_embed_token_stays_a_guest(db: Pool<Postgres>) -> anyhow:
|
||||
let me: serde_json::Value = resp.json().await?;
|
||||
assert_eq!(me["role"], json!("guest"));
|
||||
|
||||
// Governed: the workspace switch closes it at the door, iframe or not.
|
||||
authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_access")),
|
||||
ADMIN_TOKEN,
|
||||
// Governed: the door's no-account rule closes it, iframe or not, once the guest's
|
||||
// email is promoted to an account.
|
||||
sqlx::query(
|
||||
"INSERT INTO password (email, password_hash, login_type, super_admin, verified, name)
|
||||
VALUES ('guest@example.com', 'not-a-real-hash', 'password', false, true, 'Guest')",
|
||||
)
|
||||
.json(&json!({ "guest_access_enabled": false }))
|
||||
.send()
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = authed(client().get(format!("{ws}/users/whoami")), &embed)
|
||||
.send()
|
||||
@@ -951,7 +834,7 @@ async fn a_guest_minted_embed_token_stays_a_guest(db: Pool<Postgres>) -> anyhow:
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"turning guests off must stop a guest's embed token authenticating"
|
||||
"an account for the guest's email must stop its embed token authenticating"
|
||||
);
|
||||
let resp = execute(port, "test-workspace", APP_PATH, &embed)
|
||||
.send()
|
||||
@@ -961,7 +844,6 @@ async fn a_guest_minted_embed_token_stays_a_guest(db: Pool<Postgres>) -> anyhow:
|
||||
"and running components, got {}",
|
||||
resp.status()
|
||||
);
|
||||
enable_guests(port, "test-workspace").await?;
|
||||
|
||||
// And its scopes are not something the guest's email can later rewrite. The
|
||||
// guest session itself cannot reach `/users/*` (workspace pin), so model the real
|
||||
@@ -1006,7 +888,6 @@ async fn a_guest_label_is_governed_without_the_sentinel(db: Pool<Postgres>) -> a
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
enable_guests(port, "test-workspace").await?;
|
||||
let scopes: Vec<String> = guest_scopes()
|
||||
.into_iter()
|
||||
.filter(|s| s != "guest")
|
||||
@@ -1051,13 +932,6 @@ async fn a_disabled_account_cannot_become_a_guest(db: Pool<Postgres>) -> anyhow:
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_access")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "guest_access_enabled": true }))
|
||||
.send()
|
||||
.await?;
|
||||
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
|
||||
.json(&guest_app_with_runnable(APP_PATH, false))
|
||||
.send()
|
||||
|
||||
@@ -67,13 +67,6 @@ async fn a_guest_jwt_is_capped_like_a_signed_in_guest(db: Pool<Postgres>) -> any
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_access")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "guest_access_enabled": true }))
|
||||
.send()
|
||||
.await?;
|
||||
let resp = authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_jwt_key")),
|
||||
ADMIN_TOKEN,
|
||||
@@ -109,7 +102,11 @@ async fn a_guest_jwt_is_capped_like_a_signed_in_guest(db: Pool<Postgres>) -> any
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 401, "a stranger's JWT is refused past the cap");
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"a stranger's JWT is refused past the cap"
|
||||
);
|
||||
|
||||
let resp = authed(
|
||||
client().get(format!("{ws}/users/whoami")),
|
||||
|
||||
@@ -85,20 +85,6 @@ fn bearer(claims: &Claims, priv_pem: &str, alg: Algorithm) -> String {
|
||||
format!("jwt_guest_{jwt}")
|
||||
}
|
||||
|
||||
async fn enable_guests(port: u16, ws: &str, on: bool) -> anyhow::Result<()> {
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"http://localhost:{port}/api/w/{ws}/workspaces/edit_guest_access"
|
||||
)),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "guest_access_enabled": on }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_guest_jwt_pem(port: u16, ws: &str, pem: &str) -> anyhow::Result<()> {
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
@@ -157,7 +143,6 @@ async fn a_valid_guest_jwt_opens_its_app(db: Pool<Postgres>) -> anyhow::Result<(
|
||||
let port = server.addr.port();
|
||||
let ws = "test-workspace";
|
||||
|
||||
enable_guests(port, ws, true).await?;
|
||||
set_guest_jwt_pem(port, ws, PUB1).await?;
|
||||
let resp = authed(
|
||||
client().post(format!("http://localhost:{port}/api/w/{ws}/scripts/create")),
|
||||
@@ -239,7 +224,6 @@ async fn guest_jwt_refusals(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let port = server.addr.port();
|
||||
let ws = "test-workspace";
|
||||
|
||||
enable_guests(port, ws, true).await?;
|
||||
set_guest_jwt_pem(port, ws, PUB1).await?;
|
||||
create_app(port, ws, app(APP_PATH, "guest", false)).await?;
|
||||
create_app(port, ws, app("u/test-user/members_app", "publisher", false)).await?;
|
||||
@@ -346,48 +330,6 @@ async fn guest_jwt_refusals(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The workspace switch gates a JWT guest exactly as it gates a signed-in one, at the
|
||||
/// auth door, so turning guests off closes the JWT entry too.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn guest_jwt_needs_the_workspace_switch(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let ws = "test-workspace";
|
||||
|
||||
set_guest_jwt_pem(port, ws, PUB1).await?;
|
||||
create_app(port, ws, app(APP_PATH, "guest", false)).await?;
|
||||
let token = bearer(&Claims::valid(), PRIV1, Algorithm::ES256);
|
||||
|
||||
// Switch off (the default): refused.
|
||||
let resp = whoami(port, ws, &token).send().await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"a JWT guest must be refused while guests are off"
|
||||
);
|
||||
|
||||
// Switch on: through.
|
||||
enable_guests(port, ws, true).await?;
|
||||
let resp = whoami(port, ws, &token).send().await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"with guests on, the JWT guest is admitted"
|
||||
);
|
||||
|
||||
// Off again: closed on the next request.
|
||||
enable_guests(port, ws, false).await?;
|
||||
let resp = whoami(port, ws, &token).send().await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"turning guests off closes the JWT guest again"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A guest JWT is pinned to the workspace its claim names, so it authenticates on no
|
||||
/// workspace-less route: the arm has no workspace to check the claim against.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
@@ -397,7 +339,6 @@ async fn guest_jwt_rejected_on_workspaceless_route(db: Pool<Postgres>) -> anyhow
|
||||
let port = server.addr.port();
|
||||
let ws = "test-workspace";
|
||||
|
||||
enable_guests(port, ws, true).await?;
|
||||
set_guest_jwt_pem(port, ws, PUB1).await?;
|
||||
create_app(port, ws, app(APP_PATH, "guest", false)).await?;
|
||||
let token = bearer(&Claims::valid(), PRIV1, Algorithm::ES256);
|
||||
@@ -427,7 +368,6 @@ async fn a_guest_jwt_derived_embed_token_is_capped(db: Pool<Postgres>) -> anyhow
|
||||
let port = server.addr.port();
|
||||
let ws = "test-workspace";
|
||||
|
||||
enable_guests(port, ws, true).await?;
|
||||
set_guest_jwt_pem(port, ws, PUB1).await?;
|
||||
create_app(port, ws, app(APP_PATH, "guest", true)).await?;
|
||||
let secret: String = authed(
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
//! Per-job cost profiler: allocation bytes, allocation sites and SQL statements per job, for
|
||||
//! jobs run end to end through the real queue, worker and database in this process.
|
||||
//!
|
||||
//! Not a regression test: every test is `#[ignore]`d and asserts only that the jobs succeeded.
|
||||
//! How to run it, read it and A/B a change: `docs/perf-profiling.md`.
|
||||
//!
|
||||
//! Each workload warms up, measures the idle background rate, pushes `PERF_N` jobs scheduled a
|
||||
//! few seconds ahead (so the push is measured alone), then measures the worker until all of them
|
||||
//! complete. Reports land in `$PERF_OUT/<workload>.txt`.
|
||||
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::cell::Cell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering::Relaxed};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use futures::StreamExt;
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use uuid::Uuid;
|
||||
use windmill_common::flows::FlowValue;
|
||||
use windmill_common::jobs::JobPayload;
|
||||
use windmill_common::runnable_settings::{ConcurrencySettings, DebouncingSettings};
|
||||
use windmill_common::scripts::{ScriptHash, ScriptLang};
|
||||
use windmill_common::worker::Connection;
|
||||
use windmill_test_utils::*;
|
||||
|
||||
// ---------------------------------------------------------------- allocator
|
||||
|
||||
struct Profiler;
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: Profiler = Profiler;
|
||||
|
||||
static BYTES: AtomicU64 = AtomicU64::new(0);
|
||||
static COUNT: AtomicU64 = AtomicU64::new(0);
|
||||
static SAMPLING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
const BYTE_PERIOD: u64 = 128 * 1024;
|
||||
const COUNT_PERIOD: u64 = 2048;
|
||||
|
||||
thread_local! {
|
||||
// Set while a sample is being captured: the backtrace's own allocations are neither
|
||||
// counted nor sampled, or they would dominate every number.
|
||||
static IN_SAMPLER: Cell<bool> = const { Cell::new(false) };
|
||||
static BYTES_LEFT: Cell<u64> = const { Cell::new(BYTE_PERIOD) };
|
||||
static COUNT_LEFT: Cell<u64> = const { Cell::new(COUNT_PERIOD) };
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct Site {
|
||||
byte_samples: u64,
|
||||
count_samples: u64,
|
||||
}
|
||||
|
||||
static SITES_LEAF: Mutex<Option<HashMap<String, Site>>> = Mutex::new(None);
|
||||
static SITES_CHAIN: Mutex<Option<HashMap<String, Site>>> = Mutex::new(None);
|
||||
|
||||
unsafe impl GlobalAlloc for Profiler {
|
||||
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
|
||||
record(l.size());
|
||||
System.alloc(l)
|
||||
}
|
||||
unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 {
|
||||
record(l.size());
|
||||
System.alloc_zeroed(l)
|
||||
}
|
||||
unsafe fn realloc(&self, p: *mut u8, l: Layout, n: usize) -> *mut u8 {
|
||||
record(n);
|
||||
System.realloc(p, l, n)
|
||||
}
|
||||
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
|
||||
System.dealloc(p, l)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn record(size: usize) {
|
||||
if IN_SAMPLER.try_with(|i| i.get()).unwrap_or(true) {
|
||||
return;
|
||||
}
|
||||
BYTES.fetch_add(size as u64, Relaxed);
|
||||
COUNT.fetch_add(1, Relaxed);
|
||||
if !SAMPLING.load(Relaxed) {
|
||||
return;
|
||||
}
|
||||
let size = size as u64;
|
||||
let byte_hits = BYTES_LEFT.with(|b| {
|
||||
let left = b.get();
|
||||
if size >= left {
|
||||
let over = size - left;
|
||||
b.set(BYTE_PERIOD - over % BYTE_PERIOD);
|
||||
1 + over / BYTE_PERIOD
|
||||
} else {
|
||||
b.set(left - size);
|
||||
0
|
||||
}
|
||||
});
|
||||
let count_hit = COUNT_LEFT.with(|c| {
|
||||
let left = c.get();
|
||||
c.set(if left <= 1 { COUNT_PERIOD } else { left - 1 });
|
||||
left <= 1
|
||||
});
|
||||
if byte_hits > 0 || count_hit {
|
||||
IN_SAMPLER.with(|i| i.set(true));
|
||||
capture(byte_hits, count_hit);
|
||||
IN_SAMPLER.with(|i| i.set(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// Attributes a sample to the deepest frame in a Windmill crate. Dev builds use split DWARF and
|
||||
/// inline aggressively, so frame *names* are often bare (`poll<…>`); the relative
|
||||
/// `./windmill-*/src/…:line` location is what identifies the site.
|
||||
fn capture(byte_hits: u64, count_hit: bool) {
|
||||
let bt = std::backtrace::Backtrace::force_capture().to_string();
|
||||
let mut frames: Vec<(String, String)> = vec![];
|
||||
let mut lines = bt.lines().peekable();
|
||||
while let Some(l) = lines.next() {
|
||||
let Some((num, name)) = l.trim_start().split_once(": ") else {
|
||||
continue;
|
||||
};
|
||||
if !num.chars().all(|c| c.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
let loc = match lines.peek() {
|
||||
Some(n) if n.trim_start().starts_with("at ") => {
|
||||
lines.next().unwrap().trim_start()[3..].to_string()
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
frames.push((name.to_string(), loc));
|
||||
}
|
||||
let is_windmill = |(_, loc): &(String, String)| {
|
||||
(loc.starts_with("./windmill-") || loc.starts_with("./src/"))
|
||||
&& !loc.starts_with("./windmill-test-utils")
|
||||
};
|
||||
let Some(first) = frames.iter().position(is_windmill) else {
|
||||
return;
|
||||
};
|
||||
let short_loc = |l: &str| -> String {
|
||||
let l = l.trim_start_matches("./");
|
||||
l.rfind(':').map_or(l, |i| &l[..i]).to_string()
|
||||
};
|
||||
const PLUMBING: &[&str] = &[
|
||||
"alloc::",
|
||||
"<alloc::",
|
||||
"__rust",
|
||||
"perf_profile",
|
||||
"<perf_profile",
|
||||
"try_with",
|
||||
"capture",
|
||||
"record",
|
||||
"reserve",
|
||||
"finish_grow",
|
||||
"grow",
|
||||
"do_reserve",
|
||||
"with_capacity",
|
||||
"allocate",
|
||||
"new_uninit",
|
||||
"exchange_malloc",
|
||||
"box_new",
|
||||
"realloc",
|
||||
];
|
||||
let callee: String = frames[..first]
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|f| f.0.as_str())
|
||||
.find(|n| !PLUMBING.iter().any(|p| n.starts_with(p)))
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.take(100)
|
||||
.collect();
|
||||
let leaf = format!("{} [-> {callee}]", short_loc(&frames[first].1));
|
||||
let chain = frames[first..]
|
||||
.iter()
|
||||
.filter(|f| is_windmill(f))
|
||||
.map(|f| short_loc(&f.1))
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" < ");
|
||||
let chain = format!("{chain} [-> {callee}]");
|
||||
for (map, key) in [(&SITES_LEAF, leaf), (&SITES_CHAIN, chain)] {
|
||||
let mut g = map.lock().unwrap();
|
||||
let e = g.get_or_insert_with(HashMap::new).entry(key).or_default();
|
||||
e.byte_samples += byte_hits;
|
||||
e.count_samples += count_hit as u64;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sql counting
|
||||
|
||||
static SQL_ON: AtomicBool = AtomicBool::new(false);
|
||||
static SQL: Mutex<Option<HashMap<String, (u64, f64)>>> = Mutex::new(None);
|
||||
|
||||
struct SqlLayer;
|
||||
|
||||
#[derive(Default)]
|
||||
struct SqlVisit {
|
||||
statement: String,
|
||||
summary: String,
|
||||
secs: f64,
|
||||
}
|
||||
|
||||
impl tracing::field::Visit for SqlVisit {
|
||||
fn record_str(&mut self, f: &tracing::field::Field, v: &str) {
|
||||
match f.name() {
|
||||
"db.statement" => self.statement = v.to_string(),
|
||||
"summary" => self.summary = v.to_string(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn record_f64(&mut self, f: &tracing::field::Field, v: f64) {
|
||||
if f.name() == "elapsed_secs" {
|
||||
self.secs = v;
|
||||
}
|
||||
}
|
||||
fn record_debug(&mut self, f: &tracing::field::Field, v: &dyn std::fmt::Debug) {
|
||||
match f.name() {
|
||||
"db.statement" => self.statement = format!("{v:?}"),
|
||||
"summary" => self.summary = format!("{v:?}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for SqlLayer {
|
||||
fn on_event(&self, ev: &tracing::Event<'_>, _: tracing_subscriber::layer::Context<'_, S>) {
|
||||
if !SQL_ON.load(Relaxed) {
|
||||
return;
|
||||
}
|
||||
let mut v = SqlVisit::default();
|
||||
ev.record(&mut v);
|
||||
// sqlx leaves `db.statement` empty when the summary already is the whole statement
|
||||
let raw = if v.statement.trim().is_empty() {
|
||||
v.summary
|
||||
} else {
|
||||
v.statement
|
||||
};
|
||||
let key: String = raw
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.chars()
|
||||
.take(200)
|
||||
.collect();
|
||||
let mut g = SQL.lock().unwrap();
|
||||
let e = g.get_or_insert_with(HashMap::new).entry(key).or_default();
|
||||
e.0 += 1;
|
||||
e.1 += v.secs;
|
||||
}
|
||||
}
|
||||
|
||||
/// SQL counting is a separate mode (`PERF_SQL=1`): sqlx formats a query summary for every
|
||||
/// statement once its event is enabled, which inflates the allocation numbers.
|
||||
fn sql_mode() -> bool {
|
||||
std::env::var("PERF_SQL").is_ok_and(|v| !v.is_empty() && v != "0")
|
||||
}
|
||||
|
||||
fn init_sql_counting() {
|
||||
use tracing_subscriber::prelude::*;
|
||||
if !sql_mode() {
|
||||
return;
|
||||
}
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
let filter = tracing_subscriber::filter::Targets::new()
|
||||
.with_target("sqlx::query", tracing::Level::TRACE);
|
||||
let _ = tracing::subscriber::set_global_default(
|
||||
tracing_subscriber::registry().with(SqlLayer.with_filter(filter)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- measurement
|
||||
|
||||
struct Snap {
|
||||
bytes: u64,
|
||||
count: u64,
|
||||
at: Instant,
|
||||
}
|
||||
|
||||
fn start_phase() -> Snap {
|
||||
*SITES_LEAF.lock().unwrap() = None;
|
||||
*SITES_CHAIN.lock().unwrap() = None;
|
||||
*SQL.lock().unwrap() = None;
|
||||
SAMPLING.store(true, Relaxed);
|
||||
SQL_ON.store(true, Relaxed);
|
||||
Snap { bytes: BYTES.load(Relaxed), count: COUNT.load(Relaxed), at: Instant::now() }
|
||||
}
|
||||
|
||||
fn end_phase() -> Snap {
|
||||
let snap = Snap { bytes: BYTES.load(Relaxed), count: COUNT.load(Relaxed), at: Instant::now() };
|
||||
SAMPLING.store(false, Relaxed);
|
||||
SQL_ON.store(false, Relaxed);
|
||||
snap
|
||||
}
|
||||
|
||||
struct Idle {
|
||||
bytes_per_sec: f64,
|
||||
count_per_sec: f64,
|
||||
}
|
||||
|
||||
fn report(out: &mut String, title: &str, s0: &Snap, s1: &Snap, n: u64, idle: &Idle) {
|
||||
use std::fmt::Write;
|
||||
let secs = s1.at.duration_since(s0.at).as_secs_f64();
|
||||
let n_f = n as f64;
|
||||
let bytes = (s1.bytes - s0.bytes) as f64;
|
||||
let count = (s1.count - s0.count) as f64;
|
||||
let adj_bytes = (bytes - idle.bytes_per_sec * secs).max(0.0);
|
||||
let adj_count = (count - idle.count_per_sec * secs).max(0.0);
|
||||
// Allocation numbers from SQL mode are inflated by sqlx's query summaries; the mode tag
|
||||
// keeps them from being mistaken for an allocation run.
|
||||
let mode = if sql_mode() {
|
||||
"sql mode, allocs not comparable"
|
||||
} else {
|
||||
"alloc mode"
|
||||
};
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"\n=== {title} [{mode}]: {n} jobs in {secs:.2}s | raw {:.1} KiB/job {:.0} allocs/job | idle-adjusted {:.1} KiB/job {:.0} allocs/job",
|
||||
bytes / n_f / 1024.0,
|
||||
count / n_f,
|
||||
adj_bytes / n_f / 1024.0,
|
||||
adj_count / n_f
|
||||
);
|
||||
for (name, map) in [("LEAF", &SITES_LEAF), ("CHAIN", &SITES_CHAIN)] {
|
||||
let g = map.lock().unwrap();
|
||||
let Some(map) = g.as_ref() else { continue };
|
||||
let mut sites: Vec<_> = map.iter().map(|(k, s)| (k.clone(), *s)).collect();
|
||||
sites.sort_by_key(|(_, s)| std::cmp::Reverse(s.byte_samples));
|
||||
let _ = writeln!(out, "--- {name} by bytes (KiB/job est.)");
|
||||
for (k, s) in sites.iter().take(30).filter(|(_, s)| s.byte_samples > 0) {
|
||||
let kib = (s.byte_samples * BYTE_PERIOD) as f64 / n_f / 1024.0;
|
||||
let _ = writeln!(out, "{kib:>9.1} {k}");
|
||||
}
|
||||
sites.sort_by_key(|(_, s)| std::cmp::Reverse(s.count_samples));
|
||||
let _ = writeln!(out, "--- {name} by alloc count (allocs/job est.)");
|
||||
for (k, s) in sites.iter().take(30).filter(|(_, s)| s.count_samples > 0) {
|
||||
let allocs = (s.count_samples * COUNT_PERIOD) as f64 / n_f;
|
||||
let _ = writeln!(out, "{allocs:>9.0} {k}");
|
||||
}
|
||||
}
|
||||
let g = SQL.lock().unwrap();
|
||||
if let Some(map) = g.as_ref() {
|
||||
let mut stmts: Vec<_> = map.iter().collect();
|
||||
stmts.sort_by(|a, b| b.1 .0.cmp(&a.1 .0));
|
||||
let total: u64 = stmts.iter().map(|(_, (c, _))| c).sum();
|
||||
let total_secs: f64 = stmts.iter().map(|(_, (_, s))| s).sum();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"--- SQL: {:.1} statements/job, {:.2} ms/job db time",
|
||||
total as f64 / n_f,
|
||||
total_secs * 1000.0 / n_f
|
||||
);
|
||||
for (k, (c, s)) in stmts.iter().take(60) {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{:>7.2}/job {:>7.3}ms/job {k}",
|
||||
*c as f64 / n_f,
|
||||
s * 1000.0 / n_f
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_all(
|
||||
completed: &mut (impl futures::Stream<Item = Uuid> + Unpin),
|
||||
ids: &HashSet<Uuid>,
|
||||
) {
|
||||
let mut left = ids.clone();
|
||||
while !left.is_empty() {
|
||||
let id = tokio::time::timeout(Duration::from_secs(120), completed.next())
|
||||
.await
|
||||
.expect("timed out waiting for completions")
|
||||
.expect("completion stream ended");
|
||||
left.remove(&id);
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_succeeded(db: &Pool<Postgres>, ids: impl IntoIterator<Item = &Uuid>) {
|
||||
for id in ids {
|
||||
let job = completed_job(*id, db).await;
|
||||
assert!(job.success, "job {id} failed: {:?}", job.json_result());
|
||||
}
|
||||
}
|
||||
|
||||
async fn profile_workload(db: Pool<Postgres>, name: &str, payload: impl Fn() -> JobPayload) {
|
||||
let n: usize = std::env::var("PERF_N")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(30);
|
||||
// `capture` takes std's non-reentrant backtrace lock. With either variable enabled, any
|
||||
// `anyhow::Error` captures a backtrace too, and an allocation it makes under that lock can
|
||||
// land on a sample point and deadlock the thread.
|
||||
for var in ["RUST_BACKTRACE", "RUST_LIB_BACKTRACE"] {
|
||||
let enabled = std::env::var(var).is_ok_and(|v| v != "0");
|
||||
assert!(
|
||||
!enabled,
|
||||
"unset {var}: it can deadlock the allocation sampler"
|
||||
);
|
||||
}
|
||||
init_sql_counting();
|
||||
set_jwt_secret().await;
|
||||
let server = ApiServer::start(db.clone()).await.unwrap();
|
||||
let mut completed = listen_for_completed_jobs(&db).await;
|
||||
let (quit, worker) = spawn_test_worker(&Connection::Sql(db.clone()), server.addr.port());
|
||||
|
||||
// Warm caches (script/flow lookups, runtime caches) so they do not count against the
|
||||
// measured jobs.
|
||||
let mut warm = HashSet::new();
|
||||
for _ in 0..3 {
|
||||
warm.insert(RunJob::from(payload()).push(&db).await);
|
||||
}
|
||||
wait_all(&mut completed, &warm).await;
|
||||
assert_succeeded(&db, &warm).await;
|
||||
|
||||
let mut out = String::new();
|
||||
|
||||
let i0 = start_phase();
|
||||
tokio::time::sleep(Duration::from_secs(4)).await;
|
||||
let i1 = end_phase();
|
||||
let idle_secs = i1.at.duration_since(i0.at).as_secs_f64();
|
||||
let idle = Idle {
|
||||
bytes_per_sec: (i1.bytes - i0.bytes) as f64 / idle_secs,
|
||||
count_per_sec: (i1.count - i0.count) as f64 / idle_secs,
|
||||
};
|
||||
let no_idle = Idle { bytes_per_sec: 0.0, count_per_sec: 0.0 };
|
||||
report(
|
||||
&mut out,
|
||||
&format!("{name} IDLE (per second)"),
|
||||
&i0,
|
||||
&i1,
|
||||
idle_secs.round() as u64,
|
||||
&no_idle,
|
||||
);
|
||||
|
||||
// Scheduled ahead so the worker cannot start on them while the push is being measured.
|
||||
let run_at = chrono::Utc::now() + chrono::Duration::seconds(12);
|
||||
let p0 = start_phase();
|
||||
let mut ids = HashSet::new();
|
||||
for _ in 0..n {
|
||||
ids.insert(
|
||||
RunJob::from(payload())
|
||||
.push_arg_scheduled_for_o(Some(run_at))
|
||||
.push(&db)
|
||||
.await,
|
||||
);
|
||||
}
|
||||
let p1 = end_phase();
|
||||
report(&mut out, &format!("{name} PUSH"), &p0, &p1, n as u64, &idle);
|
||||
let wait = (run_at - chrono::Utc::now())
|
||||
.to_std()
|
||||
.expect("the push phase outlasted the schedule offset; lower PERF_N");
|
||||
tokio::time::sleep(wait).await;
|
||||
|
||||
let w0 = start_phase();
|
||||
wait_all(&mut completed, &ids).await;
|
||||
let w1 = end_phase();
|
||||
report(
|
||||
&mut out,
|
||||
&format!("{name} WORKER"),
|
||||
&w0,
|
||||
&w1,
|
||||
n as u64,
|
||||
&idle,
|
||||
);
|
||||
assert_succeeded(&db, &ids).await;
|
||||
|
||||
quit.send();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(30), worker).await;
|
||||
let _ = server.close().await;
|
||||
|
||||
let dir: std::path::PathBuf =
|
||||
std::env::var("PERF_OUT").map_or_else(|_| std::env::temp_dir(), Into::into);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(format!("{name}.txt")), &out).unwrap();
|
||||
println!("{out}");
|
||||
}
|
||||
|
||||
async fn insert_script(
|
||||
db: &Pool<Postgres>,
|
||||
path: &str,
|
||||
hash: i64,
|
||||
lang: &str,
|
||||
content: &str,
|
||||
lock: Option<&str>,
|
||||
) {
|
||||
sqlx::query(
|
||||
"INSERT INTO script (workspace_id, created_by, content, schema, summary, description, path, hash, language, lock)
|
||||
VALUES ('test-workspace', 'test-user', $1, '{}', '', '', $2, $3, $4::script_lang, $5)",
|
||||
)
|
||||
.bind(content)
|
||||
.bind(path)
|
||||
.bind(hash)
|
||||
.bind(lang)
|
||||
.bind(lock)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn script_hash_payload(path: &str, hash: i64, language: ScriptLang) -> JobPayload {
|
||||
JobPayload::ScriptHash {
|
||||
hash: ScriptHash(hash),
|
||||
path: path.to_string(),
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
language,
|
||||
priority: None,
|
||||
apply_preprocessor: false,
|
||||
concurrency_settings: ConcurrencySettings::default().into(),
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn insert_flow(db: &Pool<Postgres>, path: &str, version: i64, value: serde_json::Value) {
|
||||
let _: FlowValue = serde_json::from_value(value.clone()).expect("invalid flow value");
|
||||
sqlx::query(
|
||||
"INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, schema, extra_perms, versions)
|
||||
VALUES ('test-workspace', $1, '', '', $2, 'test-user', NOW(), '{}', '{}', ARRAY[$3::bigint])",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&value)
|
||||
.bind(version)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO flow_version (id, workspace_id, path, value, schema, created_by, created_at)
|
||||
VALUES ($1, 'test-workspace', $2, $3, '{}', 'test-user', NOW())",
|
||||
)
|
||||
.bind(version)
|
||||
.bind(path)
|
||||
.bind(&value)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn flow_payload(path: &str, version: i64) -> JobPayload {
|
||||
JobPayload::Flow {
|
||||
path: path.to_string(),
|
||||
dedicated_worker: None,
|
||||
apply_preprocessor: false,
|
||||
version,
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- workloads
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
#[ignore = "profiling harness, see docs/perf-profiling.md"]
|
||||
async fn perf_bash_hash(db: Pool<Postgres>) {
|
||||
insert_script(&db, "f/perf/bash", 900001, "bash", "echo 42\n", Some("")).await;
|
||||
profile_workload(db, "bash_hash", || {
|
||||
script_hash_payload("f/perf/bash", 900001, ScriptLang::Bash)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
#[ignore = "profiling harness, see docs/perf-profiling.md"]
|
||||
async fn perf_bun_hash(db: Pool<Postgres>) {
|
||||
// An empty bun lock is rejected at run time ("expected //bun.lock split pattern").
|
||||
let content = "export function main() { return { a: 1, b: [1, 2, 3] }; }\n";
|
||||
insert_script(&db, "f/perf/bun", 900002, "bun", content, None).await;
|
||||
profile_workload(db, "bun_hash", || {
|
||||
script_hash_payload("f/perf/bun", 900002, ScriptLang::Bun)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
#[ignore = "profiling harness, see docs/perf-profiling.md"]
|
||||
async fn perf_flow_identity(db: Pool<Postgres>) {
|
||||
let step = |id: &str| json!({"id": id, "value": {"type": "identity"}});
|
||||
let value = json!({"modules": [step("a"), step("b"), step("c")]});
|
||||
insert_flow(&db, "f/perf/identity", 910001, value).await;
|
||||
profile_workload(db, "flow_identity", || {
|
||||
flow_payload("f/perf/identity", 910001)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
#[ignore = "profiling harness, see docs/perf-profiling.md"]
|
||||
async fn perf_flow_bash(db: Pool<Postgres>) {
|
||||
let step = |id: &str| {
|
||||
json!({"id": id, "value": {"type": "rawscript", "language": "bash",
|
||||
"content": "echo 1\n", "input_transforms": {}}})
|
||||
};
|
||||
let value = json!({"modules": [step("a"), step("b"), step("c")]});
|
||||
insert_flow(&db, "f/perf/bashflow", 910002, value).await;
|
||||
profile_workload(db, "flow_bash", || flow_payload("f/perf/bashflow", 910002)).await;
|
||||
}
|
||||
@@ -171,10 +171,10 @@ impl AuthCache {
|
||||
// from a job's WM_TOKEN, even on an AUTH_CACHE hit whose cached authed
|
||||
// predates this field.
|
||||
opt_job_authed.authed.job_id = opt_job_authed.job_id;
|
||||
// The workspace's guest switch is enforced here, once, for every guest request
|
||||
// — not per handler, where each guest-reachable route would have to remember
|
||||
// it. Uncached, so turning guests off takes effect on the next request of every
|
||||
// guest session and every token derived from one.
|
||||
// The no-account rule is enforced here, once, for every guest request — not per
|
||||
// handler, where each guest-reachable route would have to remember it. Uncached,
|
||||
// so an account provisioned after the mint ends the guest session, and every
|
||||
// token derived from one, on its next request.
|
||||
if crate::scopes::has_guest_sentinel(opt_job_authed.authed.scopes.as_deref()) {
|
||||
let Some(w_id) = w_id else { return None };
|
||||
let email = &opt_job_authed.authed.email;
|
||||
@@ -277,11 +277,9 @@ impl AuthCache {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// The workspace switch, the instance switch and the app being in guest
|
||||
// mode, in one answer (guest_app_admits). The door re-reads the switches
|
||||
// and the no-account rule per request through the sentinel below
|
||||
// (guest_session_stands), so turning any of them off stops a cached JWT
|
||||
// session on its next call.
|
||||
// The app being in guest mode (guest_app_admits). The door re-reads the
|
||||
// no-account rule per request through the sentinel below
|
||||
// (guest_session_stands), and the on-behalf resolver re-reads the mode.
|
||||
match windmill_common::workspaces::guest_app_admits(
|
||||
&self.db,
|
||||
w_id,
|
||||
@@ -811,8 +809,8 @@ impl AuthCache {
|
||||
}
|
||||
|
||||
/// How long a guest JWT resolves from the auth cache before the arm re-runs (and
|
||||
/// re-reads the key). A guest JWT is not revocable except by the workspace switch or
|
||||
/// by rotating the key, so the entry must be short enough that a rotated key bites
|
||||
/// re-reads the key). A guest JWT is not revocable except by taking its app out of
|
||||
/// guest mode or by rotating the key, so the entry must be short enough that a rotated key bites
|
||||
/// soon, unlike a normal token whose row can be deleted. Also what makes the
|
||||
/// day-keyed activity dedupe below reachable across a midnight.
|
||||
const GUEST_JWT_CACHE_TTL: chrono::Duration = chrono::Duration::minutes(5);
|
||||
|
||||
@@ -1160,3 +1160,55 @@ async fn test_set_password_revokes_mcp_oauth_grants(db: Pool<Postgres>) -> anyho
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Without automatic usernames, a username is only unique within a workspace, so renaming one
|
||||
/// account must leave the memberships of a like-named account elsewhere alone.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_rename_keeps_other_workspaces_like_named_memberships(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
sqlx::raw_sql(
|
||||
"INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'other', 'test-user');
|
||||
INSERT INTO usr (workspace_id, email, username, is_admin, role)
|
||||
VALUES ('other-workspace', 'test3@windmill.dev', 'test-user-2', false, 'User');
|
||||
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
|
||||
('test-workspace', 'ops', '', '{}'), ('other-workspace', 'finance', '', '{}');
|
||||
INSERT INTO usr_to_group (workspace_id, group_, usr) VALUES
|
||||
('test-workspace', 'ops', 'test-user-2'),
|
||||
('other-workspace', 'finance', 'test-user-2');",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let resp = authed(client().post(format!(
|
||||
"http://localhost:{port}/api/users/rename/test2@windmill.dev"
|
||||
)))
|
||||
.json(&json!({ "new_username": "alice" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "rename: {}", resp.text().await?);
|
||||
|
||||
let memberships: Vec<(String, String, String)> = sqlx::query_as(
|
||||
"SELECT workspace_id, group_, usr FROM usr_to_group
|
||||
WHERE group_ IN ('ops', 'finance') ORDER BY workspace_id",
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
memberships,
|
||||
vec![
|
||||
(
|
||||
"other-workspace".into(),
|
||||
"finance".into(),
|
||||
"test-user-2".into()
|
||||
),
|
||||
("test-workspace".into(), "ops".into(), "alice".into()),
|
||||
]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -163,7 +163,6 @@ pub fn workspaced_service() -> Router {
|
||||
)
|
||||
.route("/edit_deploy_ui_config", post(edit_deploy_ui_config))
|
||||
.route("/edit_default_app", post(edit_default_app))
|
||||
.route("/edit_guest_access", post(edit_guest_access))
|
||||
.route(
|
||||
"/edit_add_admins_and_developers_to_forks",
|
||||
post(edit_add_admins_and_developers_to_forks),
|
||||
@@ -350,13 +349,10 @@ pub struct WorkspaceSettings {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub public_app_execution_limit_per_minute: Option<i32>,
|
||||
pub error_handler_fallback_to_instance_alerts: bool,
|
||||
/// Whether this workspace admits guest sessions (`ExecutionMode::Guest`). An app's
|
||||
/// own `execution_mode: guest` is inert while this is off.
|
||||
pub guest_access_enabled: bool,
|
||||
/// The key a guest JWT is verified against: a PEM public key, or a JWKS URL, at most
|
||||
/// one (a DB CHECK enforces it). Public material, not a secret, so it is admin-
|
||||
/// readable here. `None`/`None` falls back to the instance issuer (`JWT_EXT_JWKS_URL`)
|
||||
/// off cloud, or accepts no JWT guest if none is set; `guest_access_enabled` is the switch.
|
||||
/// off cloud, or accepts no JWT guest if none is set.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guest_jwt_public_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -384,9 +380,6 @@ pub struct WorkspacePublicSettings {
|
||||
pub teams_team_guid: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mute_critical_alerts: Option<bool>,
|
||||
/// Not sensitive, and the app editor needs it to say whether the guest rung is
|
||||
/// live -- an app can be set to `guest` while the workspace has guests off.
|
||||
pub guest_access_enabled: bool,
|
||||
/// Read by the fork dialog, which tells the forker who else the fork will include.
|
||||
pub add_admins_and_developers_to_forks: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -1214,7 +1207,6 @@ async fn get_settings(
|
||||
variable_expiration_handler,
|
||||
public_app_execution_limit_per_minute,
|
||||
error_handler_fallback_to_instance_alerts,
|
||||
guest_access_enabled,
|
||||
guest_jwt_public_key,
|
||||
guest_jwt_jwks_url,
|
||||
add_admins_and_developers_to_forks
|
||||
@@ -1258,7 +1250,6 @@ async fn get_public_settings(
|
||||
teams_team_name,
|
||||
teams_team_guid,
|
||||
mute_critical_alerts,
|
||||
guest_access_enabled,
|
||||
add_admins_and_developers_to_forks,
|
||||
deploy_ui,
|
||||
large_file_storage,
|
||||
@@ -5947,53 +5938,6 @@ async fn edit_default_app(
|
||||
));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EditGuestAccess {
|
||||
guest_access_enabled: bool,
|
||||
}
|
||||
|
||||
/// Turn guest sessions on or off for this workspace. Off by default, and off is
|
||||
/// authoritative and immediate: the switch is re-read where a guest session is
|
||||
/// minted (`guest_app_admits`) and at the auth door on every guest request, so an app
|
||||
/// whose policy already says `guest` — pushed by git-sync, say — closes to guests on
|
||||
/// the next request, sessions already issued included.
|
||||
async fn edit_guest_access(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(EditGuestAccess { guest_access_enabled }): Json<EditGuestAccess>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
if guest_access_enabled {
|
||||
windmill_common::workspaces::require_guest_support()?;
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET guest_access_enabled = $1 WHERE workspace_id = $2",
|
||||
guest_access_enabled,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspaces.edit_guest_access",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(&guest_access_enabled.to_string()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!(
|
||||
"Guest access set to {guest_access_enabled} for workspace {w_id}"
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EditAddAdminsAndDevelopersToForks {
|
||||
add_admins_and_developers_to_forks: bool,
|
||||
@@ -6039,13 +5983,13 @@ async fn edit_add_admins_and_developers_to_forks(
|
||||
struct EditGuestJwtKey {
|
||||
/// A PEM public key (RS or ES family), or a JWKS URL, at most one. Both empty clears the
|
||||
/// workspace key; verification then falls back to the instance issuer (`JWT_EXT_JWKS_URL`)
|
||||
/// off cloud, or refuses the JWT if none is set. The off-switch is `guest_access_enabled`.
|
||||
/// off cloud, or refuses the JWT if none is set.
|
||||
public_key: Option<String>,
|
||||
jwks_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Configure the key a guest JWT (`jwt_guest_`) is verified against for this workspace.
|
||||
/// Workspace-admin gated, like the guest switch: guests are free up to the instance
|
||||
/// Workspace-admin gated: guests are free up to the instance
|
||||
/// allowance on any plan, so configuring their key needs no licence. The key is
|
||||
/// validated before it is stored so a typo is refused here, not silently on every guest
|
||||
/// later: a PEM must parse as an RS/ES public key (HS* has no PEM form and is
|
||||
@@ -15813,7 +15757,6 @@ pub enum SettingsEdit {
|
||||
Ducklake,
|
||||
DbtWarehouses,
|
||||
DeployUi,
|
||||
GuestAccess,
|
||||
GuestJwtKey,
|
||||
PublicAppRateLimit,
|
||||
AddAdminsAndDevelopersToForks,
|
||||
@@ -15885,7 +15828,6 @@ pub async fn apply_settings_edit(
|
||||
DeployUi => edit_deploy_ui_config(a, d, p, settings_edit_body(edit, body)?).await?,
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
DeployUi => edit_deploy_ui_config(a, d, p).await?,
|
||||
GuestAccess => edit_guest_access(a, d, p, settings_edit_body(edit, body)?).await?,
|
||||
GuestJwtKey => edit_guest_jwt_key(a, d, p, settings_edit_body(edit, body)?).await?,
|
||||
PublicAppRateLimit => {
|
||||
edit_public_app_rate_limit(d, p, a, settings_edit_body(edit, body)?).await?
|
||||
|
||||
@@ -135,7 +135,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, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler) 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, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler 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, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler) 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, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler FROM workspace_settings WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
|
||||
@@ -35080,7 +35080,7 @@
|
||||
]
|
||||
},
|
||||
"sleep": {
|
||||
"description": "Delay before executing this step (in seconds or as expression)",
|
||||
"description": "Delay after this step completes, before the next step starts (in seconds or as expression)",
|
||||
"$ref": "#/components/schemas/schemas-InputTransform"
|
||||
},
|
||||
"cache_ttl": {
|
||||
@@ -47034,7 +47034,7 @@
|
||||
]
|
||||
},
|
||||
"sleep": {
|
||||
"description": "Delay before executing this step (in seconds or as expression)",
|
||||
"description": "Delay after this step completes, before the next step starts (in seconds or as expression)",
|
||||
"$ref": "#/components/schemas/schemas-InputTransform"
|
||||
},
|
||||
"cache_ttl": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.851.0
|
||||
version: 1.853.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -4304,15 +4304,11 @@ paths:
|
||||
$ref: "#/components/schemas/WorkspaceDeployUISettings"
|
||||
mute_critical_alerts:
|
||||
type: boolean
|
||||
guest_access_enabled:
|
||||
type: boolean
|
||||
description: Whether this workspace admits guest sessions. An app's own `guest` execution mode is inert while this is false.
|
||||
add_admins_and_developers_to_forks:
|
||||
type: boolean
|
||||
description: Whether every new fork of this workspace starts with its admins and developers as members, keeping their role.
|
||||
required:
|
||||
- workspace_id
|
||||
- guest_access_enabled
|
||||
- add_admins_and_developers_to_forks
|
||||
|
||||
/w/{workspace}/workspaces/get_settings:
|
||||
@@ -4397,9 +4393,6 @@ paths:
|
||||
error_handler_fallback_to_instance_alerts:
|
||||
type: boolean
|
||||
description: Report failed jobs to the instance critical alert channels when no workspace error handler is set.
|
||||
guest_access_enabled:
|
||||
type: boolean
|
||||
description: Whether this workspace admits guest sessions. An app's own `guest` execution mode is inert while this is false.
|
||||
guest_jwt_public_key:
|
||||
type: string
|
||||
description: PEM public key a guest JWT (`jwt_guest_`) is verified against for this workspace. Mutually exclusive with `guest_jwt_jwks_url`.
|
||||
@@ -6888,41 +6881,6 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/edit_guest_access:
|
||||
post:
|
||||
summary: enable or disable guest sessions for this workspace
|
||||
description: >-
|
||||
Guests are people the identity provider authenticates who have no Windmill
|
||||
account; the `guest` app execution mode admits them. Off by default. Re-read
|
||||
where a guest session is minted and at the auth door on every guest request, so
|
||||
turning it off takes effect immediately, for sessions already issued and for
|
||||
apps whose policy already says `guest`. Turning it *on* is refused with a 400
|
||||
where guests are unavailable (the shared cloud); turning it off always works.
|
||||
operationId: editGuestAccess
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: Whether guest sessions are admitted
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
guest_access_enabled:
|
||||
type: boolean
|
||||
required:
|
||||
- guest_access_enabled
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/edit_add_admins_and_developers_to_forks:
|
||||
post:
|
||||
summary: choose whether new forks of this workspace start with its admins and developers
|
||||
@@ -6964,7 +6922,7 @@ paths:
|
||||
verified against this key: a PEM public key (RS/ES family, HS* refused) or a JWKS
|
||||
URL, at most one. Both empty clears the workspace key; off cloud, verification then
|
||||
falls back to the instance issuer (`JWT_EXT_JWKS_URL`) if one is set, else no guest
|
||||
JWT is accepted (`guest_access_enabled` is the on/off switch). Workspace-admin gated.
|
||||
JWT is accepted. Workspace-admin gated.
|
||||
The key is validated before it is stored. Setting a key is refused with a 400 where
|
||||
guests are unavailable (the shared cloud); clearing one always works.
|
||||
operationId: editGuestJwtKey
|
||||
@@ -10584,9 +10542,8 @@ paths:
|
||||
summary: whether the app behind a custom path admits guests
|
||||
description: >-
|
||||
The custom-path counterpart of `getGuestEntry`. Unauthenticated; 404 unless
|
||||
the app's execution mode is `guest` AND its workspace has
|
||||
`guest_access_enabled` AND the instance has not set `guest_access_disabled`,
|
||||
and never on a deployment where guests are unavailable (the shared cloud).
|
||||
the app's execution mode is `guest`, and never on a deployment where guests
|
||||
are unavailable (the shared cloud).
|
||||
Returns the workspace too, since a custom URL may not carry it.
|
||||
operationId: getGuestEntryByCustomPath
|
||||
tags:
|
||||
@@ -15706,11 +15663,9 @@ paths:
|
||||
summary: whether the app behind a share secret admits guests
|
||||
description: >-
|
||||
Unauthenticated: what a signed-out visitor reads to learn that signing in
|
||||
would let them in. 404 unless the app's execution mode is `guest` AND the
|
||||
workspace has `guest_access_enabled` AND the instance has not set the
|
||||
`guest_access_disabled` global setting, and never on a deployment where guests
|
||||
are unavailable (the shared cloud), so it says nothing about apps that are not
|
||||
open to guests. Discloses only the app path, to a caller already
|
||||
would let them in. 404 unless the app's execution mode is `guest`, and never
|
||||
on a deployment where guests are unavailable (the shared cloud), so it says
|
||||
nothing about apps that are not open to guests. Discloses only the app path, to a caller already
|
||||
holding the share secret.
|
||||
operationId: getGuestEntry
|
||||
tags:
|
||||
@@ -32657,16 +32612,12 @@ components:
|
||||
Guests are free up to `free_allowance` distinct emails over the trailing
|
||||
`window_days`. Past that an Enterprise plan meters them (`metered`, four guests
|
||||
to one seat: `billable_guests`, `guest_seats`); every other plan and build
|
||||
admits no new email until the count drops. `instance_enabled` is the superadmin
|
||||
switch (`guest_access_disabled` global setting) every workspace switch sits under.
|
||||
`available` is whether this deployment can have guests at all: false on the shared
|
||||
cloud, where guest access requires a self-hosted or dedicated deployment, and every
|
||||
other field and switch is then moot.
|
||||
admits no new email until the count drops. `available` is whether this
|
||||
deployment can have guests at all: false on the shared cloud, where guest access
|
||||
requires a self-hosted or dedicated deployment, and every other field is then moot.
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
instance_enabled:
|
||||
type: boolean
|
||||
guest_count:
|
||||
type: integer
|
||||
format: int64
|
||||
@@ -32685,7 +32636,6 @@ components:
|
||||
format: int64
|
||||
required:
|
||||
- available
|
||||
- instance_enabled
|
||||
- guest_count
|
||||
- window_days
|
||||
- free_allowance
|
||||
@@ -37762,9 +37712,8 @@ components:
|
||||
requires an authenticated viewer), while updating one keeps the mode
|
||||
the app is already deployed under. Neither `anonymous`, which makes
|
||||
the app publicly executable, nor `guest`, which opens it to anyone the
|
||||
identity provider authenticates, is ever assumed. A guest is only
|
||||
admitted where the workspace also has `guest_access_enabled`, which is
|
||||
checked when the session is minted and again on every guest request
|
||||
identity provider authenticates, is ever assumed. Widening an app to
|
||||
`guest` is gated by the `RestrictGuestAppDeployment` protection rule
|
||||
on_behalf_of:
|
||||
type: string
|
||||
description: The user or group the app runs as in anonymous or publisher mode (e.g. 'u/admin' or 'g/mygroup'). The authority for the app's identity.
|
||||
|
||||
@@ -306,9 +306,8 @@ pub enum ExecutionMode {
|
||||
/// publisher exactly as in [`ExecutionMode::Publisher`]. Such a viewer holds a
|
||||
/// guest session: an identity with no account at all (no `password` row, no `usr`
|
||||
/// row anywhere), which is what keeps it off every row-based seat counter; what a
|
||||
/// guest costs instead is the allowance in `windmill_common::workspaces`. Honored
|
||||
/// only where `workspace_settings.guest_access_enabled` is on, re-read at the auth
|
||||
/// door on every guest request.
|
||||
/// guest costs instead is the allowance in `windmill_common::workspaces`. Widening
|
||||
/// an app into it is gated by `RestrictGuestAppDeployment`.
|
||||
Guest,
|
||||
/// Default for a policy that omits `execution_mode`. It MUST stay a mode
|
||||
/// that requires an authenticated viewer: an omitted field must never be
|
||||
@@ -386,8 +385,8 @@ fn refuse_guest_mode_where_unavailable(
|
||||
/// A guest is authorized by its token's scope and never by an ACL probe: it holds no
|
||||
/// `usr` row, so RLS finds nothing for it and every guest would read as having no
|
||||
/// access. That scope is also what keeps a guest session to the one app it was minted
|
||||
/// for, even though the mode itself admits anyone signed in. The workspace's guest
|
||||
/// switch is not checked here: `AuthCache` enforces it for every guest request.
|
||||
/// for, even though the mode itself admits anyone signed in. The no-account rule is not
|
||||
/// checked here: `AuthCache` enforces it for every guest request.
|
||||
pub fn authorize_non_member_viewer(
|
||||
mode: ExecutionMode,
|
||||
app_path: &str,
|
||||
|
||||
@@ -1332,7 +1332,7 @@ is, a different one moves it there and archives the old path"),
|
||||
},
|
||||
"execution_mode": {
|
||||
"type": "string",
|
||||
"description": "Who may open the app, and who its runnables execute as. Optional, and what omitting it means depends on the operation: creating an app defaults it to `publisher` (runs on behalf of the app's publisher and requires an authenticated viewer), while updating one keeps the mode the app is already deployed under. Neither `anonymous`, which makes the app publicly executable, nor `guest`, which opens it to anyone the identity provider authenticates, is ever assumed. A guest is only admitted where the workspace also has `guest_access_enabled`, which is checked when the session is minted and again on every guest request. Possible values: viewer, publisher, guest, anonymous"
|
||||
"description": "Who may open the app, and who its runnables execute as. Optional, and what omitting it means depends on the operation: creating an app defaults it to `publisher` (runs on behalf of the app's publisher and requires an authenticated viewer), while updating one keeps the mode the app is already deployed under. Neither `anonymous`, which makes the app publicly executable, nor `guest`, which opens it to anyone the identity provider authenticates, is ever assumed. Widening an app to `guest` is gated by the `RestrictGuestAppDeployment` protection rule. Possible values: viewer, publisher, guest, anonymous"
|
||||
},
|
||||
"on_behalf_of": {
|
||||
"type": "string",
|
||||
@@ -1449,7 +1449,7 @@ is, a different one moves it there and archives the old path"),
|
||||
},
|
||||
"execution_mode": {
|
||||
"type": "string",
|
||||
"description": "Who may open the app, and who its runnables execute as. Optional, and what omitting it means depends on the operation: creating an app defaults it to `publisher` (runs on behalf of the app's publisher and requires an authenticated viewer), while updating one keeps the mode the app is already deployed under. Neither `anonymous`, which makes the app publicly executable, nor `guest`, which opens it to anyone the identity provider authenticates, is ever assumed. A guest is only admitted where the workspace also has `guest_access_enabled`, which is checked when the session is minted and again on every guest request. Possible values: viewer, publisher, guest, anonymous"
|
||||
"description": "Who may open the app, and who its runnables execute as. Optional, and what omitting it means depends on the operation: creating an app defaults it to `publisher` (runs on behalf of the app's publisher and requires an authenticated viewer), while updating one keeps the mode the app is already deployed under. Neither `anonymous`, which makes the app publicly executable, nor `guest`, which opens it to anyone the identity provider authenticates, is ever assumed. Widening an app to `guest` is gated by the `RestrictGuestAppDeployment` protection rule. Possible values: viewer, publisher, guest, anonymous"
|
||||
},
|
||||
"on_behalf_of": {
|
||||
"type": "string",
|
||||
|
||||
@@ -317,15 +317,10 @@ async fn undeployed_runnable_targets(ctx: &Ctx<'_>, runnables: &Map<String, Valu
|
||||
}
|
||||
|
||||
async fn guest_access_is_live(ctx: &Ctx<'_>) -> bool {
|
||||
let (Ok(usage), Ok(settings)) = (
|
||||
ctx.get(&ctx.ws("workspaces/guest_usage")).await,
|
||||
ctx.get(&ctx.ws("workspaces/get_public_settings")).await,
|
||||
) else {
|
||||
let Ok(usage) = ctx.get(&ctx.ws("workspaces/guest_usage")).await else {
|
||||
return false;
|
||||
};
|
||||
truthy(usage.get("available"))
|
||||
&& truthy(usage.get("instance_enabled"))
|
||||
&& truthy(settings.get("guest_access_enabled"))
|
||||
}
|
||||
|
||||
/// Deploys a raw app draft from its sources: the server bundles them on a worker and
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -800,20 +800,15 @@ pub(super) async fn describe_app_exposure(ctx: &Ctx<'_>, mode: Option<&Value>) -
|
||||
if mode.and_then(Value::as_str) != Some("guest") {
|
||||
return mode.cloned();
|
||||
}
|
||||
let usage = ctx.get(&ctx.ws("workspaces/guest_usage")).await;
|
||||
let settings = ctx.get(&ctx.ws("workspaces/get_public_settings")).await;
|
||||
// A switch that could not be read proves nothing either way: report the mode bare.
|
||||
let (Ok(usage), Ok(settings)) = (usage, settings) else {
|
||||
// A deployment that could not be read proves nothing either way: report the mode bare.
|
||||
let Ok(usage) = ctx.get(&ctx.ws("workspaces/guest_usage")).await else {
|
||||
return mode.cloned();
|
||||
};
|
||||
let live = truthy(usage.get("available"))
|
||||
&& truthy(usage.get("instance_enabled"))
|
||||
&& truthy(settings.get("guest_access_enabled"));
|
||||
if live {
|
||||
if truthy(usage.get("available")) {
|
||||
mode.cloned()
|
||||
} else {
|
||||
Some(json!(
|
||||
"guest, but inert: the instance or workspace admits no guest, so this app admits members only"
|
||||
"guest, but inert: this deployment admits no guest, so this app admits members only"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,9 +443,10 @@ async fn update_username_in_workpsace<'c>(
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE usr_to_group SET usr = $1 WHERE usr = $2",
|
||||
"UPDATE usr_to_group SET usr = $1 WHERE usr = $2 AND workspace_id = $3",
|
||||
new_username,
|
||||
old_username
|
||||
old_username,
|
||||
w_id
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
@@ -81,7 +81,6 @@ struct SettingsRow {
|
||||
deploy_ui: Option<Value>,
|
||||
teams_command_script: Option<String>,
|
||||
public_app_execution_limit_per_minute: Option<i32>,
|
||||
guest_access_enabled: Option<bool>,
|
||||
guest_jwt_public_key: Option<String>,
|
||||
guest_jwt_jwks_url: Option<String>,
|
||||
error_handler_fallback_to_instance_alerts: Option<bool>,
|
||||
@@ -272,16 +271,18 @@ async fn protection_rules_document(conn: &mut PgConnection, w_id: &str) -> Resul
|
||||
.await?;
|
||||
Ok(Value::Object(
|
||||
rows.into_iter()
|
||||
.map(|(name, bits, bypass_groups, bypass_users, fork_role_mapping)| {
|
||||
let rules = ProtectionRules::from_bits_truncate(bits);
|
||||
let mut rule = json!({
|
||||
"rules": rule_kinds(rules),
|
||||
"bypass_groups": bypass_groups,
|
||||
"bypass_users": bypass_users,
|
||||
});
|
||||
with_fork_role_mapping(&mut rule, fork_role_mapping);
|
||||
(name, rule)
|
||||
})
|
||||
.map(
|
||||
|(name, bits, bypass_groups, bypass_users, fork_role_mapping)| {
|
||||
let rules = ProtectionRules::from_bits_truncate(bits);
|
||||
let mut rule = json!({
|
||||
"rules": rule_kinds(rules),
|
||||
"bypass_groups": bypass_groups,
|
||||
"bypass_users": bypass_users,
|
||||
});
|
||||
with_fork_role_mapping(&mut rule, fork_role_mapping);
|
||||
(name, rule)
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
@@ -312,7 +313,7 @@ pub async fn load_settings_document(
|
||||
git_sync, default_app, default_scripts, workspace.name AS name, mute_critical_alerts,
|
||||
color, operator_settings, datatable, slack_team_id, slack_name, slack_command_script,
|
||||
slack_oauth_client_id, slack_oauth_client_secret, ducklake, dbt_warehouses, deploy_ui,
|
||||
teams_command_script, public_app_execution_limit_per_minute, guest_access_enabled,
|
||||
teams_command_script, public_app_execution_limit_per_minute,
|
||||
guest_jwt_public_key, guest_jwt_jwks_url, error_handler_fallback_to_instance_alerts,
|
||||
add_admins_and_developers_to_forks
|
||||
FROM workspace_settings
|
||||
@@ -360,7 +361,7 @@ pub async fn load_settings_document(
|
||||
// In the order `wmill sync pull` has always written settings.yaml. A key that predates the
|
||||
// document and is not marked `always` is written only when set, as it always was; a key
|
||||
// added since is always written, so a pulled file states it and from then on manages it.
|
||||
let entries: [(&str, Option<Value>, bool); 32] = [
|
||||
let entries: [(&str, Option<Value>, bool); 31] = [
|
||||
("auto_invite", row.auto_invite, false),
|
||||
("webhook", row.webhook.map(Value::String), false),
|
||||
("error_handler", row.error_handler, true),
|
||||
@@ -418,11 +419,6 @@ pub async fn load_settings_document(
|
||||
row.public_app_execution_limit_per_minute.map(|n| json!(n)),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"guest_access_enabled",
|
||||
Some(json!(row.guest_access_enabled.unwrap_or(false))),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"guest_jwt_public_key",
|
||||
row.guest_jwt_public_key.map(Value::String),
|
||||
@@ -565,7 +561,6 @@ fn same(a: Option<&Value>, b: Option<&Value>) -> bool {
|
||||
/// Switches the endpoint stores as `false` when cleared, compared as `false` when unset.
|
||||
const FALSE_WHEN_UNSET: &[&str] = &[
|
||||
"mute_critical_alerts",
|
||||
"guest_access_enabled",
|
||||
"add_admins_and_developers_to_forks",
|
||||
"error_handler_fallback_to_instance_alerts",
|
||||
];
|
||||
@@ -690,6 +685,10 @@ fn resolve_secret(
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys earlier versions wrote that no longer configure anything. A synced `settings.yaml`
|
||||
/// keeps them until its next pull, so they are dropped without the unknown-key warning.
|
||||
const RETIRED_KEYS: &[&str] = &["guest_access_enabled"];
|
||||
|
||||
const KNOWN_KEYS: &[&str] = &[
|
||||
"auto_invite",
|
||||
"webhook",
|
||||
@@ -716,7 +715,6 @@ const KNOWN_KEYS: &[&str] = &[
|
||||
"deploy_ui",
|
||||
"teams_command_script",
|
||||
"public_app_execution_limit_per_minute",
|
||||
"guest_access_enabled",
|
||||
"guest_jwt_public_key",
|
||||
"guest_jwt_jwks_url",
|
||||
"error_handler_fallback_to_instance_alerts",
|
||||
@@ -1072,7 +1070,7 @@ fn plan_document(
|
||||
let unknown: Vec<&str> = desired
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.filter(|k| !KNOWN_KEYS.contains(k))
|
||||
.filter(|k| !KNOWN_KEYS.contains(k) && !RETIRED_KEYS.contains(k))
|
||||
.collect();
|
||||
if !unknown.is_empty() {
|
||||
warnings.push(format!("Ignored unknown settings: {}", unknown.join(", ")));
|
||||
@@ -1540,14 +1538,6 @@ fn plan_document(
|
||||
|v| json!({ "public_app_execution_limit_per_minute": v }),
|
||||
);
|
||||
|
||||
let guest_access = null_as_false(p.managed("guest_access_enabled"));
|
||||
p.simple(
|
||||
"guest_access_enabled",
|
||||
guest_access,
|
||||
Target::Edit(SettingsEdit::GuestAccess),
|
||||
|v| json!({ "guest_access_enabled": v.as_bool().unwrap_or(false) }),
|
||||
);
|
||||
|
||||
let (public_key, jwks_url) = (
|
||||
p.managed("guest_jwt_public_key"),
|
||||
p.managed("guest_jwt_jwks_url"),
|
||||
@@ -2008,7 +1998,10 @@ mod tests {
|
||||
#[test]
|
||||
fn unset_switches_compare_as_false() {
|
||||
let mc = WorkspaceCrypt::new("k");
|
||||
let d = doc(json!({ "mute_critical_alerts": false, "guest_access_enabled": null }));
|
||||
let d = doc(json!({
|
||||
"mute_critical_alerts": false,
|
||||
"add_admins_and_developers_to_forks": null
|
||||
}));
|
||||
assert!(
|
||||
plan_document(current(), d, false, &mc, &DatatableNames::default())
|
||||
.unwrap()
|
||||
@@ -2017,6 +2010,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retired_keys_are_dropped_without_a_warning() {
|
||||
let mc = WorkspaceCrypt::new("k");
|
||||
let d = doc(json!({ "guest_access_enabled": true }));
|
||||
let p = plan_document(current(), d, false, &mc, &DatatableNames::default()).unwrap();
|
||||
assert!(p.changes.is_empty() && p.writes.is_empty() && p.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stating_one_guest_key_clears_the_other() {
|
||||
let mc = WorkspaceCrypt::new("k");
|
||||
@@ -2033,7 +2034,6 @@ mod tests {
|
||||
fn mistyped_values_are_refused_rather_than_coerced() {
|
||||
let mc = WorkspaceCrypt::new("k");
|
||||
for d in [
|
||||
json!({ "guest_access_enabled": "true" }),
|
||||
json!({ "add_admins_and_developers_to_forks": 1 }),
|
||||
json!({ "slack_oauth_client_id": 123 }),
|
||||
json!({ "error_handler": { "path": "f/x", "muted_on_cancel": "yes" } }),
|
||||
|
||||
@@ -76,10 +76,6 @@ pub const EXPOSE_METRICS_SETTING: &str = "expose_metrics";
|
||||
pub const EXPOSE_DEBUG_METRICS_SETTING: &str = "expose_debug_metrics";
|
||||
pub const KEEP_JOB_DIR_SETTING: &str = "keep_job_dir";
|
||||
pub const REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING: &str = "require_preexisting_user_for_oauth";
|
||||
/// Superadmin switch over guest sessions for the whole instance, above the per-workspace
|
||||
/// one. Read from the table, uncached, by the same gates that read the workspace switch;
|
||||
/// the superadmin Guests list writes it through `/settings/global/{key}` by this name.
|
||||
pub const GUEST_ACCESS_DISABLED_SETTING: &str = "guest_access_disabled";
|
||||
pub const JOB_ISOLATION_SETTING: &str = "job_isolation";
|
||||
pub const NSJAIL_TMPFS_SIZE_MB_SETTING: &str = "nsjail_tmpfs_size_mb";
|
||||
pub const NSJAIL_TMP_BACKING_SETTING: &str = "nsjail_tmp_backing";
|
||||
|
||||
@@ -184,7 +184,11 @@ fn compile(secrets: &HashSet<String>) -> CompiledMasks {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Built once per job (its token is always a secret) and searched over little log text, so
|
||||
// the default DFA's build cost dominates; a contiguous NFA searches as fast here since
|
||||
// the literal prefilter does the scanning.
|
||||
let ac = aho_corasick::AhoCorasickBuilder::new()
|
||||
.kind(Some(aho_corasick::AhoCorasickKind::ContiguousNFA))
|
||||
.match_kind(aho_corasick::MatchKind::LeftmostLongest)
|
||||
.build(sorted.iter().map(|s| s.as_str()))
|
||||
.expect("failed to build aho-corasick automaton");
|
||||
|
||||
@@ -906,12 +906,8 @@ pub async fn guest_count_in_window<'c, E: sqlx::Executor<'c, Database = sqlx::Po
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct GuestUsage {
|
||||
/// Whether this deployment can admit guests at all ([`instance_supports_guests`]).
|
||||
/// Off, every other field is moot and no switch below can turn guests on.
|
||||
/// Off, every other field is moot and an app in `guest` mode admits members only.
|
||||
pub available: bool,
|
||||
/// The superadmin switch (`GUEST_ACCESS_DISABLED_SETTING`), which every workspace
|
||||
/// switch sits under. Reported as stored, so a superadmin sees what they set even
|
||||
/// where `available` overrules it.
|
||||
pub instance_enabled: bool,
|
||||
/// Distinct guest emails over the trailing `window_days`.
|
||||
pub guest_count: i64,
|
||||
pub window_days: i32,
|
||||
@@ -931,8 +927,8 @@ pub const GUESTS_UNAVAILABLE_MESSAGE: &str =
|
||||
/// Whether guests can exist on this deployment at all. They cannot on the shared cloud:
|
||||
/// a guest is an identity Windmill itself never vouched for, admitted on the say-so of
|
||||
/// whoever runs the instance, which is not a call a multi-tenant deployment can make for
|
||||
/// its tenants. Folded into every guest gate below, so a workspace switch or an app
|
||||
/// policy left saying `guest` is inert rather than honored.
|
||||
/// its tenants. Folded into every guest gate below, so an app policy left saying `guest`
|
||||
/// is inert rather than honored.
|
||||
pub fn instance_supports_guests() -> bool {
|
||||
!*crate::worker::CLOUD_HOSTED
|
||||
}
|
||||
@@ -947,31 +943,7 @@ pub fn require_guest_support() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// SQL for the superadmin switch alone, absent meaning on. The setting is read as text
|
||||
/// before the cast so `true` and `"true"` both count.
|
||||
fn instance_switch_sql() -> String {
|
||||
format!(
|
||||
"NOT COALESCE((SELECT (value #>> '{{}}')::boolean FROM global_settings \
|
||||
WHERE name = '{}'), false)",
|
||||
crate::global_settings::GUEST_ACCESS_DISABLED_SETTING
|
||||
)
|
||||
}
|
||||
|
||||
/// SQL for "the instance admits guests": the superadmin switch, under
|
||||
/// [`instance_supports_guests`].
|
||||
fn instance_admits_guests_sql() -> String {
|
||||
if !instance_supports_guests() {
|
||||
return "false".to_string();
|
||||
}
|
||||
instance_switch_sql()
|
||||
}
|
||||
|
||||
pub async fn guest_usage(db: &crate::DB) -> Result<GuestUsage> {
|
||||
let instance_switch = instance_switch_sql();
|
||||
let instance_enabled: bool = sqlx::query_scalar(&format!("SELECT {instance_switch}"))
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("reading the instance guest switch: {e:#}")))?;
|
||||
let guest_count = guest_count_in_window(db).await?;
|
||||
let metered = guests_are_metered().await;
|
||||
let billable_guests = if metered {
|
||||
@@ -981,7 +953,6 @@ pub async fn guest_usage(db: &crate::DB) -> Result<GuestUsage> {
|
||||
};
|
||||
Ok(GuestUsage {
|
||||
available: instance_supports_guests(),
|
||||
instance_enabled,
|
||||
guest_count,
|
||||
window_days: GUEST_WINDOW_DAYS,
|
||||
free_allowance: FREE_GUESTS_PER_WINDOW,
|
||||
@@ -1025,19 +996,19 @@ pub async fn guest_admission(conn: &mut sqlx::PgConnection, email: &str) -> Resu
|
||||
)))
|
||||
}
|
||||
|
||||
/// Whether a guest session for `email` in `w_id` still stands: the instance and the
|
||||
/// workspace admit guests, and the email still has no account. Read at the auth door on
|
||||
/// every guest request, so turning either switch off, or an account provisioned after
|
||||
/// the mint (or racing it), ends the session on its next request.
|
||||
/// Whether a guest session for `email` in `w_id` still stands: the deployment admits
|
||||
/// guests, and the email still has no account. Read at the auth door on every guest
|
||||
/// request, so an account provisioned after the mint (or racing it) ends the session on
|
||||
/// its next request. Whether its app still admits guests is the on-behalf resolver's call.
|
||||
pub async fn guest_session_stands(db: &crate::DB, w_id: &str, email: &str) -> Result<bool> {
|
||||
let instance_admits = instance_admits_guests_sql();
|
||||
let stands: Option<bool> = sqlx::query_scalar(&format!(
|
||||
"SELECT guest_access_enabled
|
||||
AND {instance_admits}
|
||||
AND NOT EXISTS(SELECT 1 FROM password WHERE email = $2)
|
||||
if !instance_supports_guests() {
|
||||
return Ok(false);
|
||||
}
|
||||
let stands: Option<bool> = sqlx::query_scalar(
|
||||
"SELECT NOT EXISTS(SELECT 1 FROM password WHERE email = $2)
|
||||
AND NOT EXISTS(SELECT 1 FROM usr WHERE email = $2)
|
||||
FROM workspace_settings WHERE workspace_id = $1"
|
||||
))
|
||||
FROM workspace_settings WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(w_id)
|
||||
.bind(email)
|
||||
.fetch_optional(db)
|
||||
@@ -1046,27 +1017,23 @@ pub async fn guest_session_stands(db: &crate::DB, w_id: &str, email: &str) -> Re
|
||||
Ok(stands.unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Every switch at once: the instance's, the workspace's, and `app_path` being in
|
||||
/// `guest` execution mode. The single answer to "may a guest session be minted for this
|
||||
/// app", used by the mint itself and by the sign-in branch that decides whether to call
|
||||
/// it. A missing app or a policy with no stated mode reads as "no". The allowance is
|
||||
/// `guest_admission`.
|
||||
/// Whether the deployment admits guests and `app_path` is in `guest` execution mode.
|
||||
/// The single answer to "may a guest session be minted for this app", used by the mint
|
||||
/// itself and by the sign-in branch that decides whether to call it. A missing app or a
|
||||
/// policy with no stated mode reads as "no". The allowance is `guest_admission`.
|
||||
pub async fn guest_app_admits<'c, E: sqlx::Executor<'c, Database = sqlx::Postgres>>(
|
||||
executor: E,
|
||||
w_id: &str,
|
||||
app_path: &str,
|
||||
) -> Result<bool> {
|
||||
// The mint refuses a path it cannot scope, so discovery must not advertise one.
|
||||
if !crate::auth::is_scope_literal_path(app_path) {
|
||||
if !instance_supports_guests() || !crate::auth::is_scope_literal_path(app_path) {
|
||||
return Ok(false);
|
||||
}
|
||||
let instance_admits = instance_admits_guests_sql();
|
||||
let admits: Option<bool> = sqlx::query_scalar(&format!(
|
||||
"SELECT COALESCE(ws.guest_access_enabled AND app.policy->>'execution_mode' = 'guest', false)
|
||||
AND {instance_admits}
|
||||
FROM app JOIN workspace_settings ws ON ws.workspace_id = app.workspace_id
|
||||
WHERE app.workspace_id = $1 AND app.path = $2"
|
||||
))
|
||||
let admits: Option<bool> = sqlx::query_scalar(
|
||||
"SELECT COALESCE(policy->>'execution_mode' = 'guest', false)
|
||||
FROM app WHERE workspace_id = $1 AND path = $2",
|
||||
)
|
||||
.bind(w_id)
|
||||
.bind(app_path)
|
||||
.fetch_optional(executor)
|
||||
|
||||
@@ -695,6 +695,9 @@ async fn get_resource_value(
|
||||
Ok(Json(value))
|
||||
}
|
||||
|
||||
/// Including the resource's and its folder's `extra_perms` in the error is intended, not a
|
||||
/// leak: who holds access to an item is not secret within a workspace, and it tells the
|
||||
/// denied caller whom to ask. Keep the maps in the message.
|
||||
pub async fn explain_resource_perm_error(
|
||||
path: &str,
|
||||
w_id: &str,
|
||||
|
||||
@@ -486,6 +486,9 @@ fn describe_authed(authed: Option<&(impl Authable + Sync)>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Including the variable's and its folder's `extra_perms` in the error is intended, not a
|
||||
/// leak: who holds access to an item is not secret within a workspace, and it tells the
|
||||
/// denied caller whom to ask. Keep the maps in the message.
|
||||
async fn explain_variable_perm_error(
|
||||
path: &str,
|
||||
w_id: &str,
|
||||
|
||||
@@ -539,6 +539,7 @@ pub struct ExponentialDelay {
|
||||
pub attempts: u32,
|
||||
pub multiplier: u16,
|
||||
pub seconds: u16,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub random_factor: Option<i8>,
|
||||
}
|
||||
|
||||
@@ -925,7 +926,9 @@ pub struct Branch {
|
||||
pub modules: Vec<FlowModule>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub modules_node: Option<FlowNodeId>,
|
||||
#[serde(default = "default_true")]
|
||||
// The worker reads an absent skip_failure as false (BranchWithSkipFailures),
|
||||
// so a round-trip through this struct must not turn it into true.
|
||||
#[serde(default)]
|
||||
pub skip_failure: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub parallel: bool,
|
||||
@@ -1700,6 +1703,39 @@ mod tests {
|
||||
assert_eq!(empty, json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exponential_retry_without_random_factor_omits_it() {
|
||||
// A non-default exponential retry with no random_factor must not gain
|
||||
// random_factor:null on a round-trip (the flow-dependencies job writes
|
||||
// that value back to flow.yaml, and the CLI linter rejects null).
|
||||
let input = json!({ "exponential": { "attempts": 3, "multiplier": 1, "seconds": 2 } });
|
||||
let retry: Retry = serde_json::from_value(input.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(&retry).unwrap(), input);
|
||||
|
||||
// A set random_factor still round-trips.
|
||||
let input = json!({
|
||||
"exponential": { "attempts": 3, "multiplier": 1, "seconds": 2, "random_factor": 10 }
|
||||
});
|
||||
let retry: Retry = serde_json::from_value(input.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(&retry).unwrap(), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_without_skip_failure_keeps_failing_the_flow() {
|
||||
// An absent skip_failure means "a failing branch fails the flow" to the
|
||||
// worker and the flow editor; a round-trip must preserve that.
|
||||
let input = json!({
|
||||
"type": "branchall",
|
||||
"branches": [{ "modules": [] }, { "modules": [], "skip_failure": true }]
|
||||
});
|
||||
let val: FlowModuleValue = serde_json::from_value(input).unwrap();
|
||||
let output = serde_json::to_value(&val).unwrap();
|
||||
let reparsed: FlowModuleWithBranches = serde_json::from_value(output).unwrap();
|
||||
let skips: Vec<bool> =
|
||||
reparsed.branches.iter().map(|b| b.skip_failure.unwrap_or(false)).collect();
|
||||
assert_eq!(skips, vec![false, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_after_if_omits_null_error_message() {
|
||||
let input = json!({ "expr": "result == 404", "skip_if_stopped": true });
|
||||
|
||||
@@ -122,7 +122,7 @@ impl KnownAdapter {
|
||||
"duckdb" => Some(KnownAdapter::Duckdb),
|
||||
"clickhouse" => Some(KnownAdapter::Clickhouse),
|
||||
"snowflake" | "snowflake_oauth" => Some(KnownAdapter::Snowflake),
|
||||
"bigquery" | "gcp_service_account" => Some(KnownAdapter::Bigquery),
|
||||
"bigquery" | "google_service_account" | "gcp_service_account" => Some(KnownAdapter::Bigquery),
|
||||
"databricks" => Some(KnownAdapter::Databricks),
|
||||
"salesforce" => Some(KnownAdapter::Salesforce),
|
||||
"ms_sql_server" | "mssql" | "sqlserver" | "fabric" => Some(KnownAdapter::Mssql),
|
||||
@@ -1098,6 +1098,10 @@ mod tests {
|
||||
KnownAdapter::from_resource_type("fabric"),
|
||||
Some(KnownAdapter::Mssql)
|
||||
);
|
||||
assert_eq!(
|
||||
KnownAdapter::from_resource_type("google_service_account"),
|
||||
Some(KnownAdapter::Bigquery)
|
||||
);
|
||||
}
|
||||
|
||||
// `PartialEq` covers the carried name, so two spellings of one adapter must
|
||||
|
||||
@@ -5881,15 +5881,18 @@ async fn handle_code_execution_job(
|
||||
// preview only, from its `RawCode`; a job queued by a server predating that may still carry
|
||||
// a caller's, and honoring it on a deployed runnable would run caller code as that runnable
|
||||
// (and as its `on_behalf_of` identity).
|
||||
let modules = modules_from_data.clone().or_else(|| {
|
||||
let args = job.args.as_ref().filter(|_| job.kind == JobKind::Preview);
|
||||
args.and_then(|args| {
|
||||
args.get(MODULES_ARG).and_then(|raw| {
|
||||
serde_json::from_str::<std::collections::HashMap<String, ScriptModule>>(raw.get())
|
||||
.ok()
|
||||
})
|
||||
})
|
||||
});
|
||||
let modules_from_args;
|
||||
let modules = if modules_from_data.is_some() {
|
||||
modules_from_data
|
||||
} else {
|
||||
modules_from_args = job
|
||||
.args
|
||||
.as_ref()
|
||||
.filter(|_| job.kind == JobKind::Preview)
|
||||
.and_then(|args| args.get(MODULES_ARG))
|
||||
.and_then(|raw| serde_json::from_str(raw.get()).ok());
|
||||
&modules_from_args
|
||||
};
|
||||
|
||||
try_validate_schema(
|
||||
job,
|
||||
@@ -5924,7 +5927,7 @@ async fn handle_code_execution_job(
|
||||
envs,
|
||||
codebase,
|
||||
lock,
|
||||
&modules,
|
||||
modules,
|
||||
false,
|
||||
in_pipeline,
|
||||
))
|
||||
|
||||
@@ -6166,6 +6166,7 @@ pub(super) fn is_simple_modules(
|
||||
&& modules[0].suspend.is_none()
|
||||
&& modules[0].cache_ttl.is_none()
|
||||
&& modules[0].retry.is_none()
|
||||
&& modules[0].timeout.is_none()
|
||||
&& modules[0].stop_after_if.is_none()
|
||||
&& modules[0].stop_after_all_iters_if.is_none()
|
||||
&& modules[0].skip_if.is_none()
|
||||
@@ -6710,8 +6711,32 @@ pub async fn get_previous_job_result(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{effective_flow_step_timeout, extract_chat_message_from_flow_result};
|
||||
use super::{
|
||||
effective_flow_step_timeout, extract_chat_message_from_flow_result, is_simple_modules,
|
||||
};
|
||||
use serde_json::{json, value::to_raw_value};
|
||||
use windmill_common::flows::FlowModule;
|
||||
|
||||
// The simple-loop fast path reads step settings off the loop module, so a loop body whose
|
||||
// step has its own timeout must take the full path or that timeout is dropped.
|
||||
#[test]
|
||||
fn loop_body_with_step_timeout_is_not_simple() {
|
||||
let step = |extra: serde_json::Value| -> FlowModule {
|
||||
let mut m = json!({
|
||||
"id": "b",
|
||||
"value": {"type": "rawscript", "language": "python3", "content": "", "input_transforms": {}}
|
||||
});
|
||||
m.as_object_mut()
|
||||
.unwrap()
|
||||
.extend(extra.as_object().unwrap().clone());
|
||||
serde_json::from_value(m).unwrap()
|
||||
};
|
||||
assert!(is_simple_modules(&vec![step(json!({}))], None));
|
||||
assert!(!is_simple_modules(
|
||||
&vec![step(json!({"timeout": {"type": "static", "value": 300}}))],
|
||||
None
|
||||
));
|
||||
}
|
||||
|
||||
// A `<= 0` step timeout override must behave as "no override" and inherit the referenced
|
||||
// script's timeout, not collapse to a 0-second (instant-kill) timeout. A positive override
|
||||
|
||||
+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.851.0";
|
||||
export const VERSION = "v1.853.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@windmill-labs/chat-sdk",
|
||||
"version": "1.851.0",
|
||||
"version": "1.853.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@windmill-labs/chat-sdk",
|
||||
"version": "1.851.0",
|
||||
"version": "1.853.0",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@ai-sdk/react": "^4.0.102",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@windmill-labs/chat-sdk",
|
||||
"description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app",
|
||||
"version": "1.851.0",
|
||||
"version": "1.853.0",
|
||||
"author": "Ruben Fiszel",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme",
|
||||
|
||||
@@ -9,7 +9,7 @@ import { stringify as yamlStringify } from "yaml";
|
||||
import { yamlParseFile } from "../../utils/yaml.ts";
|
||||
import { readTextFile, validateRequiredArgs } from "../../utils/utils.ts";
|
||||
import * as wmill from "../../../gen/services.gen.ts";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { buildFolderPath, getMetadataFileName, loadNonDottedPathsSetting } from "../../utils/resource_folders.ts";
|
||||
|
||||
import { requireLogin } from "../../core/auth.ts";
|
||||
@@ -651,6 +651,10 @@ async function preview(
|
||||
const cwdBeforeConfig = process.cwd();
|
||||
if (useLocalPathScripts) {
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
} else {
|
||||
// --remote skips the config read, but resolving the flow folder still
|
||||
// needs the configured suffix (nonDottedPaths).
|
||||
await loadNonDottedPathsSetting();
|
||||
}
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
@@ -660,26 +664,7 @@ async function preview(
|
||||
const argPath = flowPath;
|
||||
flowPath = toSyncRootRelativePath(flowPath, cwdBeforeConfig);
|
||||
|
||||
// Normalize path - ensure it's a directory path to a .flow or __flow folder
|
||||
const isFlowDir = flowPath.endsWith(".flow") || flowPath.endsWith(".flow" + SEP)
|
||||
|| flowPath.endsWith("__flow") || flowPath.endsWith("__flow" + SEP);
|
||||
if (!isFlowDir) {
|
||||
// Check if it's a flow.yaml file
|
||||
if (flowPath.endsWith("flow.yaml") || flowPath.endsWith("flow.json")) {
|
||||
// Use dirname so a bare "flow.yaml" (no parent dir) becomes "."
|
||||
// instead of "" — the latter, after appending SEP below, becomes "/"
|
||||
// and silently reads from filesystem root.
|
||||
flowPath = dirname(flowPath);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Flow path must be a .flow/__flow directory or a flow.yaml file"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!flowPath.endsWith(SEP)) {
|
||||
flowPath += SEP;
|
||||
}
|
||||
flowPath = resolveFlowFolderArg(flowPath);
|
||||
|
||||
// The flow's windmill path (e.g. "f/cli_smoke/myrelflow"). It is what the
|
||||
// preview job runs under, and the anchor for relative-import resolution:
|
||||
@@ -852,20 +837,13 @@ async function previewStep(
|
||||
// Falls through here only when the deployed PathScript is what we want —
|
||||
// either --remote was passed, or no local file exists for this path.
|
||||
log.info(colors.yellow(`Previewing step '${stepId}' (script ${moduleValue.path})...`));
|
||||
const script = moduleValue.hash
|
||||
? await wmill.getScriptByHash({
|
||||
workspace: workspace.workspaceId,
|
||||
hash: moduleValue.hash,
|
||||
})
|
||||
: await wmill.getScriptByPath({
|
||||
workspace: workspace.workspaceId,
|
||||
path: moduleValue.path,
|
||||
});
|
||||
const script = await loadStepScript(workspace.workspaceId, moduleValue);
|
||||
jobId = await wmill.runScriptPreview({
|
||||
workspace: workspace.workspaceId,
|
||||
requestBody: {
|
||||
content: script.content,
|
||||
language: script.language as any,
|
||||
lock: script.lock,
|
||||
// Anchor to the script's own deployed path so its relative imports
|
||||
// resolve against the workspace tree (or temp_script_refs).
|
||||
path: moduleValue.path,
|
||||
@@ -916,6 +894,54 @@ async function previewStep(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `flow preview` argument to its local flow folder, with a trailing
|
||||
* separator. Accepts the folder itself, its flow.yaml, or the flow's Windmill
|
||||
* path (`f/x/my_flow`), which other commands take without the folder suffix.
|
||||
*/
|
||||
export function resolveFlowFolderArg(flowPath: string): string {
|
||||
const withSep = (p: string) => (p.endsWith(SEP) ? p : p + SEP);
|
||||
const isFlowDir = flowPath.endsWith(".flow") || flowPath.endsWith(".flow" + SEP)
|
||||
|| flowPath.endsWith("__flow") || flowPath.endsWith("__flow" + SEP);
|
||||
if (isFlowDir) {
|
||||
return withSep(flowPath);
|
||||
}
|
||||
if (flowPath.endsWith("flow.yaml") || flowPath.endsWith("flow.json")) {
|
||||
// dirname, so a bare "flow.yaml" becomes "." rather than "", which would
|
||||
// turn into "/" and read from the filesystem root.
|
||||
return withSep(dirname(flowPath));
|
||||
}
|
||||
const bare = flowPath.endsWith(SEP) ? flowPath.slice(0, -SEP.length) : flowPath;
|
||||
// The configured suffix first, then the other one: a workspace can hold both.
|
||||
const candidates = [...new Set([buildFolderPath(bare, "flow"), bare + ".flow", bare + "__flow"])];
|
||||
const found = candidates.find((c) => existsSync(c) && statSync(c).isDirectory());
|
||||
if (found) {
|
||||
return withSep(found);
|
||||
}
|
||||
throw new Error(
|
||||
`Flow path must be a .flow/__flow directory, a flow.yaml file, or the Windmill path of a local flow; found none of ${candidates.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the code and lockfile a `script` step runs, the way a whole-flow run
|
||||
* resolves them: hub paths (`hub/<id>/...`) from the hub, others from the
|
||||
* workspace.
|
||||
*/
|
||||
export async function loadStepScript(
|
||||
workspaceId: string,
|
||||
moduleValue: { path: string; hash?: string },
|
||||
): Promise<{ content: string; language: string; lock?: string }> {
|
||||
if (moduleValue.path.startsWith("hub/")) {
|
||||
const hub = await wmill.getHubScriptByPath({ path: moduleValue.path });
|
||||
return { content: hub.content, language: hub.language, lock: hub.lockfile };
|
||||
}
|
||||
const script = moduleValue.hash
|
||||
? await wmill.getScriptByHash({ workspace: workspaceId, hash: moduleValue.hash })
|
||||
: await wmill.getScriptByPath({ workspace: workspaceId, path: moduleValue.path });
|
||||
return { content: script.content, language: script.language, lock: script.lock };
|
||||
}
|
||||
|
||||
// Strip the `.flow`/`__flow` directory suffix to recover the flow's logical
|
||||
// Windmill path. Workspaces with nonDottedPaths use `__flow`; the default
|
||||
// uses `.flow`. A previous version used `indexOf(".flow")` which returned -1
|
||||
@@ -1204,7 +1230,7 @@ const command = new Command()
|
||||
.action(run as any)
|
||||
.command(
|
||||
"preview",
|
||||
"preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default. Pass --step <id> to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow)."
|
||||
"preview a local flow without deploying it. <flow_path> is the flow's Windmill path (f/x/my_flow), its .flow/__flow folder or its flow.yaml. Runs the flow definition from local files and uses local PathScripts by default. Pass --step <id> to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow)."
|
||||
)
|
||||
.arguments("<flow_path:string>")
|
||||
.option(
|
||||
|
||||
+21
-6
@@ -74,7 +74,7 @@ export function formatFolderInstructions(
|
||||
}
|
||||
|
||||
// What the review asks of the model and how its answer is read, for every place it runs: the
|
||||
// deployment request panel here, and `wmill review` on a git sync pull request, which carries
|
||||
// deployment request panel and the drafts review here, and `wmill review` on a git sync pull request, which carries
|
||||
// this module through cli/generate-ai-review.ts. Keep it free of `$lib` imports; the
|
||||
// staleness test in cli/test/ai_review_bundle_unit.test.ts fails when the CLI's copy drifts.
|
||||
|
||||
@@ -109,32 +109,47 @@ export const TOTAL_CHAR_BUDGET = 160_000
|
||||
// Under the Anthropic SDK's non-streaming pre-flight max_tokens limit.
|
||||
export const MAX_OUTPUT_TOKENS = 8_000
|
||||
|
||||
/** What is being reviewed: a fork's deployment request, or a pull request on a git sync repo. */
|
||||
export type ReviewSubject = 'deployment_request' | 'pull_request'
|
||||
/**
|
||||
* What is being reviewed: a fork's deployment request, a workspace's undeployed drafts, or a
|
||||
* pull request on a git sync repo.
|
||||
*/
|
||||
export type ReviewSubject = 'deployment_request' | 'draft' | 'pull_request'
|
||||
|
||||
const INTRO: Record<ReviewSubject, string> = {
|
||||
deployment_request:
|
||||
'You review a Windmill deployment request: a set of changes made in a fork workspace that will be merged into its parent workspace. Each changed item is given with its parent version (what is deployed now) and its fork version (what will be deployed).',
|
||||
draft:
|
||||
'You review drafts in a Windmill workspace: changes saved to items of the workspace but not deployed yet. Each changed item is given with its deployed version (what is deployed now) and its draft version (what will be deployed).',
|
||||
pull_request:
|
||||
"You review a pull request on a git repository that Windmill's git sync keeps in step with a workspace: merging it deploys its changes. Each changed item is given with the files that make it up in the repository, each as a unified diff of the base branch (what is deployed now) against the pull request (what will be deployed). Every diff line starts with its line number: R<n> for line n of the pull request's version of the file, L<n> for line n of the base version, then + for an added line, - for a removed one and a space for unchanged context."
|
||||
}
|
||||
|
||||
const ITEM_ANSWER_SHAPE =
|
||||
'{"summary": "<one or two sentences on what the change does and its overall risk>", "findings": [{"kind": "<item kind as given>", "path": "<item path as given>", "severity": "high" | "medium" | "low", "comment": "<the issue and what to do about it, under 80 words>"}]}'
|
||||
|
||||
const ANSWER_SHAPE: Record<ReviewSubject, string> = {
|
||||
deployment_request:
|
||||
'{"summary": "<one or two sentences on what the change does and its overall risk>", "findings": [{"kind": "<item kind as given>", "path": "<item path as given>", "severity": "high" | "medium" | "low", "comment": "<the issue and what to do about it, under 80 words>"}]}',
|
||||
deployment_request: ITEM_ANSWER_SHAPE,
|
||||
draft: ITEM_ANSWER_SHAPE,
|
||||
pull_request:
|
||||
'{"summary": "<one or two sentences on what the change does and its overall risk>", "findings": [{"kind": "<item kind as given>", "path": "<item path as given>", "file": "<the file the issue is in, as given>", "line": "<the R or L line number the issue is on, as given, such as R12, or null for the file as a whole>", "severity": "high" | "medium" | "low", "comment": "<the issue and what to do about it, under 80 words>"}]}'
|
||||
}
|
||||
|
||||
const SOURCE: Record<ReviewSubject, string> = {
|
||||
deployment_request: 'The parent workspace',
|
||||
draft: 'The workspace',
|
||||
pull_request: "The repository's base branch"
|
||||
}
|
||||
|
||||
const APPROVED: Record<ReviewSubject, string> = {
|
||||
deployment_request: 'the merge',
|
||||
draft: 'the deployment',
|
||||
pull_request: 'the merge'
|
||||
}
|
||||
|
||||
export function reviewSystemPrompt(subject: ReviewSubject): string {
|
||||
return `${INTRO[subject]} An item can be a script, flow, app, schedule, trigger, folder, resource type, datatable migration, variable or resource.
|
||||
|
||||
Report only issues a reviewer should act on before approving the merge:
|
||||
Report only issues a reviewer should act on before approving ${APPROVED[subject]}:
|
||||
- bugs and logic errors introduced by the change
|
||||
- breaking changes: removed or renamed inputs, changed input types, changed outputs that callers may rely on
|
||||
- risky operational changes: schedules retimed, triggers pointed at a different script or flow, folder owners or permissions, app policies, destructive migrations
|
||||
|
||||
@@ -14,7 +14,7 @@ import { colors } from "@cliffy/ansi/colors";
|
||||
import { Command } from "@cliffy/command";
|
||||
import { Confirm } from "@cliffy/prompt/confirm";
|
||||
import * as log from "../../core/log.ts";
|
||||
import { SETTINGS_MANAGED_ONLY_WHEN_PRESENT } from "../../core/settings.ts";
|
||||
import { RETIRED_SETTINGS, SETTINGS_MANAGED_ONLY_WHEN_PRESENT } from "../../core/settings.ts";
|
||||
import * as path from "node:path";
|
||||
import { sep as SEP } from "node:path";
|
||||
import {
|
||||
@@ -2606,6 +2606,10 @@ const isWorkspaceSettingsFile = (p: string) =>
|
||||
function stripUnappliedSettingsFields(local: any, remote: any) {
|
||||
delete local?.name;
|
||||
delete remote?.name;
|
||||
for (const key of RETIRED_SETTINGS) {
|
||||
delete local?.[key];
|
||||
delete remote?.[key];
|
||||
}
|
||||
for (const key of ["operator_settings", ...SETTINGS_MANAGED_ONLY_WHEN_PRESENT]) {
|
||||
if (local?.[key] === undefined) delete remote?.[key];
|
||||
}
|
||||
|
||||
@@ -338,14 +338,14 @@ export async function pushNativeTrigger(
|
||||
}
|
||||
}
|
||||
|
||||
const triggerTemplates: Record<TriggerType, Record<string, any>> = {
|
||||
// Scaffold only fields the backend reads: `mode` supersedes the deprecated `enabled`, and
|
||||
// `request_type` the deprecated `is_async`.
|
||||
export const triggerTemplates: Record<TriggerType, Record<string, any>> = {
|
||||
http: {
|
||||
script_path: "",
|
||||
is_flow: false,
|
||||
route_path: "",
|
||||
http_method: "get",
|
||||
is_async: false,
|
||||
requires_auth: true,
|
||||
request_type: "sync",
|
||||
authentication_method: "none",
|
||||
is_static_website: false,
|
||||
@@ -360,7 +360,7 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = {
|
||||
filters: [],
|
||||
can_return_message: false,
|
||||
can_return_error_result: false,
|
||||
enabled: false,
|
||||
mode: "disabled",
|
||||
},
|
||||
kafka: {
|
||||
script_path: "",
|
||||
@@ -369,7 +369,7 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = {
|
||||
group_id: "",
|
||||
topics: [],
|
||||
filters: [],
|
||||
enabled: false,
|
||||
mode: "disabled",
|
||||
},
|
||||
nats: {
|
||||
script_path: "",
|
||||
@@ -377,7 +377,7 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = {
|
||||
nats_resource_path: "",
|
||||
subjects: [],
|
||||
use_jetstream: false,
|
||||
enabled: false,
|
||||
mode: "disabled",
|
||||
},
|
||||
postgres: {
|
||||
script_path: "",
|
||||
@@ -385,21 +385,21 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = {
|
||||
postgres_resource_path: "",
|
||||
publication_name: "",
|
||||
replication_slot_name: "",
|
||||
enabled: false,
|
||||
mode: "disabled",
|
||||
},
|
||||
mqtt: {
|
||||
script_path: "",
|
||||
is_flow: false,
|
||||
mqtt_resource_path: "",
|
||||
subscribe_topics: [],
|
||||
enabled: false,
|
||||
mode: "disabled",
|
||||
},
|
||||
amqp: {
|
||||
script_path: "",
|
||||
is_flow: false,
|
||||
amqp_resource_path: "",
|
||||
queue_name: "",
|
||||
enabled: false,
|
||||
mode: "disabled",
|
||||
},
|
||||
sqs: {
|
||||
script_path: "",
|
||||
@@ -407,7 +407,7 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = {
|
||||
queue_url: "",
|
||||
aws_resource_path: "",
|
||||
aws_auth_resource_type: "credentials",
|
||||
enabled: false,
|
||||
mode: "disabled",
|
||||
},
|
||||
gcp: {
|
||||
script_path: "",
|
||||
@@ -417,7 +417,7 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = {
|
||||
subscription_id: "",
|
||||
delivery_type: "pull",
|
||||
subscription_mode: "create_update",
|
||||
enabled: false,
|
||||
mode: "disabled",
|
||||
},
|
||||
azure: {
|
||||
script_path: "",
|
||||
@@ -426,13 +426,13 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = {
|
||||
azure_mode: "namespace_pull",
|
||||
scope_resource_id: "",
|
||||
subscription_name: "",
|
||||
enabled: false,
|
||||
mode: "disabled",
|
||||
},
|
||||
email: {
|
||||
script_path: "",
|
||||
is_flow: false,
|
||||
local_part: "",
|
||||
enabled: false,
|
||||
mode: "disabled",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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.851.0";
|
||||
export const VERSION = "1.853.0";
|
||||
|
||||
@@ -72,7 +72,6 @@ export interface SimplifiedSettings {
|
||||
deploy_ui?: any;
|
||||
teams_command_script?: string | null;
|
||||
public_app_execution_limit_per_minute?: number | null;
|
||||
guest_access_enabled?: boolean;
|
||||
guest_jwt_public_key?: string | null;
|
||||
guest_jwt_jwks_url?: string | null;
|
||||
error_handler_fallback_to_instance_alerts?: boolean;
|
||||
@@ -89,7 +88,6 @@ export const SETTINGS_MANAGED_ONLY_WHEN_PRESENT = [
|
||||
"deploy_ui",
|
||||
"teams_command_script",
|
||||
"public_app_execution_limit_per_minute",
|
||||
"guest_access_enabled",
|
||||
"guest_jwt_public_key",
|
||||
"guest_jwt_jwks_url",
|
||||
"error_handler_fallback_to_instance_alerts",
|
||||
@@ -97,6 +95,10 @@ export const SETTINGS_MANAGED_ONLY_WHEN_PRESENT = [
|
||||
"variable_expiration_handler",
|
||||
] as const;
|
||||
|
||||
/** Keys earlier versions wrote that no longer configure anything; the server ignores them,
|
||||
* so a file that still carries one must not show as differing from the workspace. */
|
||||
export const RETIRED_SETTINGS = ["guest_access_enabled"] as const;
|
||||
|
||||
// Legacy settings interface for reading old settings.yaml files
|
||||
interface LegacySimplifiedSettings {
|
||||
auto_invite_enabled?: boolean;
|
||||
|
||||
Generated
+6
-6
File diff suppressed because one or more lines are too long
@@ -140,8 +140,9 @@ describe("wmill review prompt", () => {
|
||||
"encryption_key.yaml": "HEAD-ENCRYPTION-KEY\n",
|
||||
"f/a/notes.prod.resource.yaml": "resource_type: postgresql\nvalue:\n password: PROD-PASSWORD\n",
|
||||
"f/a/files.fileset/x.flow/flow.yaml": "password: FILESET-PASSWORD-2\n",
|
||||
// A pathspec matching every changed file, if git were let read it as one.
|
||||
"*": "star\n",
|
||||
// A pathspec matching every changed file, if git were let read it as one. Windows
|
||||
// forbids `*` in a file name.
|
||||
...(process.platform === "win32" ? {} : { "*": "star\n" }),
|
||||
},
|
||||
(base, head) => {
|
||||
const { system, user, instructions } = buildReviewPrompt(base, head);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* `wmill flow preview` argument resolution and `--step` script loading.
|
||||
*/
|
||||
import { expect, test, describe, beforeEach, afterEach } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { mockServices } from "./mock_services.ts";
|
||||
|
||||
const calls: { fn: string; args: any }[] = [];
|
||||
mockServices({
|
||||
getHubScriptByPath: async (args: any) => {
|
||||
calls.push({ fn: "getHubScriptByPath", args });
|
||||
return {
|
||||
content: "export async function main() { return 'hub' }",
|
||||
language: "deno",
|
||||
lockfile: "hub-lock",
|
||||
};
|
||||
},
|
||||
getScriptByPath: async (args: any) => {
|
||||
calls.push({ fn: "getScriptByPath", args });
|
||||
return { content: "workspace code", language: "bun", lock: "ws-lock" };
|
||||
},
|
||||
getScriptByHash: async (args: any) => {
|
||||
calls.push({ fn: "getScriptByHash", args });
|
||||
return { content: "hashed code", language: "python3", lock: "hash-lock" };
|
||||
},
|
||||
});
|
||||
|
||||
const { resolveFlowFolderArg, loadStepScript } = await import(
|
||||
"../src/commands/flow/flow.ts"
|
||||
);
|
||||
const { setNonDottedPaths } = await import("../src/utils/resource_folders.ts");
|
||||
|
||||
const SEP = path.sep;
|
||||
|
||||
describe("resolveFlowFolderArg", () => {
|
||||
let root: string;
|
||||
let previousCwd: string;
|
||||
|
||||
beforeEach(() => {
|
||||
previousCwd = process.cwd();
|
||||
root = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), "wmill_flow_preview_")),
|
||||
);
|
||||
fs.mkdirSync(path.join(root, "f", "t", "dotted.flow"), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, "f", "t", "nondotted__flow"), { recursive: true });
|
||||
process.chdir(root);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("a Windmill path resolves to its local .flow folder", () => {
|
||||
expect(resolveFlowFolderArg(`f${SEP}t${SEP}dotted`)).toBe(
|
||||
`f${SEP}t${SEP}dotted.flow${SEP}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("a Windmill path resolves to a __flow folder when that is the one on disk", () => {
|
||||
expect(resolveFlowFolderArg(`f${SEP}t${SEP}nondotted`)).toBe(
|
||||
`f${SEP}t${SEP}nondotted__flow${SEP}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("when both folders exist, the configured suffix wins", () => {
|
||||
fs.mkdirSync(path.join(root, "f", "t", "both.flow"), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, "f", "t", "both__flow"), { recursive: true });
|
||||
try {
|
||||
setNonDottedPaths(true);
|
||||
expect(resolveFlowFolderArg(`f${SEP}t${SEP}both`)).toBe(
|
||||
`f${SEP}t${SEP}both__flow${SEP}`,
|
||||
);
|
||||
setNonDottedPaths(false);
|
||||
expect(resolveFlowFolderArg(`f${SEP}t${SEP}both`)).toBe(
|
||||
`f${SEP}t${SEP}both.flow${SEP}`,
|
||||
);
|
||||
} finally {
|
||||
setNonDottedPaths(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("the folder and its flow.yaml keep resolving to the folder", () => {
|
||||
const folder = `f${SEP}t${SEP}dotted.flow${SEP}`;
|
||||
expect(resolveFlowFolderArg(`f${SEP}t${SEP}dotted.flow`)).toBe(folder);
|
||||
expect(resolveFlowFolderArg(folder)).toBe(folder);
|
||||
expect(resolveFlowFolderArg(`${folder}flow.yaml`)).toBe(folder);
|
||||
expect(resolveFlowFolderArg("flow.yaml")).toBe(`.${SEP}`);
|
||||
});
|
||||
|
||||
test("a Windmill path with no local flow folder is refused, naming what was looked for", () => {
|
||||
expect(() => resolveFlowFolderArg(`f${SEP}t${SEP}missing`)).toThrow(
|
||||
`found none of f${SEP}t${SEP}missing.flow, f${SEP}t${SEP}missing__flow`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadStepScript", () => {
|
||||
beforeEach(() => {
|
||||
calls.length = 0;
|
||||
});
|
||||
|
||||
test("a hub path is fetched from the hub, with the hub's lockfile", async () => {
|
||||
const script = await loadStepScript("ws", {
|
||||
path: "hub/29624/slack/send_message_to_channel",
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
fn: "getHubScriptByPath",
|
||||
args: { path: "hub/29624/slack/send_message_to_channel" },
|
||||
},
|
||||
]);
|
||||
expect(script).toEqual({
|
||||
content: "export async function main() { return 'hub' }",
|
||||
language: "deno",
|
||||
lock: "hub-lock",
|
||||
});
|
||||
});
|
||||
|
||||
test("a workspace path is fetched from the workspace, with its deployed lock", async () => {
|
||||
const script = await loadStepScript("ws", { path: "f/t/helper" });
|
||||
expect(calls).toEqual([
|
||||
{ fn: "getScriptByPath", args: { workspace: "ws", path: "f/t/helper" } },
|
||||
]);
|
||||
expect(script).toEqual({ content: "workspace code", language: "bun", lock: "ws-lock" });
|
||||
});
|
||||
|
||||
test("a pinned hash is fetched by hash", async () => {
|
||||
const script = await loadStepScript("ws", { path: "f/t/helper", hash: "abc" });
|
||||
expect(calls).toEqual([
|
||||
{ fn: "getScriptByHash", args: { workspace: "ws", hash: "abc" } },
|
||||
]);
|
||||
expect(script).toEqual({ content: "hashed code", language: "python3", lock: "hash-lock" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* `wmill trigger new` scaffolds only fields the backend reads: `request_type`
|
||||
* rather than the deprecated `is_async`, no `requires_auth` (its column was
|
||||
* renamed to `authentication_method`), and `mode` rather than the deprecated
|
||||
* `enabled`.
|
||||
*/
|
||||
import { expect, test, describe } from "bun:test";
|
||||
import { triggerTemplates } from "../src/commands/trigger/trigger.ts";
|
||||
|
||||
describe("trigger new templates", () => {
|
||||
test("the http template has no is_async or requires_auth", () => {
|
||||
const http = triggerTemplates.http;
|
||||
expect(Object.keys(http)).not.toContain("is_async");
|
||||
expect(Object.keys(http)).not.toContain("requires_auth");
|
||||
expect(http.request_type).toBe("sync");
|
||||
expect(http.authentication_method).toBe("none");
|
||||
});
|
||||
|
||||
test("no template scaffolds the deprecated enabled field", () => {
|
||||
const withEnabled = Object.entries(triggerTemplates)
|
||||
.filter(([, template]) => "enabled" in template)
|
||||
.map(([kind]) => kind);
|
||||
expect(withEnabled).toEqual([]);
|
||||
});
|
||||
|
||||
test("templates that scaffolded a disabled trigger still do, through mode", () => {
|
||||
const modes = Object.fromEntries(
|
||||
Object.entries(triggerTemplates).map(([kind, t]) => [kind, t.mode]),
|
||||
);
|
||||
expect(modes).toEqual({
|
||||
http: undefined,
|
||||
websocket: "disabled",
|
||||
kafka: "disabled",
|
||||
nats: "disabled",
|
||||
postgres: "disabled",
|
||||
mqtt: "disabled",
|
||||
amqp: "disabled",
|
||||
sqs: "disabled",
|
||||
gcp: "disabled",
|
||||
azure: "disabled",
|
||||
email: "disabled",
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -236,7 +236,7 @@ points at the object-storage resource and a DuckLake names its catalog.
|
||||
|
||||
**What a warehouse may point at.** Either a Windmill connection resource whose
|
||||
type `render_profile` translates (`postgresql`, `redshift`, `mysql`, `snowflake`,
|
||||
`snowflake_oauth`, `bigquery`, `gcp_service_account`, `databricks`), or a
|
||||
`snowflake_oauth`, `bigquery`, `google_service_account`, `gcp_service_account`, `databricks`), or a
|
||||
**`dbt_profile`** resource, whose VALUE IS one entry of that file's `outputs`
|
||||
map — `type` included, nothing lifted out or renamed. A block is copied from a
|
||||
working `profiles.yml` and pasted in, which is the whole point: a type that asked
|
||||
|
||||
@@ -202,8 +202,8 @@ already authors elsewhere must not be charged again. A change that adds no seat
|
||||
- A builder may make an app public (`ExecutionMode::Anonymous`): anyone with the URL runs the
|
||||
runnables its policy names, as the builder. Refusing it is the workspace's call, through the
|
||||
`RestrictAnonymousAppDeployment` protection rule. A `Guest` app is the same with a login in
|
||||
front: anyone the instance's identity provider admits, member or not, once guest access is
|
||||
enabled. `RestrictGuestAppDeployment` refuses it.
|
||||
front: anyone the instance's identity provider admits, member or not.
|
||||
`RestrictGuestAppDeployment` refuses it.
|
||||
- A builder raw app may set `allow_user_resources` on a runnable's inputs, and the bundle picks the
|
||||
`$res:` path it sends. `build_args` resolves that path as the *viewer*, with no prompt, into a
|
||||
job that runs as the builder. Getting a value out takes a guessable path and a readable script
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Profiling the per-job path
|
||||
|
||||
How to find and prove a performance improvement on the job path, which is what any run of a
|
||||
script or flow pays: push, pull, execution, log writes, completion. The method finds candidates
|
||||
by measuring rather than by reading code, and keeps only changes whose effect was measured
|
||||
before and after. A candidate that cannot be measured is not a finding.
|
||||
|
||||
## The harness
|
||||
|
||||
`backend/tests/perf_profile.rs` runs real jobs through the real queue, worker and database, all
|
||||
in the test process. Its tests are `#[ignore]`d, so CI only compiles them. For each workload it
|
||||
reports the following, separately for the idle, push and worker phases:
|
||||
|
||||
- requested allocation bytes and allocation count per job (raw, and with the idle rate subtracted)
|
||||
- allocation sites, sampled once every 128 KiB allocated and once every 2048 allocations. Each
|
||||
site is shown as the deepest Windmill `file:line` plus the library call it allocated in
|
||||
(`LEAF`), and as the call chain of the four deepest Windmill frames (`CHAIN`)
|
||||
- with `PERF_SQL=1`, instead of allocations: every SQL statement, how many times it runs per job,
|
||||
and its database time per job
|
||||
|
||||
Workloads: a deployed bash script, a deployed Bun script, a deployed 3-step identity flow and a
|
||||
deployed 3-step bash flow. To profile another path, add a workload with the same shape: create
|
||||
the script or flow with SQL, then call `profile_workload`. Typical additions are a Python
|
||||
script, a script with many args, a flow with a branch or a loop, or a job with a large result.
|
||||
|
||||
```bash
|
||||
cd backend && source ../.env.local # DATABASE_URL: sqlx::test creates its DBs from it
|
||||
OUT=/tmp/perf && mkdir -p $OUT
|
||||
# allocations and sites
|
||||
PERF_OUT=$OUT PERF_N=30 cargo test --features quickjs --test perf_profile -- --ignored --nocapture --test-threads=1
|
||||
# SQL statements per job (a separate run, see the caveats below)
|
||||
PERF_SQL=1 PERF_OUT=$OUT/sql PERF_N=30 cargo test --features quickjs --test perf_profile -- --ignored --nocapture --test-threads=1
|
||||
grep -h '^===' $OUT/*.txt # the per-job totals, one line per phase
|
||||
```
|
||||
|
||||
Use the same `--features` as the running dev backend so the build reuses its artifacts. One
|
||||
workload takes about 20 s.
|
||||
|
||||
## Reading the output
|
||||
|
||||
- **Bytes per job is the main metric.** It is very repeatable, varying less than 0.1% between
|
||||
runs on the same code, so 3 runs before and 3 after a change are enough. Time per job is noisy
|
||||
in a debug build and is not a measure of anything.
|
||||
- **A site that sits on top of every script workload deserves attention first.** Per-job setup
|
||||
that costs the same however small the job is matters most, because most jobs are small.
|
||||
- **Discount `pin<…>` / `Box::pin` sites.** They are async state machines, which are many times
|
||||
larger without optimisation. Confirm one in release before acting on it.
|
||||
- **SQL statements per job is usually the larger lever.** Each statement is a network round trip
|
||||
in production. Look for statements repeated per job or per step that read something already
|
||||
known (a cached value, a row read earlier in the same job), and for appends that could be
|
||||
merged.
|
||||
|
||||
## A/B a candidate
|
||||
|
||||
1. Run the relevant workload 3 times on `main` and keep the `WORKER` (or `PUSH`) lines.
|
||||
2. Apply the change, then run it 3 times again.
|
||||
3. When the cost is CPU in a library, also confirm it in a release build: a small standalone
|
||||
crate in `/tmp` that pins the same crate version (see `Cargo.lock`), with a counting global
|
||||
allocator and a timing loop. Debug numbers measure allocations reliably, but they do not
|
||||
measure CPU.
|
||||
4. Run the tests that pin the behaviour you touched, and put the before/after table in the PR.
|
||||
|
||||
Changes to job completion, the queue pull or the flow state machine are the critical path. Test
|
||||
them beyond the profiler: the existing suites for that area, concurrency, and older Postgres
|
||||
versions.
|
||||
|
||||
## Caveats
|
||||
|
||||
- **Split DWARF.** In dev builds a frame's location is a relative `./windmill-*/src/…:line`, and
|
||||
inlined frames have bare names (`poll<…>`). The harness keys sites by location for that reason.
|
||||
Do not switch it to keying by symbol name.
|
||||
- **SQL counting changes the allocation numbers.** Once `sqlx::query` events are enabled, sqlx
|
||||
formats a summary for every statement. Never read allocations from a `PERF_SQL=1` run.
|
||||
- **The sampler's own work is excluded.** `IN_SAMPLER` stops the backtrace's allocations from
|
||||
being counted. Keep it if you change how samples are captured, or idle reads tens of MB/s.
|
||||
- **`RUST_BACKTRACE` and `RUST_LIB_BACKTRACE` must be unset (or `0`).** Sampling takes std's
|
||||
backtrace lock, which is not reentrant. With either variable enabled, errors capture
|
||||
backtraces of their own, and an allocation made under that lock can deadlock the sampler.
|
||||
The harness refuses to start when either is set.
|
||||
- **Deployed Bun scripts need a NULL lock.** `''` fails at run time with "expected //bun.lock
|
||||
split pattern".
|
||||
- **Disk.** The test build shares `target/` with the dev backend, but the first build also
|
||||
compiles the test-only dependencies. Check `df -h /` first.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.851.0",
|
||||
"version": "1.853.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.851.0",
|
||||
"version": "1.853.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.851.0",
|
||||
"version": "1.853.0",
|
||||
"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",
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
import { userStore, userWorkspaces } from '$lib/stores'
|
||||
import { capabilitiesIn } from '$lib/roleCapabilities'
|
||||
import { childWorkspaceNoun } from '$lib/utils/devWorkspaceLabel'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import AiReview from './deploymentRequest/AiReview.svelte'
|
||||
import { draftReviewItem } from './deploymentRequest/draftReviewItem'
|
||||
|
||||
interface Props {
|
||||
currentWorkspaceId: string
|
||||
@@ -456,6 +459,13 @@
|
||||
selectedItems = []
|
||||
}
|
||||
|
||||
// Your own drafts only: another user's draft is view-only, and reading one returns yours.
|
||||
const reviewItems = $derived(
|
||||
visibleItems
|
||||
.filter((i) => selectedItems.includes(i.key) && i.mine && i.draftKind !== 'data_pipeline')
|
||||
.map((i) => draftReviewItem(i.draftKind, i.path, currentWorkspaceId, i.draft_only))
|
||||
)
|
||||
|
||||
// --- Diff ---
|
||||
let diffDrawer: DiffDrawer | undefined = $state(undefined)
|
||||
let isFlow = $state(false)
|
||||
@@ -480,7 +490,13 @@
|
||||
mode: 'simple',
|
||||
original: deployed as any,
|
||||
current: draft as any,
|
||||
title: 'Deployed → Draft'
|
||||
title: 'Deployed → Draft',
|
||||
aiReview: {
|
||||
workspaceId: currentWorkspaceId,
|
||||
kind: item.draftKind,
|
||||
path: item.path,
|
||||
added: item.draft_only
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -912,35 +928,45 @@
|
||||
{/snippet}
|
||||
|
||||
{#snippet footer()}
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
destructive
|
||||
disabled={discardableCount === 0 || deploying || discarding}
|
||||
loading={discarding}
|
||||
startIcon={{ icon: Undo2 }}
|
||||
onClick={onDiscardSelectedClick}
|
||||
>
|
||||
Discard {discardableCount} draft{discardableCount !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={deployableCount === 0 || deploying || discarding || !deployPerm.ok}
|
||||
title={!deployPerm.ok ? deployPerm.reason : undefined}
|
||||
loading={deploying}
|
||||
onClick={deploySelected}
|
||||
>
|
||||
Deploy {deployableCount} draft{deployableCount !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
destructive
|
||||
disabled={discardableCount === 0 || deploying || discarding}
|
||||
loading={discarding}
|
||||
startIcon={{ icon: Undo2 }}
|
||||
onClick={onDiscardSelectedClick}
|
||||
>
|
||||
Discard {discardableCount} draft{discardableCount !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={deployableCount === 0 || deploying || discarding || !deployPerm.ok}
|
||||
title={!deployPerm.ok ? deployPerm.reason : undefined}
|
||||
loading={deploying}
|
||||
onClick={deploySelected}
|
||||
>
|
||||
Deploy {deployableCount} draft{deployableCount !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
</div>
|
||||
{#if !deployPerm.ok}
|
||||
<span class="text-xs text-yellow-600">{deployPerm.reason}</span>
|
||||
{:else if undeployableSelectedCount > 0}
|
||||
<span class="text-xs text-secondary">
|
||||
{undeployableSelectedCount} selected draft{undeployableSelectedCount !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
can't be deployed (no write permission on the path) but can still be discarded
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !deployPerm.ok}
|
||||
<span class="text-xs text-yellow-600">{deployPerm.reason}</span>
|
||||
{:else if undeployableSelectedCount > 0}
|
||||
<span class="text-xs text-secondary">
|
||||
{undeployableSelectedCount} selected draft{undeployableSelectedCount !== 1 ? 's' : ''}
|
||||
can't be deployed (no write permission on the path) but can still be discarded
|
||||
</span>
|
||||
{#if $copilotInfo.enabled}
|
||||
<AiReview
|
||||
scope={{ kind: 'drafts', workspaceId: currentWorkspaceId, items: reviewItems }}
|
||||
source="drafts"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -1854,9 +1854,12 @@
|
||||
<!-- The request form and an open request carry their own review. -->
|
||||
{#if mergeIntoParent && $copilotInfo.enabled && !hasOpenDeploymentRequest && !deploymentRequestPanel?.isDialogOpen()}
|
||||
<AiReview
|
||||
diffs={selectedDiffs}
|
||||
forkWorkspaceId={currentWorkspaceId}
|
||||
{parentWorkspaceId}
|
||||
scope={{
|
||||
kind: 'deployment',
|
||||
diffs: selectedDiffs,
|
||||
forkWorkspaceId: currentWorkspaceId,
|
||||
parentWorkspaceId
|
||||
}}
|
||||
source="selection"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import type { Script } from '$lib/gen'
|
||||
import Select from './select/Select.svelte'
|
||||
import type { DiffVersionOption } from './diff_drawer'
|
||||
import type { DiffAiReview, DiffVersionOption } from './diff_drawer'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import AiReview from './deploymentRequest/AiReview.svelte'
|
||||
import type { ReviewScope } from './deploymentRequest/aiReview'
|
||||
|
||||
type DiffData = {
|
||||
lang?: string
|
||||
@@ -36,6 +39,25 @@
|
||||
|
||||
let diffViewer: Drawer | undefined = $state(undefined)
|
||||
|
||||
let aiReviewScope: ReviewScope | undefined = $state(undefined)
|
||||
/** Normal mode: what the review is rebuilt from when the version picker swaps the left side. */
|
||||
let aiReviewTarget: { review: DiffAiReview; current: Value } | undefined = undefined
|
||||
|
||||
function reviewScope(review: DiffAiReview, before: Value, after: Value): ReviewScope {
|
||||
return {
|
||||
kind: 'drafts',
|
||||
workspaceId: review.workspaceId,
|
||||
items: [
|
||||
{
|
||||
kind: review.kind,
|
||||
path: review.path,
|
||||
change: review.added ? 'added' : 'modified',
|
||||
load: async () => ({ before, after })
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
restoreDeployed?: () => Promise<void>
|
||||
isFlow?: boolean
|
||||
@@ -77,6 +99,8 @@
|
||||
if (token == null) openingToken++
|
||||
data = undefined
|
||||
diffType = undefined
|
||||
aiReviewScope = undefined
|
||||
aiReviewTarget = undefined
|
||||
diffViewer?.openDrawer()
|
||||
}
|
||||
|
||||
@@ -111,6 +135,8 @@
|
||||
loadingVersion = false
|
||||
data = undefined
|
||||
diffType = undefined
|
||||
aiReviewScope = undefined
|
||||
aiReviewTarget = undefined
|
||||
diffViewer?.closeDrawer()
|
||||
}
|
||||
|
||||
@@ -209,6 +235,11 @@
|
||||
deployed: prepareDiff(value),
|
||||
deployedLabel: opt?.isHead ? headLabel : opt?.label
|
||||
}
|
||||
// The review covers the version on screen; a new scope also drops a result for the
|
||||
// previous one.
|
||||
if (aiReviewTarget) {
|
||||
aiReviewScope = reviewScope(aiReviewTarget.review, value, aiReviewTarget.current)
|
||||
}
|
||||
} catch (e: any) {
|
||||
// The snap-back would otherwise be the only sign the version never loaded.
|
||||
if (generation === versionLoadGeneration) {
|
||||
@@ -270,6 +301,7 @@
|
||||
current: Value
|
||||
defaultDiffType?: 'deployed' | 'draft'
|
||||
button?: { text: string; onClick: () => void }
|
||||
aiReview?: DiffAiReview
|
||||
}
|
||||
| {
|
||||
mode: 'simple'
|
||||
@@ -277,6 +309,7 @@
|
||||
current: Value
|
||||
title: string
|
||||
button?: { text: string; onClick: () => void }
|
||||
aiReview?: DiffAiReview
|
||||
},
|
||||
token?: number
|
||||
) {
|
||||
@@ -284,6 +317,8 @@
|
||||
// caller with none is taking the drawer, so it claims one.
|
||||
if (token != null && token !== openingToken) return
|
||||
if (token == null) openingToken++
|
||||
aiReviewScope = undefined
|
||||
aiReviewTarget = undefined
|
||||
if (diff.mode === 'normal') {
|
||||
const {
|
||||
deployed,
|
||||
@@ -326,6 +361,13 @@
|
||||
// The draft-vs-current view (and its tab) is obsolete — always show the
|
||||
// deployed-vs-current diff.
|
||||
diffType = 'deployed'
|
||||
if (diff.aiReview) {
|
||||
aiReviewTarget = {
|
||||
review: { ...diff.aiReview, added: diff.aiReview.added || !!deployed.draft_only },
|
||||
current
|
||||
}
|
||||
aiReviewScope = reviewScope(aiReviewTarget.review, deployed, current)
|
||||
}
|
||||
} else {
|
||||
const { original, current, title, button } = diff
|
||||
data = {
|
||||
@@ -336,6 +378,7 @@
|
||||
button
|
||||
}
|
||||
diffType = 'custom'
|
||||
if (diff.aiReview) aiReviewScope = reviewScope(diff.aiReview, original, current)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -343,6 +386,14 @@
|
||||
<Drawer bind:this={diffViewer} size="1200px" on:close>
|
||||
<DrawerContent title="Diff" on:close={diffViewer.closeDrawer}>
|
||||
<div class="flex flex-col gap-4 h-full">
|
||||
{#if data && aiReviewScope && $copilotInfo.enabled}
|
||||
<div class="max-h-[40vh] overflow-y-auto shrink-0">
|
||||
<!-- A new diff is a new review: a result never carries over to it. -->
|
||||
{#key aiReviewScope}
|
||||
<AiReview scope={aiReviewScope} source="draft_diff" />
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
{#if data}
|
||||
<!-- Outside the `contentType` check on purpose: with no differences against
|
||||
the head, picking an older version is exactly how the reader finds one,
|
||||
|
||||
@@ -178,7 +178,12 @@
|
||||
mode: 'simple',
|
||||
title: `${fullLabel(owner)}'s draft vs deployed`,
|
||||
original: deployed,
|
||||
current: draftValue as any
|
||||
current: draftValue as any,
|
||||
// Your own draft only: a fix made from the review edits your draft, not theirs.
|
||||
aiReview:
|
||||
currentUsername && owner.username === currentUsername
|
||||
? { workspaceId: workspace, kind: itemKind, path, added: draft_only }
|
||||
: undefined
|
||||
})
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Could not load draft: ${e.body ?? e.message}`, true)
|
||||
|
||||
@@ -1357,7 +1357,10 @@
|
||||
...currentFlow,
|
||||
path: $pathStore,
|
||||
draft_triggers: currentDraftTriggers
|
||||
}
|
||||
},
|
||||
aiReview: opWorkspace
|
||||
? { workspaceId: opWorkspace, kind: 'flow', path: $pathStore }
|
||||
: undefined
|
||||
},
|
||||
opening
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import { fade } from 'svelte/transition'
|
||||
import { classNames } from '$lib/utils'
|
||||
import { apiErrorMessage, classNames } from '$lib/utils'
|
||||
import { AlertTriangle, CornerDownLeft, Loader2 } from 'lucide-svelte'
|
||||
import { UserService, FolderService } from '$lib/gen'
|
||||
import type { WorkspaceOffboardPreview } from '$lib/gen'
|
||||
@@ -99,7 +99,7 @@
|
||||
wsConfigs[workspace_id] = config
|
||||
}
|
||||
} catch (e) {
|
||||
sendUserToast('Failed to load offboard preview', true)
|
||||
sendUserToast(`Failed to load offboard preview: ${apiErrorMessage(e)}`, true)
|
||||
onClose()
|
||||
} finally {
|
||||
loading = false
|
||||
@@ -180,7 +180,7 @@
|
||||
onComplete()
|
||||
}
|
||||
} catch (e) {
|
||||
sendUserToast(`Offboarding failed: ${e}`, true)
|
||||
sendUserToast(`Offboarding failed: ${apiErrorMessage(e)}`, true)
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
|
||||
@@ -1156,7 +1156,10 @@
|
||||
})
|
||||
},
|
||||
draft: savedScript['draft'],
|
||||
current
|
||||
current,
|
||||
aiReview: opWorkspace
|
||||
? { workspaceId: opWorkspace, kind: 'script', path: script.path }
|
||||
: undefined
|
||||
},
|
||||
opening
|
||||
)
|
||||
|
||||
@@ -813,7 +813,6 @@
|
||||
loading={guestLoading}
|
||||
onLoadMore={() =>
|
||||
loadGuestPage(Math.floor((guestList?.guests.length ?? 0) / guestPerPage) + 1)}
|
||||
onInstanceSwitch={() => loadGuestPage(1)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import { fade } from 'svelte/transition'
|
||||
import { classNames } from '$lib/utils'
|
||||
import { apiErrorMessage, classNames } from '$lib/utils'
|
||||
import { AlertTriangle, CornerDownLeft, Loader2 } from 'lucide-svelte'
|
||||
import { UserService, FolderService } from '$lib/gen'
|
||||
import type { OffboardPreview } from '$lib/gen/types.gen'
|
||||
@@ -90,7 +90,7 @@
|
||||
doReassign = false
|
||||
}
|
||||
} catch (e) {
|
||||
sendUserToast('Failed to load offboard preview', true)
|
||||
sendUserToast(`Failed to load offboard preview: ${apiErrorMessage(e)}`, true)
|
||||
onClose()
|
||||
} finally {
|
||||
loading = false
|
||||
@@ -128,7 +128,7 @@
|
||||
onComplete()
|
||||
}
|
||||
} catch (e) {
|
||||
sendUserToast(`Offboarding failed: ${e}`, true)
|
||||
sendUserToast(`Offboarding failed: ${apiErrorMessage(e)}`, true)
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
|
||||
@@ -758,7 +758,10 @@
|
||||
policy,
|
||||
custom_path: customPath,
|
||||
labels
|
||||
}
|
||||
},
|
||||
aiReview: $workspaceStore
|
||||
? { workspaceId: $workspaceStore, kind: 'app', path: savedApp.path }
|
||||
: undefined
|
||||
},
|
||||
opening
|
||||
)
|
||||
@@ -904,6 +907,9 @@
|
||||
custom_path: customPath,
|
||||
labels
|
||||
},
|
||||
aiReview: $workspaceStore
|
||||
? { workspaceId: $workspaceStore, kind: 'app', path: savedApp.path }
|
||||
: undefined,
|
||||
button: {
|
||||
text: 'Looks good, deploy',
|
||||
onClick: () => {
|
||||
|
||||
@@ -122,10 +122,6 @@
|
||||
? 'guest'
|
||||
: 'publisher'
|
||||
)
|
||||
// Undefined until loaded. An app can be set to `guest` while the workspace has
|
||||
// guests off, in which case the mode is stored but inert -- say so rather than
|
||||
// letting the publisher believe the app is open.
|
||||
let guestAccessEnabled: boolean | undefined = $state(undefined)
|
||||
let guestUsage: GuestUsage | undefined = $state(undefined)
|
||||
// Whether the deployment can have guests at all; off, the mode is not on offer. The
|
||||
// backend decides; the hostname stands in until it has answered, the shared cloud
|
||||
@@ -136,9 +132,6 @@
|
||||
const ws = opWs
|
||||
if (ws === undefined) return
|
||||
untrack(() => {
|
||||
WorkspaceService.getPublicSettings({ workspace: ws })
|
||||
.then((s) => (guestAccessEnabled = s.guest_access_enabled))
|
||||
.catch(() => (guestAccessEnabled = undefined))
|
||||
WorkspaceService.getGuestUsage({ workspace: ws })
|
||||
.then((u) => (guestUsage = u))
|
||||
.catch(() => (guestUsage = undefined))
|
||||
@@ -553,14 +546,6 @@
|
||||
{#if !guestsAvailable}
|
||||
Guests are not available on Windmill Cloud, so this app still admits members only.
|
||||
They require a self-hosted instance or a dedicated Windmill Cloud deployment.
|
||||
{:else if guestUsage && !guestUsage.instance_enabled}
|
||||
A superadmin has turned guests off for this instance, so this app still admits members
|
||||
only.
|
||||
{:else if guestAccessEnabled === undefined}
|
||||
Checking whether this workspace allows guests…
|
||||
{:else if guestAccessEnabled === false}
|
||||
Guests are turned off for this workspace, so this app still admits members only. A
|
||||
workspace admin can turn them on in the workspace settings.
|
||||
{:else}
|
||||
Anyone your identity provider authenticates can open this app without a Windmill
|
||||
account. They join no workspace. Members of this workspace can open it too.
|
||||
@@ -614,7 +599,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if embedMode && policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase && guestsAvailable}
|
||||
{#if embedMode && policy.execution_mode == 'guest' && guestJwtBase && guestsAvailable}
|
||||
<div class="mt-6 flex flex-col gap-2">
|
||||
<div class="text-xs font-semibold text-emphasis">
|
||||
Embed for your own authenticated users (guest JWT)
|
||||
|
||||
@@ -117,6 +117,9 @@
|
||||
headers
|
||||
})
|
||||
if (!res.ok) {
|
||||
// Minted for the deployed app under the caller's RLS: a 404 is "nothing deployed here" or
|
||||
// "not visible to this member", the same reading as `getAppLiteByPath`'s 404 below.
|
||||
if (res.status == 404) onLoadState?.('not_found')
|
||||
const err: any = new Error('Failed to fetch embed token')
|
||||
err.status = res.status
|
||||
throw err
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -260,10 +260,9 @@ vi.mock('$lib/gen', async () => {
|
||||
WorkerService: wrapService(actual.WorkerService, {
|
||||
listWorkers: vi.fn(async () => [])
|
||||
}),
|
||||
// Guests off by default, as a fresh workspace has them.
|
||||
// Guests unavailable by default, as on the shared cloud.
|
||||
WorkspaceService: wrapService(actual.WorkspaceService, {
|
||||
getGuestUsage: vi.fn(async () => ({ available: false, instance_enabled: false })),
|
||||
getPublicSettings: vi.fn(async () => ({ guest_access_enabled: false }))
|
||||
getGuestUsage: vi.fn(async () => ({ available: false }))
|
||||
}),
|
||||
FolderService: wrapService(actual.FolderService, {
|
||||
createFolder: vi.fn(async () => 'created')
|
||||
@@ -3933,11 +3932,7 @@ describe('global AI tools', () => {
|
||||
}
|
||||
|
||||
vi.mocked(WorkspaceService.getGuestUsage).mockResolvedValueOnce({
|
||||
available: true,
|
||||
instance_enabled: true
|
||||
} as any)
|
||||
vi.mocked(WorkspaceService.getPublicSettings).mockResolvedValueOnce({
|
||||
guest_access_enabled: true
|
||||
available: true
|
||||
} as any)
|
||||
expect(await readMode(guestApp)).toBe('guest')
|
||||
|
||||
@@ -3951,19 +3946,8 @@ describe('global AI tools', () => {
|
||||
})
|
||||
).toBe('anonymous')
|
||||
|
||||
// The same app, with each switch crossed in turn: either one alone admits nobody, so
|
||||
// reporting the mode bare would name an exposure the server refuses. Both off needs
|
||||
// no case of its own — whichever half of the check were dropped, one of these two
|
||||
// still catches it.
|
||||
vi.mocked(WorkspaceService.getPublicSettings).mockResolvedValueOnce({
|
||||
guest_access_enabled: true
|
||||
} as any)
|
||||
expect(await readMode(guestApp)).toContain('inert')
|
||||
|
||||
vi.mocked(WorkspaceService.getGuestUsage).mockResolvedValueOnce({
|
||||
available: true,
|
||||
instance_enabled: true
|
||||
} as any)
|
||||
// The same app where the deployment admits no guest: reporting the mode bare would
|
||||
// name an exposure the server refuses.
|
||||
expect(await readMode(guestApp)).toContain('inert')
|
||||
})
|
||||
|
||||
@@ -4734,36 +4718,19 @@ describe('global AI tools', () => {
|
||||
// anyone outside the deployers group — so the note must not name an identity.
|
||||
expect(open.message).not.toContain('u/alice')
|
||||
|
||||
// Guest is a real widening only where the deployment, the instance and the
|
||||
// workspace all admit guests; stored below that, it is inert.
|
||||
// Guest is a real widening only where the deployment admits guests; stored on one
|
||||
// that does not, it is inert and the note would announce an exposure that does not
|
||||
// exist.
|
||||
vi.mocked(WorkspaceService.getGuestUsage).mockResolvedValueOnce({
|
||||
available: true,
|
||||
instance_enabled: true
|
||||
} as any)
|
||||
vi.mocked(WorkspaceService.getPublicSettings).mockResolvedValueOnce({
|
||||
guest_access_enabled: true
|
||||
available: true
|
||||
} as any)
|
||||
const guestOn = await deployApp('f/apps/guest', { execution_mode: 'guest' })
|
||||
expect(guestOn.success).toBe(true)
|
||||
expect(guestOn.message).toContain('identity provider authenticates')
|
||||
|
||||
// Each switch crossed in turn, because either one alone admits nobody and the note
|
||||
// would then announce an exposure that does not exist. Both off needs no case of its
|
||||
// own: whichever half of the check were dropped, one of these two still catches it.
|
||||
vi.mocked(WorkspaceService.getPublicSettings).mockResolvedValueOnce({
|
||||
guest_access_enabled: true
|
||||
} as any)
|
||||
const instanceOff = await deployApp('f/apps/guest_inst_off', { execution_mode: 'guest' })
|
||||
expect(instanceOff.success).toBe(true)
|
||||
expect(instanceOff.message).not.toContain('identity provider')
|
||||
|
||||
vi.mocked(WorkspaceService.getGuestUsage).mockResolvedValueOnce({
|
||||
available: true,
|
||||
instance_enabled: true
|
||||
} as any)
|
||||
const workspaceOff = await deployApp('f/apps/guest_ws_off', { execution_mode: 'guest' })
|
||||
expect(workspaceOff.success).toBe(true)
|
||||
expect(workspaceOff.message).not.toContain('identity provider')
|
||||
const unavailable = await deployApp('f/apps/guest_unavailable', { execution_mode: 'guest' })
|
||||
expect(unavailable.success).toBe(true)
|
||||
expect(unavailable.message).not.toContain('identity provider')
|
||||
})
|
||||
|
||||
it('forwards preserve_on_behalf_of when the deployed policy carries an on_behalf_of', async () => {
|
||||
|
||||
@@ -2519,18 +2519,15 @@ const triggerServices: Record<TriggerKind, TriggerService> = {
|
||||
}
|
||||
|
||||
/** Whether the server would admit a guest here: the deployment has to support guests at
|
||||
* all, and the instance and workspace switches both have to be on. `guest` is stored on
|
||||
* an app even when none of that holds, so the mode alone never settles who can open it.
|
||||
* `undefined` when a switch read fails — neither proven live nor proven inert. */
|
||||
* all. `guest` is stored on an app even where it does not, so the mode alone never
|
||||
* settles who can open it. `undefined` when the read fails — neither proven live nor
|
||||
* proven inert. */
|
||||
async function guestAccessIsLive(workspace: string): Promise<boolean | undefined> {
|
||||
const [usage, settings] = await Promise.all([
|
||||
WorkspaceService.getGuestUsage({ workspace }).catch(() => undefined),
|
||||
WorkspaceService.getPublicSettings({ workspace }).catch(() => undefined)
|
||||
])
|
||||
if (usage === undefined || settings === undefined) {
|
||||
const usage = await WorkspaceService.getGuestUsage({ workspace }).catch(() => undefined)
|
||||
if (usage === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return !!(usage.available && usage.instance_enabled && settings.guest_access_enabled)
|
||||
return !!usage.available
|
||||
}
|
||||
|
||||
/** Who may open an app, from the mode stored on it. An app sits in `guest` mode whether
|
||||
@@ -2544,7 +2541,7 @@ async function describeAppExposure(
|
||||
if (mode !== 'guest' || (await guestAccessIsLive(workspace)) !== false) {
|
||||
return mode
|
||||
}
|
||||
return 'guest, but inert: the instance or workspace admits no guest, so this app admits members only'
|
||||
return 'guest, but inert: this deployment admits no guest, so this app admits members only'
|
||||
}
|
||||
|
||||
async function readWorkspaceItem(
|
||||
@@ -6040,7 +6037,9 @@ function createVariableToDraftState(
|
||||
// `null` clears and omitted keeps the base's. The draft stores a cleared expiry as
|
||||
// `undefined`, since a stored draft goes through a JSON round-trip.
|
||||
value_valid_until:
|
||||
args.value_valid_until === null ? undefined : (args.value_valid_until ?? base?.value_valid_until)
|
||||
args.value_valid_until === null
|
||||
? undefined
|
||||
: (args.value_valid_until ?? base?.value_valid_until)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -310,6 +310,9 @@ describe('output token limit', () => {
|
||||
// vendor's, while a Custom AI proxy in front of a hosted model keeps its row.
|
||||
expect(getModelMaxTokens('customai', 'deepseek-v4-pro', 'high')).toBe(8192)
|
||||
expect(getModelMaxTokens('customai', 'gpt-5')).toBe(128000)
|
||||
// GLM's own API (z.ai) is only reachable through Custom AI.
|
||||
expect(getModelMaxTokens('customai', 'glm-5.1')).toBe(32768)
|
||||
expect(getModelMaxTokens('customai', 'glm-4.5v')).toBe(8192)
|
||||
})
|
||||
|
||||
it('sizes the budget from the effort the same request carries', async () => {
|
||||
|
||||
@@ -152,7 +152,13 @@ const MODEL_MAX_OUTPUT_TOKENS: [name: string, maxOutputTokens: number][] = [
|
||||
['o4-mini', 100_000],
|
||||
// Google
|
||||
['gemini-3', 64_000],
|
||||
['gemini-2.5', 64_000]
|
||||
['gemini-2.5', 64_000],
|
||||
// Z.ai — open-weight, but listed here because z.ai's own API is reached only
|
||||
// through Custom AI. GLM thinks by default and counts it toward max_tokens. No
|
||||
// family row: older GLMs cap output lower (4.5V at 16K, GLM-4 at 4K).
|
||||
['glm-5', 32_768],
|
||||
['glm-4.7', 32_768],
|
||||
['glm-4.6', 32_768]
|
||||
]
|
||||
|
||||
// Open-weight models, which a self-hosted server (Custom AI) caps at whatever context
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Alert, Badge, Button } from '$lib/components/common'
|
||||
import type { AIProviderModel, WorkspaceItemDiff } from '$lib/gen'
|
||||
import type { AIProviderModel } from '$lib/gen'
|
||||
import { copilotInfo, copilotSessionModel } from '$lib/aiStore'
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
@@ -12,19 +12,38 @@
|
||||
import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle'
|
||||
import { buildCompareUrl } from '$lib/components/copilot/chat/global/pageNavigation'
|
||||
import type { SessionTarget } from '$lib/components/sessions/sessionState.svelte'
|
||||
import { reviewDeployment, type AiReviewFinding, type AiReviewResult } from './aiReview'
|
||||
import {
|
||||
instructionsWorkspace,
|
||||
reviewChanges,
|
||||
scopeItems,
|
||||
scopeWorkspace,
|
||||
type AiReviewFinding,
|
||||
type AiReviewResult,
|
||||
type ReviewScope
|
||||
} from './aiReview'
|
||||
|
||||
interface Props {
|
||||
diffs: WorkspaceItemDiff[]
|
||||
forkWorkspaceId: string
|
||||
parentWorkspaceId: string
|
||||
/** Where the review runs: the items selected for deploy, the request form, or an open request. */
|
||||
source: 'selection' | 'request_form' | 'open_request'
|
||||
scope: ReviewScope
|
||||
/**
|
||||
* Where the review runs: the items selected for deploy, the request form, an open request,
|
||||
* the drafts selected on the drafts page, one draft's diff, or a session's edits.
|
||||
*/
|
||||
source:
|
||||
| 'selection'
|
||||
| 'request_form'
|
||||
| 'open_request'
|
||||
| 'drafts'
|
||||
| 'draft_diff'
|
||||
| 'session_edits'
|
||||
/** Absent before the request exists: findings are then only shown, for the author to act on. */
|
||||
onPost?: (finding: AiReviewFinding) => Promise<void>
|
||||
}
|
||||
|
||||
let { diffs, forkWorkspaceId, parentWorkspaceId, source, onPost }: Props = $props()
|
||||
let { scope, source, onPost }: Props = $props()
|
||||
|
||||
let items = $derived(scopeItems(scope))
|
||||
let workspaceId = $derived(scopeWorkspace(scope))
|
||||
let instructionsWorkspaceId = $derived(instructionsWorkspace(scope))
|
||||
|
||||
let running = $state(false)
|
||||
let result = $state<AiReviewResult | undefined>(undefined)
|
||||
@@ -32,15 +51,15 @@
|
||||
let postingId = $state<string | undefined>(undefined)
|
||||
let abortController: AbortController | undefined
|
||||
|
||||
// `diffs` can change under a finished or running review (a selection is edited, the
|
||||
// The items can change under a finished or running review (a selection is edited, the
|
||||
// comparison refreshes), so the result is tied to the items it actually covered.
|
||||
const itemsKey = (ds: WorkspaceItemDiff[]) =>
|
||||
const itemsKey = (ds: { kind: string; path: string }[]) =>
|
||||
ds
|
||||
.map((d) => `${d.kind}:${d.path}`)
|
||||
.sort()
|
||||
.join('\n')
|
||||
let reviewed = $state<{ key: string; count: number } | undefined>(undefined)
|
||||
let stale = $derived(reviewed != undefined && reviewed.key !== itemsKey(diffs))
|
||||
let stale = $derived(reviewed != undefined && reviewed.key !== itemsKey(items))
|
||||
|
||||
const modelKey = (m: AIProviderModel) => `${m.provider}:${m.model}`
|
||||
// Local to this review: picking a model here must not change the chat's own selection,
|
||||
@@ -58,10 +77,10 @@
|
||||
running = true
|
||||
error = undefined
|
||||
result = undefined
|
||||
reviewed = { key: itemsKey(diffs), count: diffs.length }
|
||||
reviewed = { key: itemsKey(items), count: items.length }
|
||||
logFeatureUsage('deployment_request', 'ai_review', { key: source })
|
||||
try {
|
||||
const review = await reviewDeployment(diffs, forkWorkspaceId, parentWorkspaceId, controller, {
|
||||
const review = await reviewChanges(scope, controller, {
|
||||
model: $copilotInfo.aiModels.find((m) => modelKey(m) === selectedModelKey)
|
||||
})
|
||||
if (!controller.signal.aborted) result = review
|
||||
@@ -74,6 +93,21 @@
|
||||
|
||||
$effect(() => () => abortController?.abort())
|
||||
|
||||
// A review answers for the workspaces it ran in. The same paths in another workspace are
|
||||
// other items, so a switch drops the result and any review still running, rather than
|
||||
// leaving it to read as current there.
|
||||
$effect(() => {
|
||||
void workspaceId
|
||||
void instructionsWorkspaceId
|
||||
return () => {
|
||||
abortController?.abort()
|
||||
running = false
|
||||
result = undefined
|
||||
error = undefined
|
||||
reviewed = undefined
|
||||
}
|
||||
})
|
||||
|
||||
function stop() {
|
||||
abortController?.abort()
|
||||
running = false
|
||||
@@ -118,16 +152,20 @@
|
||||
|
||||
const SESSION_TARGET_KINDS: string[] = ['script', 'flow', 'raw_app']
|
||||
|
||||
// The session is pinned to the fork: the fixes belong in the workspace under review, and a
|
||||
// session left to pick its own would start in whatever workspace the user last used.
|
||||
// The session is pinned to the reviewed workspace: the fixes belong there, and a session left
|
||||
// to pick its own would start in whatever workspace the user last used.
|
||||
function fixSource(findings: AiReviewFinding[]): OpenInSessionSource {
|
||||
const lines = findings.map(
|
||||
(f) => `- [${f.severity}] ${f.kind && f.path ? `${f.kind} ${f.path}: ` : ''}${f.comment}`
|
||||
)
|
||||
const one = findings.length === 1 ? findings[0] : undefined
|
||||
const common = {
|
||||
workspaceId: forkWorkspaceId,
|
||||
seedPrompt: `An AI review of this workspace's changes, before deploying them to ${parentWorkspaceId}, found ${one ? 'this issue' : 'these issues'}. Fix ${one ? 'it' : 'them'} here:\n\n${lines.join('\n')}`,
|
||||
workspaceId,
|
||||
seedPrompt: `${
|
||||
scope.kind === 'deployment'
|
||||
? `An AI review of this workspace's changes, before deploying them to ${scope.parentWorkspaceId}`
|
||||
: "An AI review of this workspace's drafts, before deploying them"
|
||||
}, found ${one ? 'this issue' : 'these issues'}. Fix ${one ? 'it' : 'them'} here:\n\n${lines.join('\n')}`,
|
||||
autoSend: true,
|
||||
beforeOpen: () =>
|
||||
logFeatureUsage('deployment_request', 'ai_fix_in_session', {
|
||||
@@ -139,7 +177,11 @@
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
page: () => buildCompareUrl({ workspace_id: forkWorkspaceId, mode: 'fork' })
|
||||
page: () =>
|
||||
buildCompareUrl({
|
||||
workspace_id: workspaceId,
|
||||
mode: scope.kind === 'deployment' ? 'fork' : 'draft'
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -159,7 +201,7 @@
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Sparkles }}
|
||||
disabled={diffs.length === 0}
|
||||
disabled={items.length === 0}
|
||||
onclick={run}
|
||||
>
|
||||
{result ? 'Review again with AI' : 'Review with AI'}
|
||||
@@ -173,9 +215,11 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if diffs.length === 0}
|
||||
{#if items.length === 0}
|
||||
<span class="text-xs text-secondary">
|
||||
{source === 'selection' ? 'Select items to review.' : 'No changes to review.'}
|
||||
{source === 'selection' || source === 'drafts'
|
||||
? 'Select items to review.'
|
||||
: 'No changes to review.'}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -199,15 +243,15 @@
|
||||
{#if result.instructions.length > 0}
|
||||
Applied {result.instructions.length} review instruction{result.instructions.length === 1
|
||||
? ''
|
||||
: 's'} from {parentWorkspaceId}: {result.instructions.join(', ')}.
|
||||
: 's'} from {instructionsWorkspaceId}: {result.instructions.join(', ')}.
|
||||
{:else}
|
||||
No review instructions from {parentWorkspaceId} applied.
|
||||
No review instructions from {instructionsWorkspaceId} applied.
|
||||
{/if}
|
||||
Only the ai_review_instruction resources you can read in {parentWorkspaceId} are used.
|
||||
Only the ai_review_instruction resources you can read in {instructionsWorkspaceId} are used.
|
||||
</div>
|
||||
{#if result.instructionsUnavailable}
|
||||
<Alert type="warning" title="Review instructions unavailable" size="xs">
|
||||
Could not list the review instructions of {parentWorkspaceId}, so none were applied.
|
||||
Could not list the review instructions of {instructionsWorkspaceId}, so none were applied.
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if result.skippedInstructions.length > 0}
|
||||
@@ -231,7 +275,7 @@
|
||||
source={fixSource(result.findings)}
|
||||
btnClasses={AIBtnClasses('default')}
|
||||
label="Fix all in AI session"
|
||||
tooltip="Open an AI session in the fork and fix every issue listed"
|
||||
tooltip="Open an AI session in {workspaceId} and fix every issue listed"
|
||||
btnProps={{ iconOnly: false, unifiedSize: 'xs', startIcon: { icon: WandSparkles } }}
|
||||
/>
|
||||
{#if onPost}
|
||||
@@ -269,7 +313,7 @@
|
||||
source={fixSource([f])}
|
||||
btnClasses={AIBtnClasses('default')}
|
||||
label="Fix in AI session"
|
||||
tooltip="Open an AI session in the fork and fix this issue"
|
||||
tooltip="Open an AI session in {workspaceId} and fix this issue"
|
||||
btnProps={{ iconOnly: false, unifiedSize: 'xs', startIcon: { icon: WandSparkles } }}
|
||||
/>
|
||||
{#if onPost}
|
||||
|
||||
@@ -261,7 +261,10 @@
|
||||
</div>
|
||||
{#if $copilotInfo.enabled}
|
||||
<div class="border-t pt-2">
|
||||
<AiReview {diffs} {forkWorkspaceId} {parentWorkspaceId} source="request_form" />
|
||||
<AiReview
|
||||
scope={{ kind: 'deployment', diffs, forkWorkspaceId, parentWorkspaceId }}
|
||||
source="request_form"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -294,9 +297,7 @@
|
||||
<div class="border rounded-md bg-surface p-3 flex flex-col gap-3">
|
||||
{#if $copilotInfo.enabled}
|
||||
<AiReview
|
||||
{diffs}
|
||||
{forkWorkspaceId}
|
||||
{parentWorkspaceId}
|
||||
scope={{ kind: 'deployment', diffs, forkWorkspaceId, parentWorkspaceId }}
|
||||
source="open_request"
|
||||
onPost={postAiFinding}
|
||||
/>
|
||||
|
||||
@@ -49,7 +49,15 @@ vi.mock('$lib/gen', async (importOriginal) => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const { reviewDeployment } = await import('./aiReview')
|
||||
const { reviewChanges } = await import('./aiReview')
|
||||
|
||||
const reviewDeployment = (
|
||||
diffs: WorkspaceItemDiff[],
|
||||
forkWorkspaceId: string,
|
||||
parentWorkspaceId: string,
|
||||
abortController: AbortController
|
||||
) =>
|
||||
reviewChanges({ kind: 'deployment', diffs, forkWorkspaceId, parentWorkspaceId }, abortController)
|
||||
|
||||
function diff(path: string): WorkspaceItemDiff {
|
||||
return {
|
||||
@@ -107,3 +115,39 @@ describe('reviewDeployment', () => {
|
||||
expect(result.instructions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('drafts review', () => {
|
||||
it("reviews drafts as deployed against draft, under their own workspace's instructions", async () => {
|
||||
const result = await reviewChanges(
|
||||
{
|
||||
kind: 'drafts',
|
||||
workspaceId: 'parent',
|
||||
items: [
|
||||
{
|
||||
kind: 'script',
|
||||
path: 'f/a/b',
|
||||
change: 'modified',
|
||||
load: async () => ({ before: 'old body', after: 'new body' })
|
||||
},
|
||||
{
|
||||
kind: 'flow',
|
||||
path: 'f/a/c',
|
||||
change: 'added',
|
||||
load: async () => {
|
||||
throw new Error('404')
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
new AbortController()
|
||||
)
|
||||
expect(h.prompt).toContain(
|
||||
'### script f/a/b (modified)\n#### Deployed version\nold body\n#### Draft version\nnew body'
|
||||
)
|
||||
expect(result.unreadable).toEqual(['flow:f/a/c'])
|
||||
expect(result.instructions).toEqual(['f/a/REVIEW'])
|
||||
expect(h.listed).toEqual(['parent'])
|
||||
expect(h.workspace).toBe('parent')
|
||||
expect(h.system).toContain('You review drafts in a Windmill workspace')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
reviewSystemPrompt,
|
||||
VALUE_WITHHELD_KINDS,
|
||||
type AiReviewFinding,
|
||||
type FolderInstruction
|
||||
type FolderInstruction,
|
||||
type ReviewSubject
|
||||
} from './aiReviewCore'
|
||||
|
||||
export {
|
||||
@@ -41,32 +42,88 @@ export type AiReviewResult = {
|
||||
instructionsUnavailable: boolean
|
||||
}
|
||||
|
||||
/** One changed item to review, its two sides read only once the review gets to it. */
|
||||
export type ReviewItem = {
|
||||
kind: string
|
||||
path: string
|
||||
change: 'added' | 'deleted' | 'modified'
|
||||
/** Resolves `undefined`, or throws, when the contents could not be read. */
|
||||
load: () => Promise<{ before: unknown; after: unknown } | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
* What a review covers: the changes a fork carries into its parent, or drafts not yet deployed
|
||||
* in their own workspace.
|
||||
*/
|
||||
export type ReviewScope =
|
||||
| {
|
||||
kind: 'deployment'
|
||||
diffs: WorkspaceItemDiff[]
|
||||
forkWorkspaceId: string
|
||||
parentWorkspaceId: string
|
||||
}
|
||||
| { kind: 'drafts'; workspaceId: string; items: ReviewItem[] }
|
||||
|
||||
/** The workspace whose items change: fixes belong there, and the AI call is bound to it. */
|
||||
export function scopeWorkspace(scope: ReviewScope): string {
|
||||
return scope.kind === 'deployment' ? scope.forkWorkspaceId : scope.workspaceId
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace review instructions are read from. A fork's changes are reviewed under its
|
||||
* parent's, never the fork's own: the fork is the author's side, and its copy of an instruction
|
||||
* is itself one of the changes under review. Drafts are reviewed under their workspace's
|
||||
* deployed instructions, for the same reason a draft of one does not apply.
|
||||
*/
|
||||
export function instructionsWorkspace(scope: ReviewScope): string {
|
||||
return scope.kind === 'deployment' ? scope.parentWorkspaceId : scope.workspaceId
|
||||
}
|
||||
|
||||
export function scopeItems(scope: ReviewScope): ReviewItem[] {
|
||||
if (scope.kind === 'drafts') return scope.items
|
||||
const { forkWorkspaceId, parentWorkspaceId } = scope
|
||||
return scope.diffs.map((diff) => {
|
||||
const change = changeType(diff)
|
||||
return {
|
||||
kind: diff.kind,
|
||||
path: diff.path,
|
||||
change,
|
||||
load: async () => {
|
||||
const kind = diff.kind as Kind
|
||||
const [before, after] = await Promise.all([
|
||||
change === 'added' ? undefined : getItemValue(kind, diff.path, parentWorkspaceId),
|
||||
change === 'deleted' ? undefined : getItemValue(kind, diff.path, forkWorkspaceId)
|
||||
])
|
||||
// `getItemValue` answers `{}` when a read fails, which would pass as an emptied item.
|
||||
return isEmptyRead(before) || isEmptyRead(after) ? undefined : { before, after }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const FETCH_BATCH = 6
|
||||
|
||||
// Read from the parent, never the fork: the fork is the author's side, and its copy of an
|
||||
// instruction is itself one of the changes under review. A failed listing reviews without
|
||||
// instructions rather than failing the review, and is reported as `unavailable`.
|
||||
// A failed listing reviews without instructions rather than failing the review, and is reported
|
||||
// as `unavailable`.
|
||||
async function reviewInstructions(
|
||||
diffs: WorkspaceItemDiff[],
|
||||
parentWorkspaceId: string
|
||||
subject: ReviewSubject,
|
||||
paths: string[],
|
||||
workspaceId: string
|
||||
): Promise<{ text: string; applied: string[]; skipped: string[]; unavailable: boolean }> {
|
||||
let listed: FolderInstruction[]
|
||||
try {
|
||||
listed = await listFolderInstructions(parentWorkspaceId, REVIEW_INSTRUCTIONS_RESOURCE_TYPE)
|
||||
listed = await listFolderInstructions(workspaceId, REVIEW_INSTRUCTIONS_RESOURCE_TYPE)
|
||||
} catch (e) {
|
||||
console.error('Failed to list review instructions', e)
|
||||
return { text: '', applied: [], skipped: [], unavailable: true }
|
||||
}
|
||||
const covering = instructionsCovering(
|
||||
listed,
|
||||
diffs.map((d) => d.path)
|
||||
)
|
||||
const covering = instructionsCovering(listed, paths)
|
||||
const reads = await Promise.all(
|
||||
covering.map(async (instruction) => {
|
||||
try {
|
||||
return {
|
||||
instruction,
|
||||
body: await readFolderInstructionBody(parentWorkspaceId, instruction.path)
|
||||
body: await readFolderInstructionBody(workspaceId, instruction.path)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to read review instructions ${instruction.path}`, e)
|
||||
@@ -78,7 +135,7 @@ async function reviewInstructions(
|
||||
(r): r is { instruction: FolderInstruction; body: string } => r.body !== undefined
|
||||
)
|
||||
return {
|
||||
text: reviewInstructionsSection('deployment_request', blocks),
|
||||
text: reviewInstructionsSection(subject, blocks),
|
||||
applied: blocks.map((b) => b.instruction.path),
|
||||
skipped: reads.filter((r) => r.body === undefined).map((r) => r.instruction.path),
|
||||
unavailable: false
|
||||
@@ -91,27 +148,30 @@ function changeType(diff: WorkspaceItemDiff): 'added' | 'deleted' | 'modified' {
|
||||
return 'modified'
|
||||
}
|
||||
|
||||
const SIDES: Record<ReviewScope['kind'], [string, string]> = {
|
||||
deployment: ['Parent version', 'Fork version'],
|
||||
drafts: ['Deployed version', 'Draft version']
|
||||
}
|
||||
|
||||
async function itemSection(
|
||||
diff: WorkspaceItemDiff,
|
||||
forkWorkspaceId: string,
|
||||
parentWorkspaceId: string
|
||||
item: ReviewItem,
|
||||
sides: [string, string]
|
||||
): Promise<{ text: string; unreadable: boolean }> {
|
||||
const change = changeType(diff)
|
||||
const header = `### ${diff.kind} ${diff.path} (${change})`
|
||||
if (VALUE_WITHHELD_KINDS.has(diff.kind)) {
|
||||
const header = `### ${item.kind} ${item.path} (${item.change})`
|
||||
if (VALUE_WITHHELD_KINDS.has(item.kind)) {
|
||||
return { text: `${header}\nValue withheld from review.`, unreadable: false }
|
||||
}
|
||||
const kind = diff.kind as Kind
|
||||
const [parent, fork] = await Promise.all([
|
||||
change === 'added' ? undefined : getItemValue(kind, diff.path, parentWorkspaceId),
|
||||
change === 'deleted' ? undefined : getItemValue(kind, diff.path, forkWorkspaceId)
|
||||
])
|
||||
// `getItemValue` answers `{}` when a read fails, which would pass as an emptied item.
|
||||
if (isEmptyRead(parent) || isEmptyRead(fork)) {
|
||||
const values = await item.load().catch((e) => {
|
||||
console.error(`Failed to read ${item.kind} ${item.path} for review`, e)
|
||||
return undefined
|
||||
})
|
||||
if (!values) {
|
||||
return { text: `${header}\nContents could not be read.`, unreadable: true }
|
||||
}
|
||||
const before = item.change === 'added' ? undefined : values.before
|
||||
const after = item.change === 'deleted' ? undefined : values.after
|
||||
return {
|
||||
text: `${header}\n#### Parent version\n${renderItemValue(parent)}\n#### Fork version\n${renderItemValue(fork)}`,
|
||||
text: `${header}\n#### ${sides[0]}\n${renderItemValue(before)}\n#### ${sides[1]}\n${renderItemValue(after)}`,
|
||||
unreadable: false
|
||||
}
|
||||
}
|
||||
@@ -120,30 +180,33 @@ function isEmptyRead(value: unknown): boolean {
|
||||
return value != null && typeof value === 'object' && Object.keys(value as object).length === 0
|
||||
}
|
||||
|
||||
/** Reviews the changes `diffs` carry from the fork into its parent with the workspace's AI model. */
|
||||
export async function reviewDeployment(
|
||||
diffs: WorkspaceItemDiff[],
|
||||
forkWorkspaceId: string,
|
||||
parentWorkspaceId: string,
|
||||
/** Reviews the changes `scope` covers with the workspace's AI model. */
|
||||
export async function reviewChanges(
|
||||
scope: ReviewScope,
|
||||
abortController: AbortController,
|
||||
options?: {
|
||||
/** Defaults to the chat's current model. */
|
||||
model?: AIProviderModel
|
||||
}
|
||||
): Promise<AiReviewResult> {
|
||||
const instructionsPromise = reviewInstructions(diffs, parentWorkspaceId)
|
||||
const subject: ReviewSubject = scope.kind === 'deployment' ? 'deployment_request' : 'draft'
|
||||
const workspaceId = scopeWorkspace(scope)
|
||||
const items = scopeItems(scope)
|
||||
const instructionsPromise = reviewInstructions(
|
||||
subject,
|
||||
items.map((i) => i.path),
|
||||
instructionsWorkspace(scope)
|
||||
)
|
||||
const budget = new ReviewBudget()
|
||||
const unreadable: string[] = []
|
||||
// Fetched a batch at a time so a large fork stops reading items once the budget is spent.
|
||||
for (let i = 0; i < diffs.length; i += FETCH_BATCH) {
|
||||
const batch = diffs.slice(i, i + FETCH_BATCH)
|
||||
// Fetched a batch at a time so a large review stops reading items once the budget is spent.
|
||||
for (let i = 0; i < items.length; i += FETCH_BATCH) {
|
||||
const batch = items.slice(i, i + FETCH_BATCH)
|
||||
if (budget.spent) {
|
||||
budget.omitted.push(...batch.map((d) => `${d.kind}:${d.path}`))
|
||||
continue
|
||||
}
|
||||
const sections = await Promise.all(
|
||||
batch.map((d) => itemSection(d, forkWorkspaceId, parentWorkspaceId))
|
||||
)
|
||||
const sections = await Promise.all(batch.map((d) => itemSection(d, SIDES[scope.kind])))
|
||||
if (abortController.signal.aborted) throw new Error('aborted')
|
||||
sections.forEach(({ text, unreadable: failed }, j) => {
|
||||
const key = `${batch[j].kind}:${batch[j].path}`
|
||||
@@ -156,26 +219,28 @@ export async function reviewDeployment(
|
||||
if (abortController.signal.aborted) throw new Error('aborted')
|
||||
const answer = await getNonStreamingCompletion(
|
||||
[
|
||||
{ role: 'system', content: reviewSystemPrompt('deployment_request') + instructions.text },
|
||||
{ role: 'system', content: reviewSystemPrompt(subject) + instructions.text },
|
||||
{
|
||||
role: 'user',
|
||||
content: budget.userPrompt(
|
||||
`Fork workspace: ${forkWorkspaceId}\nParent workspace: ${parentWorkspaceId}`
|
||||
scope.kind === 'deployment'
|
||||
? `Fork workspace: ${scope.forkWorkspaceId}\nParent workspace: ${scope.parentWorkspaceId}`
|
||||
: `Workspace: ${scope.workspaceId}`
|
||||
)
|
||||
}
|
||||
],
|
||||
abortController,
|
||||
// Bound to the fork: without `workspace` the call goes through whichever workspace the
|
||||
// app has switched to by the time the item reads finish.
|
||||
// Bound to the reviewed workspace: without `workspace` the call goes through whichever
|
||||
// workspace the app has switched to by the time the item reads finish.
|
||||
{
|
||||
maxTokensCap: MAX_OUTPUT_TOKENS,
|
||||
forceModelProvider: options?.model,
|
||||
workspace: forkWorkspaceId
|
||||
workspace: workspaceId
|
||||
}
|
||||
)
|
||||
return {
|
||||
...parseReviewAnswer(answer ?? '', {
|
||||
items: new Set(diffs.map((d) => `${d.kind}:${d.path}`))
|
||||
items: new Set(items.map((d) => `${d.kind}:${d.path}`))
|
||||
}),
|
||||
omitted: budget.omitted,
|
||||
unreadable,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// What the review asks of the model and how its answer is read, for every place it runs: the
|
||||
// deployment request panel here, and `wmill review` on a git sync pull request, which carries
|
||||
// deployment request panel and the drafts review here, and `wmill review` on a git sync pull request, which carries
|
||||
// this module through cli/generate-ai-review.ts. Keep it free of `$lib` imports; the
|
||||
// staleness test in cli/test/ai_review_bundle_unit.test.ts fails when the CLI's copy drifts.
|
||||
import {
|
||||
@@ -43,32 +43,47 @@ export const TOTAL_CHAR_BUDGET = 160_000
|
||||
// Under the Anthropic SDK's non-streaming pre-flight max_tokens limit.
|
||||
export const MAX_OUTPUT_TOKENS = 8_000
|
||||
|
||||
/** What is being reviewed: a fork's deployment request, or a pull request on a git sync repo. */
|
||||
export type ReviewSubject = 'deployment_request' | 'pull_request'
|
||||
/**
|
||||
* What is being reviewed: a fork's deployment request, a workspace's undeployed drafts, or a
|
||||
* pull request on a git sync repo.
|
||||
*/
|
||||
export type ReviewSubject = 'deployment_request' | 'draft' | 'pull_request'
|
||||
|
||||
const INTRO: Record<ReviewSubject, string> = {
|
||||
deployment_request:
|
||||
'You review a Windmill deployment request: a set of changes made in a fork workspace that will be merged into its parent workspace. Each changed item is given with its parent version (what is deployed now) and its fork version (what will be deployed).',
|
||||
draft:
|
||||
'You review drafts in a Windmill workspace: changes saved to items of the workspace but not deployed yet. Each changed item is given with its deployed version (what is deployed now) and its draft version (what will be deployed).',
|
||||
pull_request:
|
||||
"You review a pull request on a git repository that Windmill's git sync keeps in step with a workspace: merging it deploys its changes. Each changed item is given with the files that make it up in the repository, each as a unified diff of the base branch (what is deployed now) against the pull request (what will be deployed). Every diff line starts with its line number: R<n> for line n of the pull request's version of the file, L<n> for line n of the base version, then + for an added line, - for a removed one and a space for unchanged context."
|
||||
}
|
||||
|
||||
const ITEM_ANSWER_SHAPE =
|
||||
'{"summary": "<one or two sentences on what the change does and its overall risk>", "findings": [{"kind": "<item kind as given>", "path": "<item path as given>", "severity": "high" | "medium" | "low", "comment": "<the issue and what to do about it, under 80 words>"}]}'
|
||||
|
||||
const ANSWER_SHAPE: Record<ReviewSubject, string> = {
|
||||
deployment_request:
|
||||
'{"summary": "<one or two sentences on what the change does and its overall risk>", "findings": [{"kind": "<item kind as given>", "path": "<item path as given>", "severity": "high" | "medium" | "low", "comment": "<the issue and what to do about it, under 80 words>"}]}',
|
||||
deployment_request: ITEM_ANSWER_SHAPE,
|
||||
draft: ITEM_ANSWER_SHAPE,
|
||||
pull_request:
|
||||
'{"summary": "<one or two sentences on what the change does and its overall risk>", "findings": [{"kind": "<item kind as given>", "path": "<item path as given>", "file": "<the file the issue is in, as given>", "line": "<the R or L line number the issue is on, as given, such as R12, or null for the file as a whole>", "severity": "high" | "medium" | "low", "comment": "<the issue and what to do about it, under 80 words>"}]}'
|
||||
}
|
||||
|
||||
const SOURCE: Record<ReviewSubject, string> = {
|
||||
deployment_request: 'The parent workspace',
|
||||
draft: 'The workspace',
|
||||
pull_request: "The repository's base branch"
|
||||
}
|
||||
|
||||
const APPROVED: Record<ReviewSubject, string> = {
|
||||
deployment_request: 'the merge',
|
||||
draft: 'the deployment',
|
||||
pull_request: 'the merge'
|
||||
}
|
||||
|
||||
export function reviewSystemPrompt(subject: ReviewSubject): string {
|
||||
return `${INTRO[subject]} An item can be a script, flow, app, schedule, trigger, folder, resource type, datatable migration, variable or resource.
|
||||
|
||||
Report only issues a reviewer should act on before approving the merge:
|
||||
Report only issues a reviewer should act on before approving ${APPROVED[subject]}:
|
||||
- bugs and logic errors introduced by the change
|
||||
- breaking changes: removed or renamed inputs, changed input types, changed outputs that callers may rely on
|
||||
- risky operational changes: schedules retimed, triggers pointed at a different script or flow, folder owners or permissions, app policies, destructive migrations
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getDraftDiffValues } from '$lib/utils_draft_deploy'
|
||||
import type { UserDraftItemKind } from '$lib/gen'
|
||||
import type { ReviewItem } from './aiReview'
|
||||
|
||||
/** A draft to review against its deployed version, read the way its diff is shown. */
|
||||
export function draftReviewItem(
|
||||
kind: UserDraftItemKind,
|
||||
path: string,
|
||||
workspaceId: string,
|
||||
draftOnly: boolean
|
||||
): ReviewItem {
|
||||
return {
|
||||
kind,
|
||||
path,
|
||||
change: draftOnly ? 'added' : 'modified',
|
||||
load: async () => {
|
||||
const { deployed, draft } = await getDraftDiffValues(kind, path, workspaceId, draftOnly)
|
||||
return { before: deployed, after: draft }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { LayoutDashboard } from 'lucide-svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import { createChatRawApp } from './createRawAppFromScript'
|
||||
|
||||
/** At the foot of the conversations sidebar of an agent's or a chat-mode flow's page: builds
|
||||
* a chat app over it. Shown only to those who may author raw apps. */
|
||||
let {
|
||||
kind,
|
||||
path,
|
||||
workspace,
|
||||
expanded,
|
||||
summary = undefined,
|
||||
schema = undefined,
|
||||
onNavigate = (url) => goto(url)
|
||||
}: {
|
||||
kind: 'agent' | 'flow'
|
||||
path: string
|
||||
workspace: string
|
||||
/** Whether the sidebar is open: a card when it is, an icon button on its collapsed strip. */
|
||||
expanded: boolean
|
||||
summary?: string
|
||||
schema?: Record<string, any>
|
||||
onNavigate?: (url: string) => void | Promise<void>
|
||||
} = $props()
|
||||
|
||||
const editRights = useEditRights(() => workspace)
|
||||
let canBuild = $derived(editRights.roleCanAuthor('raw_app'))
|
||||
|
||||
async function buildApp() {
|
||||
const app = createChatRawApp({ path, runType: kind, summary, schema })
|
||||
// The raw app editor reads the payload from sessionStorage on mount.
|
||||
sessionStorage.setItem('rawAppImport', JSON.stringify(app))
|
||||
await onNavigate('/apps_raw/add')
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet card(classes: string)}
|
||||
<div class="flex flex-col gap-2 p-3 {classes}">
|
||||
<span class="text-sm font-semibold text-emphasis">Share as an app</span>
|
||||
<span class="text-xs text-secondary">
|
||||
Turn this {kind} into a chat app you can share. Adapt its design to fit your needs.
|
||||
</span>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: LayoutDashboard, classes: 'text-orange-500' }}
|
||||
onClick={buildApp}
|
||||
>
|
||||
Build chat app
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if canBuild}
|
||||
{#if expanded}
|
||||
{@render card('rounded-md border border-light bg-surface-tertiary')}
|
||||
{:else}
|
||||
<!-- Collapsed, the icon alone does not say what it builds: it opens the card. -->
|
||||
<Popover placement="right-end" closeButton={false}>
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
iconOnly
|
||||
startIcon={{ icon: LayoutDashboard, classes: 'ml-[2px] text-orange-500' }}
|
||||
title="Share as an app"
|
||||
btnClasses="w-fit px-2"
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
{@render card('w-60')}
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -55,7 +55,11 @@
|
||||
|
||||
import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte'
|
||||
import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte'
|
||||
import { createRawAppFromFlow } from '$lib/components/details/createRawAppFromScript'
|
||||
import {
|
||||
createChatRawApp,
|
||||
createRawAppFromFlow
|
||||
} from '$lib/components/details/createRawAppFromScript'
|
||||
import BuildChatAppCard from '$lib/components/details/BuildChatAppCard.svelte'
|
||||
import TimeAgo from '$lib/components/TimeAgo.svelte'
|
||||
import FlowGraphViewerStep from '$lib/components/FlowGraphViewerStep.svelte'
|
||||
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
|
||||
@@ -466,19 +470,28 @@
|
||||
}
|
||||
})
|
||||
|
||||
if (!flow || !canAuthorFlow || !can_write) {
|
||||
return buttons
|
||||
}
|
||||
|
||||
// Build app starts from the full template, not the builder one a compose-only role deploys.
|
||||
if (caps.apps === 'full') {
|
||||
// A chat-mode flow gets the chat template rather than a form over its inputs. It holds no
|
||||
// inline code, so authoring raw apps is enough, as on the sidebar card. The form app starts
|
||||
// from the full template, not the builder one a compose-only role deploys.
|
||||
const chat = !!flow?.value?.chat_input_enabled
|
||||
const canBuildApp = chat
|
||||
? editRights.roleCanAuthor('raw_app')
|
||||
: canAuthorFlow && can_write && caps.apps === 'full'
|
||||
if (flow && canBuildApp) {
|
||||
buttons.push({
|
||||
label: 'Build app',
|
||||
label: chat ? 'Build chat app' : 'Build app',
|
||||
placement: 'menu',
|
||||
alwaysPlaced: true,
|
||||
buttonProps: {
|
||||
onClick: async () => {
|
||||
const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema)
|
||||
const app = chat
|
||||
? createChatRawApp({
|
||||
path: flow.path,
|
||||
runType: 'flow',
|
||||
summary: flow.summary,
|
||||
schema: flow.schema
|
||||
})
|
||||
: createRawAppFromFlow(flow.path, flow.summary, flow.schema)
|
||||
// The raw app editor reads the payload from sessionStorage on mount.
|
||||
sessionStorage.setItem('rawAppImport', JSON.stringify(app))
|
||||
await onNavigate('/apps_raw/add')
|
||||
@@ -491,6 +504,10 @@
|
||||
})
|
||||
}
|
||||
|
||||
if (!flow || !canAuthorFlow || !can_write) {
|
||||
return buttons
|
||||
}
|
||||
|
||||
buttons.push({
|
||||
label: 'Edit',
|
||||
buttonProps: {
|
||||
@@ -857,7 +874,21 @@
|
||||
flowModules={flow?.value?.modules}
|
||||
wideLayout
|
||||
frame="none"
|
||||
/>
|
||||
>
|
||||
{#snippet sidebarFooter({ expanded })}
|
||||
{#if workspace && flow}
|
||||
<BuildChatAppCard
|
||||
kind="flow"
|
||||
path={flow.path}
|
||||
{workspace}
|
||||
{expanded}
|
||||
summary={flow.summary}
|
||||
schema={flow.schema}
|
||||
{onNavigate}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</FlowChat>
|
||||
{:else}
|
||||
{@const hasSchema =
|
||||
flow.schema && Object.keys(flow.schema.properties ?? {}).length > 0}
|
||||
@@ -1016,6 +1047,7 @@
|
||||
isDeployed={true}
|
||||
noCapture={true}
|
||||
isEditor={false}
|
||||
chatMode={chatInputEnabled}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -2,11 +2,28 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
RESERVED_LOCALS,
|
||||
createRawAppFromAgent,
|
||||
createRawAppFromFlow,
|
||||
createRawAppFromScript
|
||||
} from './createRawAppFromScript'
|
||||
|
||||
describe('createRawAppFromScript', () => {
|
||||
it('builds an agent form over its message that shows only the answer', () => {
|
||||
const app = createRawAppFromAgent('u/dev/helper')
|
||||
const runnable = app.value.runnables.helper as {
|
||||
runType: string
|
||||
schema: { properties: object }
|
||||
}
|
||||
expect(runnable.runType).toBe('agent')
|
||||
expect(Object.keys(runnable.schema.properties)).toEqual(['user_message'])
|
||||
const appTsx = app.value.files['/App.tsx']
|
||||
expect(appTsx).toContain('setResult((await backend.helper({')
|
||||
expect(appTsx).toContain('))?.output)')
|
||||
expect(appTsx).toContain(
|
||||
"typeof result === 'string' ? result : JSON.stringify(result, null, 2)"
|
||||
)
|
||||
})
|
||||
|
||||
it('builds a path runnable and a form calling it', () => {
|
||||
const app = createRawAppFromScript('u/dev/greet_user', 'Greet a user', {
|
||||
type: 'object',
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { forbiddenIds } from '$lib/components/flows/idUtils'
|
||||
import { react19Template } from '$lib/components/raw_apps/templates'
|
||||
import { react19Template, themeCss } from '$lib/components/raw_apps/templates'
|
||||
import { AGENT_CHAT_SCHEMA } from '$lib/components/flows/conversations/agentEditorChat'
|
||||
|
||||
/** Shape the `/apps_raw/add` import handoff consumes (`$importStore` /
|
||||
* `sessionStorage.rawAppImport`). */
|
||||
export type RawAppImport = {
|
||||
summary: string
|
||||
/** Open it in an AI session where AI is used through sessions, rather than the bare editor. */
|
||||
openInAiSession?: boolean
|
||||
value: {
|
||||
files: Record<string, string>
|
||||
runnables: Record<string, any>
|
||||
@@ -415,8 +418,10 @@ function generateAppTsx(opts: {
|
||||
fields: Field[]
|
||||
title: string
|
||||
subtitle: string
|
||||
/** An agent's run also returns its usage, every message and the raw stream: keep the answer. */
|
||||
outputOnly?: boolean
|
||||
}): string {
|
||||
const { runnableId, fields, title, subtitle } = opts
|
||||
const { runnableId, fields, title, subtitle, outputOnly } = opts
|
||||
const states = fields
|
||||
.map((f) => ` const [${f.local}, ${f.setter}] = useState(${f.init})`)
|
||||
.join('\n')
|
||||
@@ -457,7 +462,7 @@ ${states}${states === '' ? '' : '\n'} const [result, setResult] = useState(undef
|
||||
setRunning(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
setResult(await backend.${runnableId}(${callArgs}))
|
||||
setResult(${outputOnly ? `(await backend.${runnableId}(${callArgs}))?.output` : `await backend.${runnableId}(${callArgs})`})
|
||||
} catch (e) {
|
||||
setResult(undefined)
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
@@ -481,7 +486,7 @@ ${form} <button className="run-button" type="submit" disabled={running}>
|
||||
</button>
|
||||
</form>
|
||||
{error !== undefined && <pre className="app-error">{error}</pre>}
|
||||
{result !== undefined && <pre className="app-result">{JSON.stringify(result, null, 2)}</pre>}
|
||||
{result !== undefined && <pre className="app-result">{${outputOnly ? `typeof result === 'string' ? result : ` : ''}JSON.stringify(result, null, 2)}</pre>}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -651,7 +656,7 @@ function createRawApp(opts: {
|
||||
path: string
|
||||
summary: string | undefined
|
||||
schema: Record<string, any> | undefined
|
||||
runType: 'script' | 'flow'
|
||||
runType: 'script' | 'flow' | 'agent'
|
||||
}): RawAppImport {
|
||||
const { path, summary, schema, runType } = opts
|
||||
const runnableId = runnableIdFromPath(path)
|
||||
@@ -677,6 +682,7 @@ function createRawApp(opts: {
|
||||
|
||||
return {
|
||||
summary: title,
|
||||
openInAiSession: true,
|
||||
value: {
|
||||
files: {
|
||||
...react19Template,
|
||||
@@ -684,7 +690,8 @@ function createRawApp(opts: {
|
||||
runnableId,
|
||||
fields,
|
||||
title,
|
||||
subtitle: `${runType} · ${path}`
|
||||
subtitle: `${runType} · ${path}`,
|
||||
outputOnly: runType === 'agent'
|
||||
}),
|
||||
'/index.css': indexCss
|
||||
},
|
||||
@@ -718,3 +725,448 @@ export function createRawAppFromFlow(
|
||||
): RawAppImport {
|
||||
return createRawApp({ path, summary, schema, runType: 'flow' })
|
||||
}
|
||||
|
||||
/** For an agent without managed memory, which answers each message on its own: a form over the
|
||||
* message. Attachments are left out, a raw app having no upload for them. */
|
||||
export function createRawAppFromAgent(path: string): RawAppImport {
|
||||
const { user_message } = AGENT_CHAT_SCHEMA.properties
|
||||
return createRawApp({
|
||||
path,
|
||||
summary: undefined,
|
||||
schema: { ...AGENT_CHAT_SCHEMA, properties: { user_message } },
|
||||
runType: 'agent'
|
||||
})
|
||||
}
|
||||
|
||||
const chatAppTsx = (chatName: string) => `import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useWindmillChat } from '@windmill-labs/chat-sdk/react'
|
||||
import * as wmill from './wmill'
|
||||
import './index.css'
|
||||
|
||||
const CHAT_NAME = ${JSON.stringify(chatName)}
|
||||
document.title = CHAT_NAME
|
||||
|
||||
export default function App() {
|
||||
// Each turn runs through the \`chat\` runnable, as this app. Conversations are
|
||||
// kept in the viewer's browser.
|
||||
const chat = useWindmillChat({ wmill, runnable: 'chat' })
|
||||
const [input, setInput] = useState('')
|
||||
const [showList, setShowList] = useState(false)
|
||||
const end = useRef<HTMLDivElement>(null)
|
||||
const busy = chat.status === 'submitted' || chat.status === 'streaming'
|
||||
const last = chat.messages[chat.messages.length - 1]
|
||||
|
||||
useEffect(() => {
|
||||
end.current?.scrollIntoView({ block: 'end' })
|
||||
}, [chat.messages])
|
||||
|
||||
function send() {
|
||||
const text = input.trim()
|
||||
if (!text || busy) return
|
||||
setInput('')
|
||||
// A message refused before it starts leaves the transcript as it was: give it back.
|
||||
chat.sendMessage(text).catch(() => setInput(text))
|
||||
}
|
||||
|
||||
function open(id?: string) {
|
||||
if (id) chat.selectConversation(id)
|
||||
else chat.newConversation()
|
||||
setShowList(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat">
|
||||
<aside className={showList ? 'sidebar open' : 'sidebar'}>
|
||||
<button className="primary" onClick={() => open()}>New chat</button>
|
||||
<nav>
|
||||
{chat.conversations.map((c) => (
|
||||
<div key={c.id} className={c.id === chat.conversationId ? 'conv active' : 'conv'}>
|
||||
<button className="conv-title" onClick={() => open(c.id)}>
|
||||
{c.title || 'New chat'}
|
||||
</button>
|
||||
<button
|
||||
className="conv-delete"
|
||||
aria-label="Delete conversation"
|
||||
onClick={() => chat.deleteConversation(c.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
{showList && <div className="backdrop" onClick={() => setShowList(false)} />}
|
||||
|
||||
<section className="main">
|
||||
<header>
|
||||
<button className="menu" aria-label="Conversations" onClick={() => setShowList(true)}>
|
||||
☰
|
||||
</button>
|
||||
<h1>{CHAT_NAME}</h1>
|
||||
</header>
|
||||
|
||||
<div className="messages">
|
||||
<div className="thread">
|
||||
{chat.messages.length === 0 && (
|
||||
<p className="empty">Ask {CHAT_NAME} anything.</p>
|
||||
)}
|
||||
{chat.messages.map((m) =>
|
||||
m.tool ? (
|
||||
<div key={m.id} className="tool">
|
||||
{m.tool.name}
|
||||
{m.tool.status === 'running' ? '…' : m.tool.status === 'error' ? ' failed' : ''}
|
||||
</div>
|
||||
) : m.role === 'user' || m.role === 'assistant' ? (
|
||||
<div key={m.id} className={'msg ' + m.role + (m.success ? '' : ' failed')}>
|
||||
{m.content || (m.pending && <span className="thinking">Thinking…</span>)}
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
{busy && last?.role === 'user' && (
|
||||
<div className="msg assistant">
|
||||
<span className="thinking">Thinking…</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={end} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
{chat.status === 'error' && chat.error && (
|
||||
<div className="error-banner">{chat.error.message}</div>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
send()
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
value={input}
|
||||
rows={1}
|
||||
placeholder={'Message ' + CHAT_NAME}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{busy ? (
|
||||
<button type="button" onClick={() => chat.stop()}>Stop</button>
|
||||
) : (
|
||||
<button type="submit" className="primary" disabled={!input.trim()}>
|
||||
Send
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
const chatAppCss = `${themeCss}
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--fg-strong);
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
button.primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chat {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--sunken);
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.conv {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.conv:hover,
|
||||
.conv.active {
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.conv-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
color: var(--fg);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conv-delete {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 4px 8px;
|
||||
color: var(--muted);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.conv:hover .conv-delete {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: none;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.thread {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 64px 0 0;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.msg {
|
||||
max-width: 85%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.msg.user {
|
||||
align-self: flex-end;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.msg.assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
|
||||
.msg.failed {
|
||||
border-color: var(--error);
|
||||
}
|
||||
|
||||
.tool {
|
||||
align-self: flex-start;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.thinking {
|
||||
color: var(--muted);
|
||||
animation: pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 12px 20px;
|
||||
background: var(--card);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
footer form,
|
||||
.error-banner {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
footer form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--error-soft);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
field-sizing: content;
|
||||
min-height: 40px;
|
||||
max-height: 200px;
|
||||
padding: 9px 12px;
|
||||
font: inherit;
|
||||
color: var(--fg-strong);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.chat {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
width: 260px;
|
||||
z-index: 2;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background: rgb(0 0 0 / 0.3);
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.conv-delete {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
/** React chat over a saved AI agent, or a flow in chat mode, at `path`, through its `chat`
|
||||
* runnable. */
|
||||
export function createChatRawApp({
|
||||
path,
|
||||
runType,
|
||||
summary,
|
||||
schema
|
||||
}: {
|
||||
path: string
|
||||
runType: 'agent' | 'flow'
|
||||
summary?: string
|
||||
schema?: Record<string, any>
|
||||
}): RawAppImport {
|
||||
const name = summary?.trim() || (path.split('/').pop() ?? path)
|
||||
return {
|
||||
summary: `Chat with ${name}`,
|
||||
openInAiSession: true,
|
||||
value: {
|
||||
files: {
|
||||
...react19Template,
|
||||
'/App.tsx': chatAppTsx(name),
|
||||
'/index.css': chatAppCss,
|
||||
// The runnable transport (`{ wmill, runnable }`) sends `memory_id` as a query parameter from 1.851.0.
|
||||
'/package.json': JSON.stringify(
|
||||
{
|
||||
dependencies: {
|
||||
react: '19.0.0',
|
||||
'react-dom': '19.0.0',
|
||||
'@windmill-labs/chat-sdk': '^1.851.0'
|
||||
},
|
||||
devDependencies: { '@types/react-dom': '^19.0.0', '@types/react': '^19.0.0' }
|
||||
},
|
||||
null,
|
||||
4
|
||||
)
|
||||
},
|
||||
runnables: {
|
||||
chat: { name, type: 'path', runType, path, schema: schema ?? {}, fields: {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,15 @@ export type DiffVersionOption = {
|
||||
* this is the picker's own appetite, not their default. */
|
||||
export const VERSION_PAGE_SIZE = 20
|
||||
|
||||
/** Offers an AI review of the shown change, for a diff of a draft against its deployed version. */
|
||||
export type DiffAiReview = {
|
||||
workspaceId: string
|
||||
kind: string
|
||||
path: string
|
||||
/** Never deployed: the left side is a placeholder, not a version. */
|
||||
added?: boolean
|
||||
}
|
||||
|
||||
export type DiffDrawerDiff =
|
||||
| {
|
||||
mode: 'normal'
|
||||
@@ -52,6 +61,7 @@ export type DiffDrawerDiff =
|
||||
current: Value
|
||||
defaultDiffType?: 'deployed' | 'draft'
|
||||
button?: { text: string; onClick: () => void }
|
||||
aiReview?: DiffAiReview
|
||||
}
|
||||
| {
|
||||
mode: 'simple'
|
||||
@@ -59,6 +69,7 @@ export type DiffDrawerDiff =
|
||||
current: Value
|
||||
title: string
|
||||
button?: { text: string; onClick: () => void }
|
||||
aiReview?: DiffAiReview
|
||||
}
|
||||
|
||||
export interface DiffDrawerI {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Copy, Eye, FileUp, History, List, Shield, Trash } from 'lucide-svelte'
|
||||
import { ResourceService } from '$lib/gen'
|
||||
import { Copy, Eye, FileUp, History, LayoutDashboard, List, Shield, Trash } from 'lucide-svelte'
|
||||
import { DraftService, ResourceService, type AiAgent, type OpenFlow } from '$lib/gen'
|
||||
import { mintDraftPath } from '$lib/mintDraftPath'
|
||||
import { keepsManagedMemory } from './agentFormFields'
|
||||
import type { AIAgentConfig } from './agentResourceUtils'
|
||||
import { AGENT_CHAT_SCHEMA } from './conversations/agentEditorChat'
|
||||
import { inlineAgentDraft } from './linkedAgentDrafts'
|
||||
import { nextId } from './flowModuleNextId'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { copyToClipboard, type Item } from '$lib/utils'
|
||||
import { isDeployable } from '$lib/utils_deployable'
|
||||
@@ -18,6 +24,61 @@ export async function deleteAgent(workspace: string, path: string): Promise<bool
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a new flow whose one step is the agent, reading the message and files from the flow's
|
||||
* inputs, and chatting when the agent keeps the conversation. The step links the deployed agent
|
||||
* at `path`, or with `inline` carries `config` itself, for an agent that was never deployed.
|
||||
* `beforeOpen` runs once the flow is handed over, and a throw from it keeps the page where it is.
|
||||
*/
|
||||
export async function openAgentInFlow(opts: {
|
||||
workspace: string
|
||||
path: string
|
||||
config: AIAgentConfig
|
||||
inline: boolean
|
||||
beforeOpen?: () => Promise<unknown>
|
||||
}): Promise<void> {
|
||||
const linked = {
|
||||
type: 'aiagent',
|
||||
agent: opts.path,
|
||||
tools: [],
|
||||
input_transforms: {
|
||||
user_message: { type: 'javascript', expr: 'flow_input.user_message' },
|
||||
user_attachments: { type: 'javascript', expr: 'flow_input.user_attachments' }
|
||||
}
|
||||
} as AiAgent & { agent: string }
|
||||
const value = opts.inline ? inlineAgentDraft(linked, opts.config) : linked
|
||||
// An inlined agent brings its tools' ids, which the step's own must not repeat.
|
||||
const id = nextId({}, { summary: '', value: { modules: [{ id: '', value }] } })
|
||||
const flow: OpenFlow & { path: string; description: string; extra_perms: {} } = {
|
||||
path: opts.path,
|
||||
summary: '',
|
||||
description: '',
|
||||
extra_perms: {},
|
||||
value: {
|
||||
...(keepsManagedMemory(opts.config.memory) ? { chat_input_enabled: true } : {}),
|
||||
modules: [{ id, value }]
|
||||
},
|
||||
schema: AGENT_CHAT_SCHEMA
|
||||
}
|
||||
// Saved as a draft before anything is let go, so the flow outlives a refresh of its editor: a
|
||||
// new flow's editor writes nothing until its first edit. The editor opens it as it opens any
|
||||
// draft-only flow, in the workspace named here rather than the one navigation last stored.
|
||||
const draftPath = mintDraftPath()
|
||||
try {
|
||||
await DraftService.updateDraft({
|
||||
workspace: opts.workspace,
|
||||
kind: 'flow',
|
||||
path: draftPath,
|
||||
requestBody: { value: flow }
|
||||
})
|
||||
await opts.beforeOpen?.()
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not open the flow editor: ${err}`, true)
|
||||
return
|
||||
}
|
||||
await goto(`/flows/edit/${draftPath}?workspace=${encodeURIComponent(opts.workspace)}`)
|
||||
}
|
||||
|
||||
/** An agent's menu, as its home row and its page both show it. */
|
||||
export function agentMenuItems(agent: {
|
||||
path: string
|
||||
@@ -33,6 +94,9 @@ export function agentMenuItems(agent: {
|
||||
onDeploy: () => void
|
||||
onHistory: () => void
|
||||
onDelete: (event?: MouseEvent) => void
|
||||
/** Set where the user may build an app over the agent: a chat app when it keeps a chat, a form
|
||||
* over its message otherwise. */
|
||||
buildApp?: { chat: boolean; action: () => void }
|
||||
}): Item[] {
|
||||
const deployable =
|
||||
!agent.noCode &&
|
||||
@@ -62,6 +126,15 @@ export function agentMenuItems(agent: {
|
||||
]),
|
||||
{ displayName: 'Copy path', icon: Copy, action: () => copyToClipboard(agent.path) }
|
||||
]),
|
||||
...(agent.buildApp
|
||||
? [
|
||||
{
|
||||
displayName: agent.buildApp.chat ? 'Build chat app' : 'Build app',
|
||||
icon: LayoutDashboard,
|
||||
action: agent.buildApp.action
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { FlaskConical, FormInput, MessageSquare, Pen } from 'lucide-svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { FlaskConical, FormInput, LayoutDashboard, MessageSquare, Pen } from 'lucide-svelte'
|
||||
import { resource } from 'runed'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
@@ -16,6 +15,11 @@
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import AgentEditorHost from '$lib/components/flows/content/AgentEditorHost.svelte'
|
||||
import BuildChatAppCard from '$lib/components/details/BuildChatAppCard.svelte'
|
||||
import {
|
||||
createChatRawApp,
|
||||
createRawAppFromAgent
|
||||
} from '$lib/components/details/createRawAppFromScript'
|
||||
import AgentConfigModal from '$lib/components/flows/content/AgentConfigModal.svelte'
|
||||
import RunForm from '$lib/components/RunForm.svelte'
|
||||
import InputSelectedBadge from '$lib/components/schema/InputSelectedBadge.svelte'
|
||||
@@ -86,6 +90,15 @@
|
||||
const editRights = useEditRights(() => ws)
|
||||
let canAuthor = $derived(editRights.roleCanAuthor('agent'))
|
||||
let canCode = $derived(editRights.capabilities.code)
|
||||
|
||||
async function buildApp() {
|
||||
const app = chatAvailable
|
||||
? createChatRawApp({ path, runType: 'agent' })
|
||||
: createRawAppFromAgent(path)
|
||||
// The raw app editor reads the payload from sessionStorage on mount.
|
||||
sessionStorage.setItem('rawAppImport', JSON.stringify(app))
|
||||
await onNavigate('/apps_raw/add')
|
||||
}
|
||||
let canEdit = $derived(config != undefined && (agent?.canWrite ?? false) && canAuthor)
|
||||
|
||||
// Recorded once the agent is in: a path with nothing deployed never loads one. Not from a
|
||||
@@ -133,11 +146,11 @@
|
||||
let history: AgentHistory | undefined = $state(undefined)
|
||||
let deleteOpen = $state(false)
|
||||
let evalsModal: AgentEvalsModal | undefined = $state(undefined)
|
||||
let clientWidth = $state(window.innerWidth)
|
||||
let pickedMobileTab = $state('agent')
|
||||
let pickedTab = $state<'agent' | 'api' | 'slack'>('agent')
|
||||
// The page is reused across agents, and an agent without managed memory has no Slack tab.
|
||||
let mobileTab = $derived(pickedMobileTab === 'slack' && !chatAvailable ? 'api' : pickedMobileTab)
|
||||
let wide = $derived(clientWidth >= 1024)
|
||||
let tab = $derived(
|
||||
pickedTab !== 'agent' && pickedTab !== 'api' && !chatAvailable ? 'api' : pickedTab
|
||||
)
|
||||
|
||||
const slack = resource(
|
||||
() => ({ workspace: ws, path }),
|
||||
@@ -152,7 +165,7 @@
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (!active) return
|
||||
if (event.defaultPrevented || event.key !== 'Enter' || !(event.ctrlKey || event.metaKey)) return
|
||||
if (!runForm || testPane?.mode === 'chat' || (!wide && mobileTab !== 'agent')) return
|
||||
if (!runForm || testPane?.mode === 'chat' || tab !== 'agent') return
|
||||
if (isValid) {
|
||||
event.preventDefault()
|
||||
runForm.run()
|
||||
@@ -186,27 +199,45 @@
|
||||
() => ws,
|
||||
() => getDeployUiSettings()
|
||||
)
|
||||
// A role that cannot build agents gets no menu, as on a flow's page.
|
||||
// The chat template runs the agent by path and holds no code, so raw app rights are enough, as
|
||||
// on the sidebar card. The form app starts from the full template, as on a flow's page.
|
||||
let buildAppItem = $derived(
|
||||
(chatAvailable ? editRights.roleCanAuthor('raw_app') : editRights.capabilities.apps === 'full')
|
||||
? { chat: chatAvailable, action: buildApp }
|
||||
: undefined
|
||||
)
|
||||
// A role that cannot build agents gets no menu, as on a flow's page, but for the app.
|
||||
let menuItems = $derived(
|
||||
config != undefined && canAuthor
|
||||
? agentMenuItems({
|
||||
path,
|
||||
canWrite: canEdit,
|
||||
noCode: !canCode,
|
||||
wsSpecific: agent?.state?.wsSpecific,
|
||||
deployUiSettings: deployUiSettings.current,
|
||||
onPermissions: () => shareModal?.openDrawer?.(path, 'resource'),
|
||||
onDeploy: () => deploymentDrawer?.openDrawer(path, 'resource'),
|
||||
onHistory: () => history?.open(),
|
||||
onDelete: () => (deleteOpen = true)
|
||||
}).map((item) => ({
|
||||
label: item.displayName,
|
||||
Icon: item.icon,
|
||||
onclick: (e: MouseEvent) => item.action?.(e),
|
||||
disabled: item.disabled,
|
||||
color: item.type === 'delete' ? ('red' as const) : undefined
|
||||
}))
|
||||
: []
|
||||
config == undefined
|
||||
? []
|
||||
: !canAuthor
|
||||
? buildAppItem
|
||||
? [
|
||||
{
|
||||
label: buildAppItem.chat ? 'Build chat app' : 'Build app',
|
||||
Icon: LayoutDashboard,
|
||||
onclick: buildApp
|
||||
}
|
||||
]
|
||||
: []
|
||||
: agentMenuItems({
|
||||
path,
|
||||
canWrite: canEdit,
|
||||
noCode: !canCode,
|
||||
wsSpecific: agent?.state?.wsSpecific,
|
||||
deployUiSettings: deployUiSettings.current,
|
||||
onPermissions: () => shareModal?.openDrawer?.(path, 'resource'),
|
||||
onDeploy: () => deploymentDrawer?.openDrawer(path, 'resource'),
|
||||
onHistory: () => history?.open(),
|
||||
onDelete: () => (deleteOpen = true),
|
||||
buildApp: buildAppItem
|
||||
}).map((item) => ({
|
||||
label: item.displayName,
|
||||
Icon: item.icon,
|
||||
onclick: (e: MouseEvent) => item.action?.(e),
|
||||
disabled: item.disabled,
|
||||
color: item.type === 'delete' ? ('red' as const) : undefined
|
||||
}))
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -319,15 +350,10 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DetailPageHeader>
|
||||
<!-- Below `lg` the split would squeeze the chat, so the panel's sections become tabs of the
|
||||
page, as on a script's or a flow's page. They cover the agent rather than replacing it:
|
||||
unmounting the host destroys the chat and any turn in flight, so the agent pane keeps one
|
||||
parent at every width. -->
|
||||
{#if !wide && ws && config}
|
||||
<Tabs
|
||||
bind:selected={() => mobileTab, (t) => (pickedMobileTab = t)}
|
||||
wrapperClass="flex-none no-scrollbar"
|
||||
>
|
||||
<!-- Tabs at every width, as a chat-mode flow's page has them. They cover the agent rather than
|
||||
replacing it: unmounting the host destroys the chat and any turn in flight. -->
|
||||
{#if ws && config}
|
||||
<Tabs bind:selected={() => tab, (t) => (pickedTab = t)} wrapperClass="flex-none no-scrollbar">
|
||||
<Tab value="agent" label={testPane?.mode === 'chat' ? 'Chat' : 'Run form'} />
|
||||
<Tab value="api" label="API" />
|
||||
{#if chatAvailable}
|
||||
@@ -335,22 +361,13 @@
|
||||
{/if}
|
||||
</Tabs>
|
||||
{/if}
|
||||
<div class="relative flex-1 min-h-0" bind:clientWidth>
|
||||
<Splitpanes>
|
||||
<Pane size={wide ? 65 : 100} minSize={50}>
|
||||
<div class="h-full" inert={!wide && mobileTab !== 'agent'}>
|
||||
{@render agentPane()}
|
||||
</div>
|
||||
</Pane>
|
||||
{#if wide && ws && config}
|
||||
<Pane size={35} minSize={15}>
|
||||
{@render usePane(ws)}
|
||||
</Pane>
|
||||
{/if}
|
||||
</Splitpanes>
|
||||
{#if !wide && ws && config}
|
||||
<div class={['absolute inset-0 z-10 bg-surface', mobileTab === 'agent' && 'hidden']}>
|
||||
{@render usePane(ws, mobileTab === 'slack' ? 'slack' : 'api')}
|
||||
<div class="relative flex-1 min-h-0">
|
||||
<div class="h-full" inert={tab !== 'agent'}>
|
||||
{@render agentPane()}
|
||||
</div>
|
||||
{#if ws && config}
|
||||
<div class={['absolute inset-0 z-10 bg-surface', tab === 'agent' && 'hidden']}>
|
||||
{@render usePane(tab === 'agent' ? 'api' : tab)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -372,6 +389,11 @@
|
||||
onOpenConfig={() => configModal?.open()}
|
||||
onRunStarted={embedded || pendingRun ? runStarted : undefined}
|
||||
>
|
||||
{#snippet chatSidebarFooter({ expanded })}
|
||||
{#if ws}
|
||||
<BuildChatAppCard kind="agent" {path} workspace={ws} {expanded} {onNavigate} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet viewForm({ schema, run, loading, actions })}
|
||||
{#if config && !chatAvailable}
|
||||
<Alert type="info" size="xs" title="Managed memory is off" class="mb-4">
|
||||
@@ -408,10 +430,9 @@
|
||||
{/key}
|
||||
{/snippet}
|
||||
|
||||
{#snippet usePane(ws: string, section?: 'api' | 'slack')}
|
||||
{#snippet usePane(section: 'api' | 'slack')}
|
||||
<AgentUsePanel
|
||||
agentPath={path}
|
||||
workspace={ws}
|
||||
isAdmin={!!(operatingUser.current?.is_admin || operatingUser.current?.is_super_admin)}
|
||||
chat={chatAvailable}
|
||||
slackLoading={slack.loading && !slack.current}
|
||||
|
||||
@@ -86,6 +86,10 @@
|
||||
}
|
||||
]
|
||||
>
|
||||
/** A hint pinned under the configuration, outside its scroll. */
|
||||
configFooter?: import('svelte').Snippet
|
||||
/** Pinned at the foot of the chat's conversations sidebar. */
|
||||
chatSidebarFooter?: import('svelte').Snippet<[{ expanded: boolean }]>
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -99,7 +103,9 @@
|
||||
view = false,
|
||||
onOpenConfig = undefined,
|
||||
onRunStarted = undefined,
|
||||
viewForm = undefined
|
||||
viewForm = undefined,
|
||||
configFooter = undefined,
|
||||
chatSidebarFooter = undefined
|
||||
}: Props = $props()
|
||||
|
||||
/** The one module the editor edits. Standalone (no `agent` key) so `initFlowState` loads a
|
||||
@@ -611,31 +617,40 @@
|
||||
</div>
|
||||
|
||||
{#snippet configPane()}
|
||||
<div class="h-full min-h-0 overflow-auto">
|
||||
<PropPickerWrapper pickableProperties={stepPropPicker?.pickableProperties} noPadding sidePane>
|
||||
<AiAgentStepInputs
|
||||
class="px-4 pt-4 pb-8"
|
||||
{schema}
|
||||
filter={brainFilter}
|
||||
previousModuleId={undefined}
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
<PropPickerWrapper
|
||||
pickableProperties={stepPropPicker?.pickableProperties}
|
||||
extraLib={stepPropPicker?.extraLib ?? 'missing extraLib'}
|
||||
{enableAi}
|
||||
{workspace}
|
||||
staticOnly
|
||||
visibilityKey={`agent:${path}`}
|
||||
{tools}
|
||||
{readOnly}
|
||||
onSelectTool={(id) => onSelectTool?.(id)}
|
||||
onAddTool={readOnly ? undefined : addTool}
|
||||
onDeleteTool={readOnly ? undefined : deleteTool}
|
||||
toolPickerPortal="#agent-editor"
|
||||
bind:args={
|
||||
() => (agentValue?.input_transforms ?? {}) as Record<string, InputTransform>,
|
||||
(v) => agentValue && (agentValue.input_transforms = v)
|
||||
}
|
||||
/>
|
||||
</PropPickerWrapper>
|
||||
noPadding
|
||||
sidePane
|
||||
>
|
||||
<AiAgentStepInputs
|
||||
class="px-4 pt-4 pb-8"
|
||||
{schema}
|
||||
filter={brainFilter}
|
||||
previousModuleId={undefined}
|
||||
pickableProperties={stepPropPicker?.pickableProperties}
|
||||
extraLib={stepPropPicker?.extraLib ?? 'missing extraLib'}
|
||||
{enableAi}
|
||||
{workspace}
|
||||
staticOnly
|
||||
visibilityKey={`agent:${path}`}
|
||||
{tools}
|
||||
{readOnly}
|
||||
onSelectTool={(id) => onSelectTool?.(id)}
|
||||
onAddTool={readOnly ? undefined : addTool}
|
||||
onDeleteTool={readOnly ? undefined : deleteTool}
|
||||
toolPickerPortal="#agent-editor"
|
||||
bind:args={
|
||||
() => (agentValue?.input_transforms ?? {}) as Record<string, InputTransform>,
|
||||
(v) => agentValue && (agentValue.input_transforms = v)
|
||||
}
|
||||
/>
|
||||
</PropPickerWrapper>
|
||||
</div>
|
||||
{#if configFooter}
|
||||
<div class="shrink-0 px-3 py-1.5">{@render configFooter()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -755,6 +770,7 @@
|
||||
inputSchema={AGENT_CHAT_SCHEMA}
|
||||
flowModules={chatModules}
|
||||
composerSettings={onOpenConfig ? configButton : undefined}
|
||||
sidebarFooter={chatSidebarFooter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
showAgentEditorView
|
||||
} from '../agentEditorStore.svelte'
|
||||
import { agentConfigToInputTransforms } from '../agentResourceUtils'
|
||||
import { openAgentInFlow } from '../agentActions'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { publishLinkedAgentTools } from '../flowState'
|
||||
import { linkedModulesForAgent, linkedToolsScope } from '../linkedAgentToolsStore.svelte'
|
||||
import AgentEditorHost from './AgentEditorHost.svelte'
|
||||
@@ -38,7 +42,6 @@
|
||||
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
|
||||
import { emptyString } from '$lib/utils'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import { itemDisplayName } from '$lib/components/sessions/previewRouter'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import OpenInSessionButton, {
|
||||
@@ -328,20 +331,79 @@
|
||||
|
||||
let deployingFor = $state<WriteTarget | undefined>(undefined)
|
||||
|
||||
async function onDeploy() {
|
||||
async function onDeploy(): Promise<boolean> {
|
||||
saving = true
|
||||
deployingFor = currentWriteTarget()
|
||||
try {
|
||||
await host?.deploy()
|
||||
return (await host?.deploy()) ?? false
|
||||
} finally {
|
||||
saving = false
|
||||
deployingFor = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Only where the agent stands alone: opened from a flow's step, it is already in one. Linking
|
||||
// a deployed agent as it is only reads it. Not for operators: the server refuses their flows
|
||||
// a linked agent, and an inlined one's tools may hold code.
|
||||
let offerFlow = $derived(
|
||||
target?.host === undefined &&
|
||||
draft?.state != undefined &&
|
||||
!draft.loading &&
|
||||
!refused &&
|
||||
!$userStore?.operator &&
|
||||
editRights.roleCanAuthor('flow') &&
|
||||
(!readOnly || (!draft.noDeployed && !draft.sync.hasDraft))
|
||||
)
|
||||
let openingFlow = $state(false)
|
||||
/** Set while a deploy is followed by the flow's page: the page loads next, so the deploy's own
|
||||
* navigation would race it. Holds the path the deploy wrote. */
|
||||
let handOff = $state<{ savedPath?: string } | undefined>(undefined)
|
||||
|
||||
async function useInFlow() {
|
||||
const state = draft?.state
|
||||
const at = target
|
||||
const workspace = ws
|
||||
if (!draft || !state || !at || !workspace) return
|
||||
openingFlow = true
|
||||
try {
|
||||
if (draft.noDeployed) {
|
||||
// Never a workspace item: it becomes the flow's step, and its draft goes. Through the
|
||||
// sync, so an autosave still pending cannot write it back.
|
||||
const sync = draft.sync
|
||||
await openAgentInFlow({
|
||||
workspace,
|
||||
path: state.path,
|
||||
config: state.args,
|
||||
inline: true,
|
||||
beforeOpen: async () => sync.discard(at.path, undefined)
|
||||
})
|
||||
return
|
||||
}
|
||||
// The flow links the deployed agent, so it has to run what is shown here.
|
||||
const config = state.args
|
||||
let path = at.path
|
||||
if (draft.sync.hasDraft) {
|
||||
handOff = {}
|
||||
try {
|
||||
if (!(await onDeploy()) || !handOff.savedPath) return
|
||||
path = handOff.savedPath
|
||||
} finally {
|
||||
handOff = undefined
|
||||
}
|
||||
}
|
||||
await openAgentInFlow({ workspace, path, config, inline: false })
|
||||
} finally {
|
||||
openingFlow = false
|
||||
}
|
||||
}
|
||||
|
||||
/** What a successful deploy has to reconcile. `savedPath` is the path it wrote, which a rename
|
||||
* moves off the one the editor opened. */
|
||||
async function onSaved(savedPath: string) {
|
||||
if (handOff) {
|
||||
handOff.savedPath = savedPath
|
||||
return
|
||||
}
|
||||
const at = deployingFor ?? currentWriteTarget()
|
||||
// Before the rename is announced: it finds the steps to refresh under the old path.
|
||||
const reconciled = reconcile(at, savedPath)
|
||||
@@ -665,9 +727,34 @@
|
||||
onSelectTool={(id) => showTool(id)}
|
||||
{onSaved}
|
||||
isNew={target?.isNew}
|
||||
configFooter={offerFlow ? flowFooter : undefined}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet flowFooter()}
|
||||
<div class="flex flex-wrap items-center gap-x-1.5 text-xs text-hint">
|
||||
Need to fetch context first, process the output, or add other steps?
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: BarsStaggered, props: { style: 'fill: currentColor;' } }}
|
||||
loading={openingFlow}
|
||||
onClick={useInFlow}
|
||||
title={draft?.noDeployed
|
||||
? 'It becomes the step of a new flow, and this draft is discarded.'
|
||||
: draft?.sync.hasDraft
|
||||
? 'Your changes are deployed, then a new flow links this agent.'
|
||||
: 'A new flow links this agent, which stays configured here.'}
|
||||
>
|
||||
{draft?.noDeployed
|
||||
? 'Turn into a flow'
|
||||
: draft?.sync.hasDraft
|
||||
? 'Deploy and use in a flow'
|
||||
: 'Use in a flow'}
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet settingsFields()}
|
||||
{#if draft}
|
||||
<AgentSettings
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
import PermissionedAsLine from '$lib/components/triggers/PermissionedAsLine.svelte'
|
||||
import AgentSlackSettings from './AgentSlackSettings.svelte'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { useActingUser } from '$lib/actingUser.svelte'
|
||||
import type { AgentDraftHandle, AgentRunAs } from '../agentDraft.svelte'
|
||||
|
||||
@@ -36,8 +35,8 @@
|
||||
let nextRunAs = $derived(
|
||||
pickedRunAs?.preserve && pickedRunAs.permissionedAs
|
||||
? pickedRunAs.permissionedAs
|
||||
: $userStore?.username
|
||||
? `u/${$userStore.username}`
|
||||
: acting.current?.username
|
||||
? `u/${acting.current.username}`
|
||||
: undefined
|
||||
)
|
||||
// Only a workspace that deploys somewhere has anything to keep an agent out of.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { SlackAgentsService, type SlackAgentChannel } from '$lib/gen'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { useActingUser } from '$lib/actingUser.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import type { AgentDraftHandle } from '../agentDraft.svelte'
|
||||
|
||||
@@ -26,16 +26,18 @@
|
||||
|
||||
let { draft, path, workspace, nextRunAs }: Props = $props()
|
||||
|
||||
const acting = useActingUser(() => workspace)
|
||||
|
||||
let readOnly = $derived(!draft.canWrite)
|
||||
let obo = $derived(!!draft.state?.args.run_on_behalf_of)
|
||||
let runsAs = $derived(
|
||||
readOnly
|
||||
? (draft.onBehalfOf ?? 'its run-as user')
|
||||
: !nextRunAs || nextRunAs === `u/${$userStore?.username}`
|
||||
: !nextRunAs || nextRunAs === `u/${acting.current?.username}`
|
||||
? 'you'
|
||||
: nextRunAs
|
||||
)
|
||||
let isAdmin = $derived(!!$userStore?.is_admin)
|
||||
let isAdmin = $derived(!!acting.current?.is_admin)
|
||||
|
||||
const slack = resource(
|
||||
() => ({ ws: workspace, path, deployed: !draft.noDeployed }),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user