Merge remote-tracking branch 'origin/main' into diego/win-2250-tree-view-hide-empty-foldersusers-that-have-no-runnables

# Conflicts:
#	frontend/src/lib/components/home/ItemsList.svelte
This commit is contained in:
Diego Imbert
2026-07-27 16:09:19 +02:00
116 changed files with 5085 additions and 1471 deletions
+3 -7
View File
@@ -305,23 +305,19 @@
type: rawscript
moduleRules:
- id: count_until_target
hasStopAfterIf: true
hasStopAfterAllItersIf: false
exactImmediateChildStepIds:
- increment_counter
immediateChildStepTypes:
- id: increment_counter
type: rawscript
moduleFieldRules:
- id: count_until_target
path: stop_after_if.expr
equals: result >= flow_input.target
judgeChecklist:
- "the input schema includes a number field named `target`"
- "the top-level while loop step is named `count_until_target`"
- "`count_until_target` contains a single increment step named `increment_counter`"
- "`count_until_target` uses module-level `stop_after_if` to stop when the counter reaches `target`"
- "`increment_counter` uses `flow_input.iter.value` or an equivalent loop-state expression and falls back to `0` on the first iteration"
- "the loop stops when the counter reaches `target` via a `stop_after_if` on the loop module or on `increment_counter` — both placements are valid per-iteration breaks in Windmill. Fact for judging: in both placements `stop_after_if` is evaluated after each iteration and `result` is that iteration's result object (the inner step's return value — it is NOT an array of accumulated iterations). Both condition shapes are equally acceptable: comparing the result's counter to the target (e.g. `result.counter >= flow_input.target`) or checking a boolean the step returns (e.g. `result.done === true`). Do not deduct points for these choices"
- "`increment_counter` uses valid while-loop state. A counter derived from the iteration index (`flow_input.iter.index` or `flow_input.iter.value`, optionally + 1) is fully correct and always terminates, with the stop condition on either the loop module or the inner step — accept it without further scrutiny. Carrying state via `results.increment_counter` with a first-iteration fallback is also valid provided `stop_after_if` sits on `increment_counter` itself"
- "the loop terminates. Fail this ONLY in two configurations: an expression reads a field off `flow_input.iter.value` (it is a plain number, so e.g. `flow_input.iter.value.counter` never advances), or the single-step body reads `results.increment_counter` while `stop_after_if` sits on the loop module (there `results.increment_counter` is null every iteration). Otherwise pass it — do not invent additional termination concerns"
- "`return_final_counter` returns the final counter value"
- id: flow-test11-preprocessor-and-failure-handler
@@ -1,47 +0,0 @@
{
"value": {
"modules": [
{
"id": "count_until_target",
"value": {
"type": "whileloopflow",
"skip_failures": false,
"modules": [
{
"id": "increment_counter",
"value": {
"type": "rawscript",
"language": "bun"
}
}
]
},
"stop_after_if": {
"expr": "result >= flow_input.target",
"skip_if_stopped": false
}
},
{
"id": "return_final_counter",
"value": {
"type": "rawscript"
}
}
]
},
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"target": {
"type": "number"
}
},
"required": [
"target"
],
"order": [
"target"
]
}
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE postgres_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "03fdb7d0d4d95a98fdbdcf7dd1a9289119b9ce719d6a6c2975e96d7785819fc9"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET slack_email = $1 WHERE slack_email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "099067cf6c08642b205074771ef23d7ab950015d70f7065f5645f1e76d6a25ee"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE azure_trigger SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "0da2425ff8ee737104cd9f2650f1ffba1511715ac1f9270939d79fb82f8e88d0"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE schedule SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "0f49203698dce2d77724c3d2fbaf729c6b2c2f9dd9475b778dbce66f28b48e69"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO folder(workspace_id, name, display_name, owners, extra_perms, default_permissioned_as)\n VALUES ('test-workspace', 'fold', 'fold', '{}', '{}'::jsonb,\n '[{\"path_glob\": \"a/**\", \"permissioned_as\": \"u/other\"}, {\"path_glob\": \"**\", \"permissioned_as\": \"test2@windmill.dev\"}]'::jsonb)",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "1159c2beb23057e3123847efa2b5d2d7cd7c89b47cbf5000fb74a9cdaa4b62df"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_fork_deployment_request_comment SET author_email = $1 WHERE author_email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "162ab0cc9094d7031229a65f47d730282d17bc8a71a0e234c9edf81b85199dd1"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of'], to_jsonb($1::text)) WHERE policy->>'on_behalf_of' = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "1b828adc60798b096ebf5db75fc1ade1ffffff492dc02caafe002fd868d90a8d"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id FROM usr WHERE email = 'renamed@windmill.dev'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "24e8f955d2a9a3bf502736749563e82974a5744dbb4942c1f2b2dff774594252"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE mcp_oauth_server_code SET user_email = $1 WHERE user_email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "273e18a8fe284da79b0929678f2e07d2db3240c1501d99e6b3d2fccfd7405d06"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE flow SET on_behalf_of_email = $1 WHERE on_behalf_of_email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "287a9f01a5b789b7724d7209f8f399638dd57f24fb407cbcb4fe4a510a74d3af"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM workspace_invite o WHERE o.email = $1 AND EXISTS (SELECT 1 FROM workspace_invite n WHERE n.email = $2 AND n.workspace_id = o.workspace_id)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "299a5d12779185a51b6e6815208dfe0aae64c39637208d081462b84d8636aa42"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT owner FROM workspace WHERE id = 'test-workspace'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "owner",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "2b3ffacd09b0fa4bb458f18adbd7cb0f57455338d28e3f164a105a51510c1cc1"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM tutorial_progress WHERE email = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "2f74858ebe82499e06283b0d22cc19ecf4e8c9a9ea6a76977bb17aefe9eb8f27"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE http_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "32e6b8ee457bcf28ed052f56ac8d24ad333ee5c75086ab1f07ed952cf6aecea9"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM usage WHERE id = $1 AND NOT is_workspace",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "34f1e04d3ff0ffe7fe4e142bb54e00431bc7fb4a4b20dacdce79316d7a84ba8f"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job SET permissioned_as_email = $1 WHERE permissioned_as_email = $2 AND id IN (SELECT id FROM v2_job_queue)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "35384f86bf1058a228307655c545bfbe91032050603e882bca0d7352a0f08a62"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE kafka_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "35b7c715362239c09765d3717340216154b4bda0bd5729e93318fd6527fbbbc4"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT username FROM password WHERE email = $1 FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "3b7e46f80cf1e543a76f90c74080a3e2d1cf04812aa1091e3c64f262988da099"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT username FROM password WHERE email = 'renamed@windmill.dev'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
true
]
},
"hash": "3c5e00d0e79fa518a95b05a00e75dbd96d7852ad340759252de4dcb57ad22a43"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_invite SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "3fec727489a5166c3036593d7089fd252e2c779762cb359f98a7d17e657a926f"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE amqp_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "44356ac660ef1113f21b3ec24d67de7e64cda8f1e8419c231ecc090447614c4e"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT permissioned_as FROM schedule WHERE path = 'u/test-user-2/sched'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "permissioned_as",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "5ae3532928770d6bd9fc57a3ca489f819d8845ddb54f09f53880a55cd2304c8c"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE mqtt_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "5c3e4a9fd4ac5ce75326230ecbbd40bb126ae3e2dff1824904da91b2f7d900cd"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE usr SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "5d1bcca26a78968351da909b23cb5fd0d90ed946263db6ffda99ca1cd9e727f9"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE tutorial_progress SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "5e74efd5f9152deb9511d2f1efc084cbf6aa8f252d86a50fa3a4f4e93bf373aa"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO app(workspace_id, path, summary, policy, versions)\n VALUES ('test-workspace', 'u/test-user-2/app', '', '{\"on_behalf_of\": \"test2@windmill.dev\", \"on_behalf_of_email\": \"test2@windmill.dev\"}'::jsonb, '{}')",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "629a6f38411c49c13bfb7773cef87ef0b833f57e23392ba0f9c6d2a165a4c9fe"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job SET permissioned_as = $1 WHERE permissioned_as = $2 AND id IN (SELECT id FROM v2_job_queue)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "639f87dbe115bbd3d9eb9a70e74b4c8b3a71829aa9048fc2459e1f6603956e5d"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE websocket_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "647c12e69360e6a34d01a563e6e84b26192c18cb4ede596233c00187b789d9ec"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE usage SET id = $1 WHERE id = $2 AND NOT is_workspace",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "65ee4ace29b39818594efdfa16453e4806c4dca776f80789a57d4d85e16aa197"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_fork_deployment_request_assignee SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "73910791d9eca97624c1b1b9dd65cc861d0339a4238bce6928af49601a6ad4d1"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE email_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "774db0fcbc3905a8c80f271612efcaf7268011f8868028cad6d5339419cb7aef"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE password SET username = 'test-user-2' WHERE email = 'test2@windmill.dev'",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "883be05a1a02dde8d9056094a4f17491791832d5bd206ab99a8b96ad30a0a3f5"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM email_to_igroup o WHERE o.email = $1 AND EXISTS (SELECT 1 FROM email_to_igroup n WHERE n.email = $2 AND n.igroup = o.igroup)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "8bd1c2f216b4c525888bb94f94e09c3c519eb4bccea78954cb25ff945807883c"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE gcp_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "9d6375303333eab6424dfba2b8fc4afb0d3fc87b79818b5087919a7ae604545f"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM password WHERE email = 'test2@windmill.dev'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "a3233c8a26f4ca47e957d55e77d9e3ccd51dcf9d259527e0b1f20d6e7f43f80b"
}
@@ -0,0 +1,30 @@
{
"db_name": "PostgreSQL",
"query": "WITH RECURSIVE queued_ancestors AS (\n SELECT j.id, j.parent_job, 0 AS depth\n FROM v2_job j JOIN v2_job_queue q USING (id)\n WHERE j.id = $1 AND j.workspace_id = $2\n UNION ALL\n SELECT j.id, j.parent_job, a.depth + 1\n FROM queued_ancestors a\n JOIN v2_job j ON j.id = a.parent_job AND j.workspace_id = $2\n JOIN v2_job_queue q ON q.id = j.id\n WHERE a.depth < $3\n )\n SELECT id AS \"id!\", depth AS \"depth!\" FROM queued_ancestors ORDER BY depth DESC LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "depth!",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Uuid",
"Text",
"Int4"
]
},
"nullable": [
null,
null
]
},
"hash": "a6a9a8013ac8ea8ecba8a39c3c0bfb0b5ccdd31740cd63f2d174b3cd1e18019d"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO notify_event (channel, payload) SELECT 'notify_token_invalidation', token_prefix FROM token WHERE email = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "a718367f00f7dc0280ab297ff66a939f930eb2a43beac528fd3b5fb6add5e251"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT policy::text FROM app WHERE path = 'u/test-user-2/app' AND workspace_id = 'test-workspace'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "policy",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "ab07fa69343f1ab5e2c89911c314d027a05302085c838aa1c2d84406f9fffdb2"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE script SET on_behalf_of_email = $1 WHERE on_behalf_of_email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "acf96371f08812a15760436f43f05290ec39617e18e65f05fa4a9b002560a6d5"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_fork_deployment_request SET requested_by_email = $1 WHERE requested_by_email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "aefab48bd35a3bbfdb6d5a86d017acdd2d3759e33b1c75e54aee140d8db562d6"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT default_permissioned_as::text FROM folder WHERE workspace_id = 'test-workspace' AND name = 'fold'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "default_permissioned_as",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "c22b71a3a40710e44e66aa48bda41de76b2cc0d0495c7d32f565d2548bf8c860"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(\n SELECT 1 FROM workspace WHERE owner = $1\n UNION ALL SELECT 1 FROM workspace_settings WHERE slack_email = $1\n UNION ALL SELECT 1 FROM usage WHERE id = $1 AND NOT is_workspace\n UNION ALL SELECT 1 FROM v2_job WHERE permissioned_as = $1 AND id IN (SELECT id FROM v2_job_queue))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "c9513c08e03089a581a8bcc5178094fc42098ac26facbd010006ae06da284131"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE nats_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "c9d78891f8ccdfc12c8315653187cc8d2d4a2df257e5fd723016c6344174d4a1"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE sqs_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "d24faa96edde6ff6cb7c443775552419ff11b6671a58e3aa0c75a7b48973437a"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM unique_ext_jwt_token WHERE email = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "d5f94950c15ee592d5580cf001b37560f2093f9569a2d8a4a702004041976961"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE token SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "d7597022d6fc01601bff5f243fb38080fc6b65e95a3aad630c7abe3edcedbc98"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace SET owner = 'test2@windmill.dev' WHERE id = 'test-workspace'",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "d94140411f0ddefd74732e9a755249a044397b6fbfe164bc3dfd007ccd490388"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE azure_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "d9b33bb1cf7894d5c61caa599975cc39b961191259bbccc233edca59bc7252b9"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE job_perms SET email = $1 WHERE email = $2 AND job_id IN (SELECT id FROM v2_job_queue)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "d9b877b970b8e7e5d895d9b4f83ba945776b17c153b77cb2933864e9cd313439"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE folder SET default_permissioned_as = (\n SELECT jsonb_agg(\n CASE WHEN rule->>'permissioned_as' = $2\n THEN jsonb_set(rule, ARRAY['permissioned_as'], to_jsonb($1::text))\n ELSE rule END\n ORDER BY ord)\n FROM jsonb_array_elements(default_permissioned_as) WITH ORDINALITY AS t(rule, ord))\n WHERE default_permissioned_as @> jsonb_build_array(jsonb_build_object('permissioned_as', $2::text))",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "de902e3eded6c244ea4b4594c30e32b9e86b1902bfa504d60f0b3bef8f1e8ea6"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE password SET email = 'Legacy@windmill.dev' WHERE email = 'renamed@windmill.dev'",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "df893ad32fd18b55d975247289d32fb80cbd8668c7c50c1f0b2ebd5faf50dce1"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace SET owner = $1 WHERE owner = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "e084010d1ff7492b77156c01bdaebae14f8c39ba0715fc1000fb8ff1bf78169f"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(\n SELECT 1 FROM password WHERE lower(email) = $1 AND email <> $2\n UNION ALL SELECT 1 FROM usr WHERE lower(email) = $1 AND email <> $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "ececc8132f985b3e2d8301d5dc24356953e6e6cd71f28384b6f51231a35c7947"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE capture_config SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "eeb0ef2be5db28fa722a977dfbecfe0dddc1d33b3f98c649cd91d9a3a5785e34"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE mcp_oauth_refresh_token SET user_email = $1 WHERE user_email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "eff07725675fa97c91f86564c13658316f5c3dff23704feca654dedc035adacf"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of_email'], to_jsonb($1::text)) WHERE policy->>'on_behalf_of_email' = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "f8c3c8cc0b3bb90adf611bab39f71cdd9a96e35508c852ab9c1dcbae3decbaba"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO schedule(workspace_id, path, edited_by, schedule, timezone, enabled, script_path, is_flow, args, email, permissioned_as)\n VALUES ('test-workspace', 'u/test-user-2/sched', 'test-user-2', '0 0 1 1 *', 'UTC', false, 'u/test-user-2/s', false, '{}'::json, 'test2@windmill.dev', 'test2@windmill.dev')",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "f8fa9ba552697292752e80a224e9c85bbed7cb0196c40927bf5a542416cf3b15"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE schedule SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "fbd24c0bc6c364d643f81f0a1baa4db6beaa12b133932ce0fd6c16a771a5a594"
}
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_script_owner_prefix;
DROP INDEX IF EXISTS idx_flow_owner_prefix;
DROP INDEX IF EXISTS idx_app_owner_prefix;
DROP INDEX IF EXISTS app_extra_perms;
@@ -0,0 +1,24 @@
-- Indexes backing the homepage tree's per-owner runnable counts
-- (/runnables/counts), which counts each `f/<folder>` / `u/<user>` prefix with a
-- byte-ordered range `path ~>=~ owner || '/' AND path ~<~ owner || '0'`.
-- text_pattern_ops is required: the default opclass sorts by the database
-- collation, so a byte-prefix range cannot use it. `auto_kind` is an INCLUDE
-- column so the library-script filter is answered from the index tuple and the
-- count stays an index-only scan.
-- Created CONCURRENTLY via the OVERRIDDEN_MIGRATIONS rewrite in windmill-api/src/db.rs.
CREATE INDEX IF NOT EXISTS idx_script_owner_prefix
ON script (workspace_id, path text_pattern_ops)
INCLUDE (auto_kind)
WHERE archived = false;
CREATE INDEX IF NOT EXISTS idx_flow_owner_prefix
ON flow (workspace_id, path text_pattern_ops)
WHERE archived = false;
CREATE INDEX IF NOT EXISTS idx_app_owner_prefix
ON app (workspace_id, path text_pattern_ops);
-- Same purpose as the existing script_extra_perms / flow_extra_perms GIN
-- indexes: the counts endpoint finds items shared explicitly with the caller
-- (or one of their groups) with a single `extra_perms ?| ARRAY[...]` scan.
CREATE INDEX IF NOT EXISTS app_extra_perms ON app USING gin (extra_perms);
+27
View File
@@ -229,3 +229,30 @@ INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, resu
'{"mid": "MID_RESULT"}'),
('88888888-8888-8888-8888-888888888888', 'test-workspace', 1000, 'success'::job_status,
'{"deep": "DEEP_STEP_INHERITED"}');
-- 6. QUEUED nesting for force-cancel: a hidden top flow (`f/secret/qtop`) whose
-- step is a sub-flow in the visible `shared` folder (`f/shared/qmid`). Force
-- cancel walks up to the highest queued ancestor, so force-cancelling the
-- sub-flow test-user-3 CAN see would kill the top flow they cannot.
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner
) VALUES (
'66666666-6666-6666-6666-666666666666', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'flow', 'deno', 'f/secret/qtop', 'flow', true
);
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner,
parent_job, root_job, flow_innermost_root_job
) VALUES (
'55555555-5555-5555-5555-555555555555', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'flow', 'deno', 'f/shared/qmid', 'flow', true,
'66666666-6666-6666-6666-666666666666', '66666666-6666-6666-6666-666666666666',
'66666666-6666-6666-6666-666666666666'
);
INSERT INTO public.v2_job_queue (id, workspace_id, scheduled_for, running, tag) VALUES
('66666666-6666-6666-6666-666666666666', 'test-workspace', '2023-01-01 00:00:00', true, 'flow'),
('55555555-5555-5555-5555-555555555555', 'test-workspace', '2023-01-01 00:00:00', true, 'flow');
+61 -3
View File
@@ -23,7 +23,11 @@
//! - the "app component" affordance survives: a viewer who *launched* a job
//! (created_by) running as someone else's identity can still read its result,
//! - unauthenticated behavior is unchanged: anonymous jobs readable, the
//! non-anonymous victim job rejected.
//! non-anonymous victim job rejected,
//! - `queue/cancel` and `queue/force_cancel` are gated by that same access, so
//! a viewer cannot kill a run hidden from them while its owner still can, and
//! force cancel gates on the ancestor it actually kills rather than the id in
//! the URL.
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
@@ -42,6 +46,9 @@ const RUNNING_JOB: &str = "77777777-7777-7777-7777-777777777777";
const EMBED_OWN_JOB: &str = "12121212-1212-1212-1212-121212121212";
// A QUEUED job launched by the embed viewer (created_by test-user) — cancelable by it.
const EMBED_OWN_QUEUED: &str = "13131313-1313-1313-1313-131313131313";
// Queued sub-flow test-user-3 can see (folder `shared`), whose parent top flow they
// cannot. Force cancel walks up to that parent.
const QUEUED_VISIBLE_MID: &str = "55555555-5555-5555-5555-555555555555";
// Secrets that must never leak to an unauthorized viewer.
const RESULT_SECRET: &str = "RESULT_SECRET";
@@ -347,8 +354,8 @@ async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Resul
// ---- APP EMBED TOKEN: cancellation confined to the app's own jobs. The token
// may cancel a job it launched (created_by == viewer), but `cancel_job_api`
// denies (NotFound) a job created by someone else, even though cancel
// otherwise has no per-job ownership check.
// denies (NotFound) a job created by someone else, even one the (admin)
// viewer could otherwise cancel.
let (status, body) = post(
&base,
&format!("queue/cancel/{EMBED_OWN_QUEUED}"),
@@ -598,5 +605,56 @@ async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Resul
"owner must see the running job as started (got {status}): {body}"
);
// ---- CANCEL / FORCE_CANCEL are gated by the same per-job access as reading:
// knowing the UUID of a run hidden from you must not let you kill it. ----
for path in [
format!("queue/cancel/{RUNNING_JOB}"),
format!("queue/force_cancel/{RUNNING_JOB}"),
] {
let (status, body) = post(&base, &path, Some("SECRET_TOKEN_3")).await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"viewer must not cancel another user's job ({path}, got {status}): {body}"
);
}
// The owner still cancels their own job (no over-blocking). Keep this last: it
// takes RUNNING_JOB out of the queue.
let (status, body) = post(
&base,
&format!("queue/cancel/{RUNNING_JOB}"),
Some("SECRET_TOKEN_2"),
)
.await;
assert!(
status.is_success(),
"owner must still cancel their own job (got {status}): {body}"
);
// Force cancel kills the highest queued ancestor, not the job named in the URL, so it
// must authorize that ancestor: the viewer can see the sub-flow (asserted first, or the
// denial below would prove nothing) but not the top flow force-cancelling it would kill.
let (status, body) = get(
&base,
&format!("get/{QUEUED_VISIBLE_MID}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"viewer must be able to read the sub-flow (got {status}): {body}"
);
let (status, body) = post(
&base,
&format!("queue/force_cancel/{QUEUED_VISIBLE_MID}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"viewer must not force-cancel up into a flow they cannot see (got {status}): {body}"
);
Ok(())
}
+227
View File
@@ -0,0 +1,227 @@
//! Regression test for `GET /w/{workspace}/runnables/counts`, which the homepage
//! tree uses to label every folder / user node and to drop the empty ones.
//!
//! The endpoint runs off the non-RLS pool and re-derives visibility from `path`
//! (admin / folder read set / own user space) plus one `extra_perms` pass, so it
//! duplicates by hand what RLS otherwise enforces. That duplication is what can
//! drift: a change to the RLS policies, or to how `authed.folders`/`groups` are
//! populated, would silently make the counts over- or under-report. So the test
//! pins them to the ground truth rather than to their own implementation — the
//! counts must equal the per-owner grouping of `/runnables/list`, which is
//! RLS-enforced, across every way an item can become visible.
use sqlx::{Pool, Postgres};
use std::collections::HashMap;
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {}", token))
}
fn owner_of(path: &str) -> String {
path.split('/').take(2).collect::<Vec<_>>().join("/")
}
/// Per-owner counts derived from the RLS-enforced listing. Pipeline members are
/// dropped to match what the tree renders (and what /counts promises): they are
/// folded into their folder's single pipeline entry, never listed as rows.
async fn counts_from_list(port: u16, token: &str) -> HashMap<String, i64> {
let base = format!("http://localhost:{port}/api/w/test-workspace/runnables/list");
let mut out: HashMap<String, i64> = HashMap::new();
let mut cursor: Option<String> = None;
loop {
let mut url = format!("{base}?per_page=100");
if let Some(c) = &cursor {
url.push_str(&format!("&cursor={c}"));
}
let resp = authed(client().get(&url), token).send().await.unwrap();
assert_eq!(resp.status(), 200, "list should succeed");
let body: serde_json::Value = resp.json().await.unwrap();
for it in body["items"].as_array().unwrap() {
if it["auto_kind"].as_str() == Some("pipeline") {
continue;
}
*out.entry(owner_of(it["path"].as_str().unwrap()))
.or_insert(0) += 1;
}
match body["next_cursor"].as_str() {
Some(c) => cursor = Some(c.to_string()),
None => break,
}
}
out
}
async fn counts_endpoint_q(port: u16, token: &str, query: &str) -> HashMap<String, i64> {
let url = format!("http://localhost:{port}/api/w/test-workspace/runnables/counts?{query}");
let resp = authed(client().get(&url), token).send().await.unwrap();
assert_eq!(resp.status(), 200, "counts should succeed");
let body: serde_json::Value = resp.json().await.unwrap();
body["counts"]
.as_object()
.unwrap()
.iter()
.map(|(k, v)| (k.clone(), v.as_i64().unwrap()))
.collect()
}
async fn counts_endpoint(port: u16, token: &str) -> HashMap<String, i64> {
counts_endpoint_q(port, token, "").await
}
async fn insert_script(
db: &Pool<Postgres>,
hash: i64,
path: &str,
extra_perms: &str,
auto_kind: Option<&str>,
) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, language, archived, extra_perms, auto_kind)
VALUES ('test-workspace', $1, $2, '', '', 'x', 'test-user', 'deno', false, $3::jsonb, $4)",
)
.bind(hash)
.bind(path)
.bind(extra_perms)
.bind(auto_kind)
.execute(db)
.await?;
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_runnable_counts_match_rls_listing(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
// `team` is readable by test-user-2; `secret` is not. Everything visible to
// test-user-2 out of `secret` can only get there through an explicit share.
for (name, extra_perms) in [("team", r#"{"u/test-user-2": false}"#), ("secret", "{}")] {
sqlx::query(
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms)
VALUES ('test-workspace', $1, $1, ARRAY[]::text[], $2::jsonb)",
)
.bind(name)
.bind(extra_perms)
.execute(&db)
.await?;
}
// `empty` has no runnables at all: it must be absent from the counts so the
// tree can drop it.
sqlx::query(
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms)
VALUES ('test-workspace', 'empty', 'empty', ARRAY[]::text[], '{\"u/test-user-2\": false}'::jsonb)",
)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO usr_to_group (workspace_id, group_, usr) VALUES ('test-workspace', 'all', 'test-user-2')",
)
.execute(&db)
.await?;
// Visible through the folder grant.
insert_script(&db, 9000001, "f/team/s1", "{}", None).await?;
insert_script(&db, 9000002, "f/team/s2", "{}", None).await?;
// Visible through the caller's own user space.
insert_script(&db, 9000003, "u/test-user-2/mine", "{}", None).await?;
// Not visible at all: another user's space, and an unreadable folder.
insert_script(&db, 9000004, "u/test-user/theirs", "{}", None).await?;
insert_script(&db, 9000005, "f/secret/hidden", "{}", None).await?;
// Visible only through an individual share out of an unreadable folder.
insert_script(
&db,
9000006,
"f/secret/shared_to_user",
r#"{"u/test-user-2": false}"#,
None,
)
.await?;
// Visible only through a group the caller belongs to.
insert_script(
&db,
9000007,
"f/secret/shared_to_group",
r#"{"g/all": false}"#,
None,
)
.await?;
// A pipeline member in a readable folder: listed as the folder's pipeline
// entry, not as a runnable, so it must not inflate f/team's count.
insert_script(&db, 9000008, "f/team/step", "{}", Some("pipeline")).await?;
sqlx::query(
"INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, schema, archived)
VALUES ('test-workspace', 'f/team/fl1', '', '', '{}'::jsonb, 'test-user', '{}'::json, false)",
)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms)
VALUES ('test-workspace', 'f/team/app1', '', '{}'::jsonb, ARRAY[]::bigint[], '{}'::jsonb)",
)
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// test-user-2 is a plain member: the folder grant, their own space, the direct
// share and the group share must each land, and nothing else.
let counts = counts_endpoint(port, "SECRET_TOKEN_2").await;
assert_eq!(
counts,
counts_from_list(port, "SECRET_TOKEN_2").await,
"non-admin counts must match the RLS-enforced listing"
);
assert_eq!(
counts,
HashMap::from([
("f/team".to_string(), 4),
("u/test-user-2".to_string(), 1),
("f/secret".to_string(), 2),
]),
"expected the folder grant (2 scripts + flow + app, pipeline member excluded), \
the own user space, and the two shares out of f/secret"
);
// An admin bypasses RLS entirely (and takes the grouped whole-workspace path
// rather than the per-owner prefix scans), so the counts must widen accordingly.
assert_eq!(
counts_endpoint(port, "SECRET_TOKEN").await,
counts_from_list(port, "SECRET_TOKEN").await,
"admin counts must match the RLS-enforced listing"
);
// Every kind is its own count subquery, so a repeated entry must not be counted
// twice (nor multiply the scans).
assert_eq!(
counts_endpoint_q(port, "SECRET_TOKEN_2", "kinds=script,script").await,
counts_endpoint_q(port, "SECRET_TOKEN_2", "kinds=script").await,
"duplicate kinds must not double the counts"
);
// Operators see flows and apps like anyone else; only library scripts are hidden
// from them. (test-user-3 has no grants, so their own space is the whole story.)
sqlx::query("UPDATE usr SET operator = true WHERE workspace_id = 'test-workspace' AND username = 'test-user-3'")
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, schema, archived)
VALUES ('test-workspace', 'u/test-user-3/fl', '', '', '{}'::jsonb, 'test-user-3', '{}'::json, false)",
)
.execute(&db)
.await?;
insert_script(&db, 9000009, "u/test-user-3/lib", "{}", Some("lib")).await?;
assert_eq!(
counts_endpoint_q(port, "SECRET_TOKEN_3", "include_without_main=true").await,
HashMap::from([("u/test-user-3".to_string(), 1)]),
"an operator's flow must be counted, and library scripts stay hidden from them \
even when asked for"
);
Ok(())
}
+90 -4
View File
@@ -30,6 +30,52 @@ pub const OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
pub const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1";
pub const GOOGLE_AI_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
/// Hosts that serve the OpenAI API with Azure conventions: Azure OpenAI
/// (`*.openai.azure.com`), AI Foundry (`*.services.ai.azure.com`,
/// `*.cognitiveservices.azure.com`), their sovereign-cloud counterparts, and API
/// Management fronting either.
const AZURE_HOST_SUFFIXES: &[&str] = &[
".azure.com",
".azure.us",
".azure.cn",
".azure-api.net",
".azure-api.us",
".azure-api.cn",
];
/// The deployment path Azure OpenAI serves under. It identifies an Azure endpoint
/// reached through a custom domain, which `AZURE_HOST_SUFFIXES` cannot catch — it
/// is the format `openai_azure_base_path` documents.
const AZURE_DEPLOYMENTS_PATH: &str = "/openai/deployments";
/// Whether an OpenAI-API base URL is served by Azure, which authenticates with the
/// `api-key` header and lays its routes out under `/openai/...`.
///
/// An OpenAI-compatible endpoint on an Azure-owned domain (e.g. a self-hosted
/// server behind API Management) is misread as Azure; such a resource has to use
/// the `customai` provider.
fn is_azure_endpoint(base_url: &str) -> bool {
let authority = base_url
.split_once("://")
.map_or(base_url, |(_, rest)| rest)
.split(['/', '?', '#'])
.next()
.unwrap_or_default();
let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
let host = host_port
.rsplit_once(':')
.map_or(host_port, |(host, _)| host)
.trim_end_matches('.')
.to_ascii_lowercase();
AZURE_HOST_SUFFIXES
.iter()
.any(|suffix| host.ends_with(suffix))
|| base_url
.to_ascii_lowercase()
.contains(AZURE_DEPLOYMENTS_PATH)
}
/// Empty string signals BedrockClient::from_env() to use the region from AWS environment/config
/// (e.g., AWS_REGION or AWS_DEFAULT_REGION env vars, or ~/.aws/config)
pub const USE_ENV_REGION: &str = "";
@@ -107,7 +153,7 @@ impl AIProvider {
OPENAI_AZURE_BASE_PATH.clone()
};
Ok(azure_base_path.unwrap_or("https://api.openai.com/v1".to_string()))
Ok(azure_base_path.unwrap_or_else(|| OPENAI_BASE_URL.to_string()))
}
AIProvider::DeepSeek => Ok(DEEPSEEK_BASE_URL.to_string()),
AIProvider::GoogleAI => Ok(GOOGLE_AI_BASE_URL.to_string()),
@@ -139,10 +185,17 @@ impl AIProvider {
/// Check whether this provider/URL combination uses Azure conventions
/// (the `api-key` auth header and Azure URL building). This covers Azure
/// OpenAI, Azure AI Foundry, and the `OpenAI` provider pointed at an Azure
/// base path override.
/// endpoint through `openai_azure_base_path` or a resource base URL.
///
/// A custom `OpenAI` base URL that is not an Azure endpoint is an
/// OpenAI-compatible one (gateway, proxy, self-hosted server) and keeps bearer
/// auth and the plain `<base>/<path>` layout.
pub fn is_azure(&self, base_url: &str) -> bool {
(matches!(self, AIProvider::OpenAI) && base_url != OPENAI_BASE_URL)
|| matches!(self, AIProvider::AzureOpenAI | AIProvider::AzureFoundry)
match self {
AIProvider::AzureOpenAI | AIProvider::AzureFoundry => true,
AIProvider::OpenAI => is_azure_endpoint(base_url),
_ => false,
}
}
/// Build an Azure-style OpenAI-compatible URL (Azure OpenAI / Azure AI Foundry)
@@ -300,6 +353,39 @@ mod tests {
);
}
/// An OpenAI resource with a custom base URL is an OpenAI-compatible endpoint
/// unless it is an Azure one. Azure treatment swaps bearer auth for the
/// `api-key` header and rewrites the path, so both directions must hold.
#[test]
fn openai_is_azure_only_for_azure_endpoints() {
for base_url in [
// A gateway path ending in /openai must not read as Azure.
"https://gateway-eu.pydantic.dev/proxy/openai",
"https://openrouter.ai/api/v1",
"http://localhost:4000/v1",
OPENAI_BASE_URL,
] {
assert!(
!AIProvider::OpenAI.is_azure(base_url),
"{base_url} must not be treated as Azure"
);
}
for base_url in [
"https://example.openai.azure.com/openai/deployments/my-deployment",
"https://wm-test-ai.services.ai.azure.com",
"https://contoso.azure-api.cn/openai",
// Azure OpenAI behind a custom domain, the format
// `openai_azure_base_path` documents.
"https://openai.contoso.com/openai/deployments/gpt-4o",
] {
assert!(
AIProvider::OpenAI.is_azure(base_url),
"{base_url} must be treated as Azure"
);
}
}
#[test]
fn azure_foundry_anthropic_url_from_root_and_legacy() {
// Root URL.
+56 -32
View File
@@ -2,14 +2,13 @@ use crate::{
ai_google::parse_data_url,
ai_providers::{AIPlatform, AIProvider},
image_handler::prepare_messages_for_api,
proxy::{add_user_to_body, ProxyBuildArgs, ProxyRequest},
proxy::{
add_user_to_body, common_outbound_headers, credential_header, ProxyBuildArgs, ProxyRequest,
},
query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink},
sse::{AnthropicSSEParser, SSEParser},
types::*,
utils::{
collect_system_prompt, extract_text_content, should_use_structured_output_tool,
AI_HTTP_HEADERS,
},
utils::{collect_system_prompt, extract_text_content, should_use_structured_output_tool},
};
use async_trait::async_trait;
use http::Method;
@@ -544,31 +543,23 @@ impl AnthropicQueryBuilder {
}
}
if let Some(api_key) = credentials.api_key.as_ref() {
headers.push(("authorization".to_string(), format!("Bearer {}", api_key)));
if !is_vertex {
headers.push(("X-API-Key".to_string(), api_key.clone()));
}
}
if let Some(access_token) = credentials.access_token.as_ref() {
headers.push((
"authorization".to_string(),
format!("Bearer {}", access_token),
));
}
// One credential header, matching `get_auth_headers`: Vertex takes an OAuth
// bearer token, every other Messages endpoint takes x-api-key. Endpoints in
// front of Anthropic reject requests carrying both.
headers.extend(credential_header(
credentials,
if is_vertex {
"authorization"
} else {
"x-api-key"
},
));
if let Some(org_id) = credentials.organization_id.as_ref() {
headers.push(("OpenAI-Organization".to_string(), org_id.clone()));
}
for (header_name, header_value) in AI_HTTP_HEADERS.iter() {
headers.push((header_name.clone(), header_value.clone()));
}
for (header_name, header_value) in &credentials.custom_headers {
headers.push((header_name.clone(), header_value.clone()));
}
headers.extend(common_outbound_headers(credentials));
Ok(ProxyRequest { method: args.method.clone(), url, headers, body })
}
@@ -953,12 +944,11 @@ mod tests {
assert_eq!(request.method, Method::POST);
assert_eq!(request.url, "https://api.anthropic.com/v1/messages");
assert_eq!(request.body, body.to_vec());
assert!(has_header(
&request.headers,
"authorization",
"Bearer api-key"
));
assert!(has_header(&request.headers, "X-API-Key", "api-key"));
assert!(has_header(&request.headers, "x-api-key", "api-key"));
assert!(!request
.headers
.iter()
.any(|(header_name, _)| header_name.eq_ignore_ascii_case("authorization")));
assert!(has_header(
&request.headers,
"anthropic-version",
@@ -1148,6 +1138,40 @@ mod tests {
assert!(matches!(err, Error::BadRequest(message) if message.contains("Missing 'model'")));
}
/// Endpoints that authenticate with a bearer token configure it as a resource
/// header; the built-in x-api-key must then step aside, since outgoing headers
/// are appended and both credentials would travel.
#[test]
fn resource_header_replaces_the_built_in_credential() {
let mut credentials = credentials(AIPlatform::Standard);
credentials.custom_headers = HashMap::from([(
"Authorization".to_string(),
"Bearer gateway-token".to_string(),
)]);
let builder = AnthropicQueryBuilder::new(AIProvider::Anthropic, AIPlatform::Standard);
let method = Method::POST;
let request = builder
.build_proxy_request(&ProxyBuildArgs {
method: &method,
path: "messages",
headers: &HeaderMap::new(),
body: br#"{"model":"claude-sonnet-4","messages":[]}"#,
credentials: &credentials,
})
.unwrap();
assert!(has_header(
&request.headers,
"Authorization",
"Bearer gateway-token"
));
assert!(!request
.headers
.iter()
.any(|(header_name, _)| header_name.eq_ignore_ascii_case("x-api-key")));
}
#[test]
fn builds_azure_foundry_anthropic_proxy_request() {
// Foundry resource stored with a legacy /openai/v1 suffix; the Anthropic SDK
@@ -1176,6 +1200,6 @@ mod tests {
request.url,
"https://wm-test-ai.services.ai.azure.com/anthropic/v1/messages"
);
assert!(has_header(&request.headers, "X-API-Key", "api-key"));
assert!(has_header(&request.headers, "x-api-key", "api-key"));
}
}
+165 -21
View File
@@ -82,6 +82,72 @@ pub fn supports_query_builder_proxy(provider: &AIProvider) -> bool {
proxy_execution_mode(provider).uses_query_builder_proxy()
}
/// The headers a provider can carry its credential in. Any other resource header
/// is passed through untouched, so a header one provider authenticates with stays
/// an ordinary header for the providers that do not.
pub const CREDENTIAL_HEADERS: [&str; 4] =
["authorization", "x-api-key", "api-key", "x-goog-api-key"];
/// Whether a resource header takes over `built_in`, the credential header Windmill
/// would otherwise send. Outgoing headers are appended rather than replaced, so
/// keeping the built-in one alongside a resource-supplied credential would put two
/// on the wire, which endpoints reject.
///
/// An `authorization` header always takes over, whatever `built_in` is: every
/// endpoint reads it as the credential, so it can never coexist with another one.
pub fn resource_replaces_credential(credentials: &ProviderCredentials, built_in: &str) -> bool {
credentials.custom_headers.keys().any(|header_name| {
header_name.eq_ignore_ascii_case(built_in)
|| header_name.eq_ignore_ascii_case("authorization")
})
}
/// The credential header for an outbound request, or `None` when the resource
/// supplies its own.
///
/// An api key goes in `built_in`, the header the provider authenticates keys
/// with. An OAuth token is always a bearer token instead, whatever header that
/// provider's keys use — Azure OpenAI reads keys from `api-key` but Entra ID
/// tokens only from `authorization`. `authorization` carries `Bearer <secret>`
/// and every other credential header carries the raw secret, for every provider.
pub fn credential_header(
credentials: &ProviderCredentials,
built_in: &str,
) -> Option<(String, String)> {
// `resource_replaces_credential` also matches a resource `authorization`
// header, so this covers the bearer case whatever `built_in` is.
if resource_replaces_credential(credentials, built_in) {
return None;
}
let (name, secret) = match (&credentials.api_key, &credentials.access_token) {
(_, Some(access_token)) => ("authorization", access_token),
(Some(api_key), None) => (built_in, api_key),
(None, None) => return None,
};
let value = if name.eq_ignore_ascii_case("authorization") {
format!("Bearer {}", secret)
} else {
secret.clone()
};
Some((name.to_string(), value))
}
/// The headers every outbound AI request ends with: Windmill's own, then the
/// resource's, which come last so a resource can add to what the provider set.
pub fn common_outbound_headers(
credentials: &ProviderCredentials,
) -> impl Iterator<Item = (String, String)> + '_ {
AI_HTTP_HEADERS
.iter()
.map(|(name, value)| (name.clone(), value.clone()))
.chain(
credentials
.custom_headers
.iter()
.map(|(name, value)| (name.clone(), value.clone())),
)
}
pub fn build_openai_compatible_proxy_request(args: &ProxyBuildArgs<'_>) -> Result<ProxyRequest> {
let credentials = args.credentials;
let body = if let Some(user) = credentials.user.as_ref() {
@@ -100,32 +166,16 @@ pub fn build_openai_compatible_proxy_request(args: &ProxyBuildArgs<'_>) -> Resul
let mut headers = vec![("content-type".to_string(), "application/json".to_string())];
if let Some(api_key) = credentials.api_key.as_ref() {
if is_azure {
headers.push(("api-key".to_string(), api_key.clone()));
} else {
headers.push(("authorization".to_string(), format!("Bearer {}", api_key)));
}
}
if let Some(access_token) = credentials.access_token.as_ref() {
headers.push((
"authorization".to_string(),
format!("Bearer {}", access_token),
));
}
headers.extend(credential_header(
credentials,
if is_azure { "api-key" } else { "authorization" },
));
if let Some(org_id) = credentials.organization_id.as_ref() {
headers.push(("OpenAI-Organization".to_string(), org_id.clone()));
}
for (header_name, header_value) in AI_HTTP_HEADERS.iter() {
headers.push((header_name.clone(), header_value.clone()));
}
for (header_name, header_value) in &credentials.custom_headers {
headers.push((header_name.clone(), header_value.clone()));
}
headers.extend(common_outbound_headers(credentials));
Ok(ProxyRequest { method: args.method.clone(), url, headers, body })
}
@@ -198,6 +248,100 @@ mod tests {
.contains(&("OpenAI-Organization".to_string(), "org-id".to_string())));
}
/// A resource that carries its own credential owns authentication; outgoing
/// headers are appended, not replaced, so the built-in one must be dropped or
/// two credentials reach the endpoint.
#[test]
fn resource_header_replaces_the_built_in_credential() {
let mut credentials = credentials(AIProvider::CustomAI, "https://gateway.example/openai");
credentials.custom_headers = HashMap::from([(
"Authorization".to_string(),
"Bearer gateway-token".to_string(),
)]);
let method = Method::POST;
let request = build_openai_compatible_proxy_request(&ProxyBuildArgs {
method: &method,
path: "chat/completions",
headers: &HeaderMap::new(),
body: br#"{"model":"model","messages":[]}"#,
credentials: &credentials,
})
.unwrap();
assert_eq!(
request
.headers
.iter()
.filter(|(header_name, _)| header_name.eq_ignore_ascii_case("authorization"))
.collect::<Vec<_>>(),
vec![&(
"Authorization".to_string(),
"Bearer gateway-token".to_string()
)]
);
}
/// Only the header the provider authenticates keys with hands over. A
/// credential-shaped header a provider does not read is an ordinary header,
/// and suppressing the built-in credential on account of it strands the
/// request with no credential at all.
#[test]
fn unrelated_credential_header_keeps_the_built_in_one() {
let mut credentials = credentials(AIProvider::CustomAI, "https://gateway.example/openai");
credentials.custom_headers =
HashMap::from([("x-api-key".to_string(), "routing-key".to_string())]);
let method = Method::POST;
let request = build_openai_compatible_proxy_request(&ProxyBuildArgs {
method: &method,
path: "chat/completions",
headers: &HeaderMap::new(),
body: br#"{"model":"model","messages":[]}"#,
credentials: &credentials,
})
.unwrap();
assert!(request
.headers
.contains(&("authorization".to_string(), "Bearer api-key".to_string())));
assert!(request
.headers
.contains(&("x-api-key".to_string(), "routing-key".to_string())));
}
/// Azure reads api keys from `api-key` but Entra ID tokens only from
/// `authorization`, so an OAuth resource must stay on the bearer header
/// whatever the provider's key header is.
#[test]
fn oauth_token_is_sent_as_a_bearer_on_azure() {
let mut credentials = credentials(
AIProvider::AzureOpenAI,
"https://example.openai.azure.com/openai",
);
credentials.api_key = None;
credentials.access_token = Some("oauth-token".to_string());
let method = Method::POST;
let request = build_openai_compatible_proxy_request(&ProxyBuildArgs {
method: &method,
path: "chat/completions",
headers: &HeaderMap::new(),
body: br#"{"model":"deployment","messages":[]}"#,
credentials: &credentials,
})
.unwrap();
assert!(request.headers.contains(&(
"authorization".to_string(),
"Bearer oauth-token".to_string()
)));
assert!(!request
.headers
.iter()
.any(|(header_name, _)| header_name.eq_ignore_ascii_case("api-key")));
}
#[test]
fn query_builder_proxy_support_includes_anthropic() {
let cases = [
+10 -4
View File
@@ -38,15 +38,21 @@ lazy_static::lazy_static! {
// Global auth cache accessible from main.rs for direct invalidation
pub static ref AUTH_CACHE: Cache<(String, String), ExpiringAuthCache> = Cache::new(300);
// Cache for token -> email lookups (for non-workspace-member authenticated users)
static ref TOKEN_EMAIL_CACHE: Cache<String, Option<String>> = Cache::new(500);
static ref TOKEN_EMAIL_CACHE: Cache<String, (Option<String>, std::time::Instant)> = Cache::new(500);
}
/// A token keeps its identity when a superadmin moves the account to another address, so entries
/// here must expire on their own; nothing invalidates them by token hash.
const TOKEN_EMAIL_CACHE_TTL_SECS: u64 = 60;
/// Get email from a valid token, with caching.
/// Used for WM_END_USER_EMAIL when user is authenticated but not a workspace member.
async fn get_email_from_token(db: &DB, token: &str) -> Option<String> {
let t_hash = hash_token(token);
if let Some(cached) = TOKEN_EMAIL_CACHE.get(&t_hash) {
return cached;
if let Some((cached, cached_at)) = TOKEN_EMAIL_CACHE.get(&t_hash) {
if cached_at.elapsed().as_secs() < TOKEN_EMAIL_CACHE_TTL_SECS {
return cached;
}
}
let email = sqlx::query_scalar!(
@@ -59,7 +65,7 @@ async fn get_email_from_token(db: &DB, token: &str) -> Option<String> {
.flatten()
.flatten(); // email column is nullable, so we get Option<Option<String>>
TOKEN_EMAIL_CACHE.insert(t_hash, email.clone());
TOKEN_EMAIL_CACHE.insert(t_hash, (email.clone(), std::time::Instant::now()));
email
}
@@ -511,3 +511,152 @@ async fn test_user_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_change_user_email(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let global_base = format!("http://localhost:{port}/api/users");
let change_email = |email: &str, new_email: &str| {
authed(client().post(format!("{global_base}/change_email/{email}")))
.json(&json!({ "new_email": new_email }))
.send()
};
sqlx::query!("UPDATE password SET username = 'test-user-2' WHERE email = 'test2@windmill.dev'")
.execute(&db)
.await?;
sqlx::query!("UPDATE workspace SET owner = 'test2@windmill.dev' WHERE id = 'test-workspace'")
.execute(&db)
.await?;
// A user whose username is their email is stored as the bare address in `permissioned_as` and
// in an app's policy, rather than as `u/{username}`.
sqlx::query!(
"INSERT INTO schedule(workspace_id, path, edited_by, schedule, timezone, enabled, script_path, is_flow, args, email, permissioned_as)
VALUES ('test-workspace', 'u/test-user-2/sched', 'test-user-2', '0 0 1 1 *', 'UTC', false, 'u/test-user-2/s', false, '{}'::json, 'test2@windmill.dev', 'test2@windmill.dev')"
)
.execute(&db)
.await?;
sqlx::query!(
"INSERT INTO app(workspace_id, path, summary, policy, versions)
VALUES ('test-workspace', 'u/test-user-2/app', '', '{\"on_behalf_of\": \"test2@windmill.dev\", \"on_behalf_of_email\": \"test2@windmill.dev\"}'::jsonb, '{}')"
)
.execute(&db)
.await?;
sqlx::query!(
"INSERT INTO folder(workspace_id, name, display_name, owners, extra_perms, default_permissioned_as)
VALUES ('test-workspace', 'fold', 'fold', '{}', '{}'::jsonb,
'[{\"path_glob\": \"a/**\", \"permissioned_as\": \"u/other\"}, {\"path_glob\": \"**\", \"permissioned_as\": \"test2@windmill.dev\"}]'::jsonb)"
)
.execute(&db)
.await?;
let resp = change_email("test2@windmill.dev", "renamed@windmill.dev")
.await
.unwrap();
assert_eq!(resp.status(), 200, "change_email: {}", resp.text().await?);
// The account row is moved, not recreated, so the instance-wide username and the workspace
// membership follow the new address.
let username =
sqlx::query_scalar!("SELECT username FROM password WHERE email = 'renamed@windmill.dev'")
.fetch_one(&db)
.await?;
assert_eq!(username.as_deref(), Some("test-user-2"));
let workspaces =
sqlx::query_scalar!("SELECT workspace_id FROM usr WHERE email = 'renamed@windmill.dev'")
.fetch_all(&db)
.await?;
assert_eq!(workspaces, vec!["test-workspace".to_string()]);
let owner = sqlx::query_scalar!("SELECT owner FROM workspace WHERE id = 'test-workspace'")
.fetch_one(&db)
.await?;
assert_eq!(owner, "renamed@windmill.dev");
// A `permissioned_as` (or app policy) holding the bare address is the sole identity reference
// those rows have: left stale, the schedule tick and the deployed app run as an account that no
// longer exists.
let permissioned_as = sqlx::query_scalar!(
"SELECT permissioned_as FROM schedule WHERE path = 'u/test-user-2/sched'"
)
.fetch_one(&db)
.await?;
assert_eq!(permissioned_as, "renamed@windmill.dev");
let policy = sqlx::query_scalar!(
"SELECT policy::text FROM app WHERE path = 'u/test-user-2/app' AND workspace_id = 'test-workspace'"
)
.fetch_one(&db)
.await?
.unwrap_or_default();
assert!(
!policy.contains("test2@windmill.dev") && policy.contains("renamed@windmill.dev"),
"app policy should carry only the new address: {policy}"
);
// The rules keep their order, since the folder resolver takes the first glob that matches.
let rules = sqlx::query_scalar!(
"SELECT default_permissioned_as::text FROM folder WHERE workspace_id = 'test-workspace' AND name = 'fold'"
)
.fetch_one(&db)
.await?
.unwrap_or_default();
let rules: serde_json::Value = serde_json::from_str(&rules)?;
assert_eq!(rules[0]["permissioned_as"], "u/other");
assert_eq!(rules[1]["permissioned_as"], "renamed@windmill.dev");
let old_rows =
sqlx::query_scalar!("SELECT COUNT(*) FROM password WHERE email = 'test2@windmill.dev'")
.fetch_one(&db)
.await?;
assert_eq!(old_rows, Some(0));
// Moving onto an address that already has an account would merge two identities. Login
// lowercases what it is given, so a case-only difference collides just the same.
let resp = change_email("test3@windmill.dev", "renamed@windmill.dev")
.await
.unwrap();
assert_eq!(resp.status(), 400);
sqlx::query!(
"UPDATE password SET email = 'Legacy@windmill.dev' WHERE email = 'renamed@windmill.dev'"
)
.execute(&db)
.await?;
let resp = change_email("test3@windmill.dev", "legacy@windmill.dev")
.await
.unwrap();
assert_eq!(resp.status(), 400);
let resp = change_email("test3@windmill.dev", "not-an-email")
.await
.unwrap();
assert_eq!(resp.status(), 400);
let resp = change_email("nobody@windmill.dev", "somebody@windmill.dev")
.await
.unwrap();
assert_eq!(resp.status(), 404);
// Moving your own account would leave your cached identity pointing at a deleted address.
let resp = change_email("test@windmill.dev", "self@windmill.dev")
.await
.unwrap();
assert_eq!(resp.status(), 400);
// Only super admins may move an account.
let resp = client()
.post(format!("{global_base}/change_email/test3@windmill.dev"))
.header("Authorization", "Bearer SECRET_TOKEN_3")
.json(&json!({ "new_email": "hijacked@windmill.dev" }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 401);
Ok(())
}
+1 -6
View File
@@ -1727,12 +1727,7 @@ async fn setup_custom_instance_pg_database_inner(
logs.grant_permissions = "OK".to_string();
drop(client); // /!\ Drop before joining to avoid deadlock
join_handle
.await
.map_err(|e| error::Error::ExecutionErr(format!("join error: {}", e.to_string())))?
.map_err(|e| {
error::Error::ExecutionErr(format!("tokio_postgres error: {}", e.to_string()))
})?;
windmill_common::shutdown_pg_connection(join_handle).await?;
Ok(())
}
+527
View File
@@ -48,6 +48,9 @@ use windmill_common::global_settings::AUTOMATE_USERNAME_CREATION_SETTING;
use windmill_common::oauth2::InstanceEvent;
use windmill_common::users::truncate_token;
use windmill_common::users::COOKIE_NAME;
use windmill_common::users::{
SUPERADMIN_NOTIFICATION_EMAIL, SUPERADMIN_SECRET_EMAIL, SUPERADMIN_SYNC_EMAIL, VALID_EMAIL,
};
use windmill_common::utils::paginate;
use windmill_common::worker::CLOUD_HOSTED;
use windmill_common::{
@@ -133,6 +136,7 @@ pub fn global_service() -> Router {
.route("/update/{user}", post(update_user))
.route("/delete/{user}", delete(delete_user))
.route("/username_info/{user}", get(get_instance_username_info))
.route("/change_email/{user}", post(change_user_email))
.route("/tokens/create", post(create_token))
.route("/tokens/delete/{token_prefix}", delete(delete_token))
.route(
@@ -1692,6 +1696,529 @@ async fn delete_user(
Ok(format!("email {} deleted", &email_to_delete))
}
#[derive(Deserialize)]
struct ChangeUserEmail {
new_email: String,
}
/// `workspace.owner`, `workspace_settings.slack_email` and `usage.id` hold an email in a
/// `varchar(50)`, and `v2_job.permissioned_as` in a `varchar(55)`; every other email column is
/// `varchar(255)`. The strictest of the two bounds is used for all of them.
const SHORT_EMAIL_COLUMN_MAX_LEN: usize = 50;
const EMAIL_COLUMN_MAX_LEN: usize = 255;
/// Move an account to a new email address, in place: the `password` row (and with it the
/// instance-wide username, the role and the login type) is kept and every email-keyed row is
/// repointed at the new address.
///
/// `audit` is deliberately left alone: it records who did what at the time, so rewriting it
/// would falsify history.
async fn change_user_email(
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
Path(old_email): Path<String>,
Extension(db): Extension<DB>,
Json(ce): Json<ChangeUserEmail>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
// The target is matched verbatim (accounts predating email normalization can hold uppercase),
// while the new address is normalized the same way account creation and login do.
let old_email = old_email.trim().to_string();
let new_email = ce.new_email.trim().to_lowercase();
if !VALID_EMAIL.is_match(&new_email) || new_email.len() > EMAIL_COLUMN_MAX_LEN {
return Err(Error::BadRequest(format!(
"{new_email} is not a valid email address of at most {EMAIL_COLUMN_MAX_LEN} characters"
)));
}
if new_email == old_email {
return Err(Error::BadRequest(
"The new email is identical to the current one".to_string(),
));
}
// Every API server caches the caller's identity behind their token and only drops it when the
// invalidation event is polled, so moving your own account would leave you authenticating as an
// address that no longer exists for a few seconds.
if old_email.eq_ignore_ascii_case(&authed.email) {
return Err(Error::BadRequest(
"You cannot change your own email, ask another superadmin to do it".to_string(),
));
}
for reserved in [
SUPERADMIN_SECRET_EMAIL,
SUPERADMIN_NOTIFICATION_EMAIL,
SUPERADMIN_SYNC_EMAIL,
] {
if old_email == reserved || new_email == reserved {
return Err(Error::BadRequest(format!(
"{reserved} is a reserved email address"
)));
}
}
let mut tx = db.begin().await?;
// FOR UPDATE serializes concurrent moves of *this* account. Two moves of different accounts
// onto the same destination are stopped by the `password` primary key instead, which is why the
// unique violation below is mapped back onto the same 400 as the conflict check.
let username = sqlx::query_scalar!(
"SELECT username FROM password WHERE email = $1 FOR UPDATE",
&old_email
)
.fetch_optional(&mut *tx)
.await?;
let username = not_found_if_none(username, "user", &old_email)?;
// Compared case-insensitively: login lowercases what it is given, so an account stored with
// uppercase would be shadowed by a lowercase twin rather than collide with it. The moved
// account is excluded so that normalizing its own address to lowercase stays allowed.
let taken = sqlx::query_scalar!(
"SELECT EXISTS(
SELECT 1 FROM password WHERE lower(email) = $1 AND email <> $2
UNION ALL SELECT 1 FROM usr WHERE lower(email) = $1 AND email <> $2)",
&new_email,
&old_email
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if taken {
return Err(Error::BadRequest(format!(
"{new_email} is already used by another account"
)));
}
if new_email.len() > SHORT_EMAIL_COLUMN_MAX_LEN {
let referenced_by_short_column = sqlx::query_scalar!(
"SELECT EXISTS(
SELECT 1 FROM workspace WHERE owner = $1
UNION ALL SELECT 1 FROM workspace_settings WHERE slack_email = $1
UNION ALL SELECT 1 FROM usage WHERE id = $1 AND NOT is_workspace
UNION ALL SELECT 1 FROM v2_job WHERE permissioned_as = $1 AND id IN (SELECT id FROM v2_job_queue))",
&old_email
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if referenced_by_short_column {
return Err(Error::BadRequest(format!(
"{new_email} is longer than {SHORT_EMAIL_COLUMN_MAX_LEN} characters and this user owns a workspace, a Slack connection, usage counters or a queued job, whose columns cannot hold it"
)));
}
}
// A pending_user row only reserves a username for an address that has no account yet, which
// stops being true here. The moved account keeps its own username.
sqlx::query!("DELETE FROM pending_user WHERE email = $1", &new_email)
.execute(&mut *tx)
.await?;
// ---- account ---- (draft.email follows through its ON UPDATE CASCADE fkey)
sqlx::query!(
"UPDATE password SET email = $1 WHERE email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await
.map_err(|e| match &e {
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
Error::BadRequest(format!("{new_email} is already used by another account"))
}
_ => e.into(),
})?;
sqlx::query!(
"UPDATE usr SET email = $1 WHERE email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE workspace SET owner = $1 WHERE owner = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// Slack commands run as this address when the command maps to no workspace user.
sqlx::query!(
"UPDATE workspace_settings SET slack_email = $1 WHERE slack_email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// Per-user monthly execution counters, keyed by the email.
sqlx::query!(
"DELETE FROM usage WHERE id = $1 AND NOT is_workspace",
&new_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE usage SET id = $1 WHERE id = $2 AND NOT is_workspace",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// ---- instance groups and invites ---- (both keyed on the email, so drop the rows that would
// collide with what the new address was already granted before merging the old ones in)
sqlx::query!(
"DELETE FROM email_to_igroup o WHERE o.email = $1 AND EXISTS (SELECT 1 FROM email_to_igroup n WHERE n.email = $2 AND n.igroup = o.igroup)",
&old_email,
&new_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE email_to_igroup SET email = $1 WHERE email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM workspace_invite o WHERE o.email = $1 AND EXISTS (SELECT 1 FROM workspace_invite n WHERE n.email = $2 AND n.workspace_id = o.workspace_id)",
&old_email,
&new_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE workspace_invite SET email = $1 WHERE email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM tutorial_progress WHERE email = $1", &new_email)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE tutorial_progress SET email = $1 WHERE email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// ---- credentials ---- (a password reset link was mailed to the old address)
sqlx::query!("DELETE FROM magic_link WHERE email = $1", &old_email)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE token SET email = $1 WHERE email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// Tokens stay valid, but every API server caches the authed user behind the raw token, so ask
// them all to drop those entries rather than serve the previous address until they expire.
sqlx::query!(
"INSERT INTO notify_event (channel, payload) SELECT 'notify_token_invalidation', token_prefix FROM token WHERE email = $1",
&new_email
)
.execute(&mut *tx)
.await?;
// An external JWT still asserts the old address, so its cached mapping is stale.
sqlx::query!(
"DELETE FROM unique_ext_jwt_token WHERE email = $1",
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE mcp_oauth_refresh_token SET user_email = $1 WHERE user_email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE mcp_oauth_server_code SET user_email = $1 WHERE user_email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// ---- runnables run on behalf of the user ----
sqlx::query!(
"UPDATE schedule SET email = $1 WHERE email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE capture_config SET email = $1 WHERE email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE azure_trigger SET email = $1 WHERE email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE script SET on_behalf_of_email = $1 WHERE on_behalf_of_email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE flow SET on_behalf_of_email = $1 WHERE on_behalf_of_email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// Apps carry the same identity inside their policy JSONB. An app running in Anonymous or
// Publisher mode takes its permissions from there rather than from the caller, so a stale
// address silently costs it its superadmin flag and its instance groups.
sqlx::query!(
"UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of_email'], to_jsonb($1::text)) WHERE policy->>'on_behalf_of_email' = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// ---- permissioned_as holding a raw email ----
// `username_to_permissioned_as` returns its input verbatim when it contains '@', and the
// migration that introduced these columns back-filled them the same way, so a user whose
// username is their email is stored as the bare address instead of `u/{username}`. Those rows
// are the ones that go stale here; `u/{username}` rows are safe because the username is kept.
sqlx::query!(
"UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of'], to_jsonb($1::text)) WHERE policy->>'on_behalf_of' = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// A folder's default rules are an ordered array, first match wins, so the rewrite has to
// preserve their order. A rule left on the old address makes `ensure_permissioned_as_exists`
// reject the creation of every runnable the rule matches.
sqlx::query!(
r#"UPDATE folder SET default_permissioned_as = (
SELECT jsonb_agg(
CASE WHEN rule->>'permissioned_as' = $2
THEN jsonb_set(rule, ARRAY['permissioned_as'], to_jsonb($1::text))
ELSE rule END
ORDER BY ord)
FROM jsonb_array_elements(default_permissioned_as) WITH ORDINALITY AS t(rule, ord))
WHERE default_permissioned_as @> jsonb_build_array(jsonb_build_object('permissioned_as', $2::text))"#,
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE schedule SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE http_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE websocket_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE postgres_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE mqtt_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE kafka_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE nats_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE sqs_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE gcp_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE email_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE amqp_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE azure_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// ---- jobs ---- Restricted to what is still queued: those rows drive the permissions of a run
// that has not finished yet, whereas completed jobs are history and neither column is indexed
// (rewriting every past row of a busy user would hold this transaction's locks for minutes).
sqlx::query!(
"UPDATE v2_job SET permissioned_as_email = $1 WHERE permissioned_as_email = $2 AND id IN (SELECT id FROM v2_job_queue)",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE v2_job SET permissioned_as = $1 WHERE permissioned_as = $2 AND id IN (SELECT id FROM v2_job_queue)",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE job_perms SET email = $1 WHERE email = $2 AND job_id IN (SELECT id FROM v2_job_queue)",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// ---- fork deployment requests ----
sqlx::query!(
"UPDATE workspace_fork_deployment_request SET requested_by_email = $1 WHERE requested_by_email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE workspace_fork_deployment_request_assignee SET email = $1 WHERE email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE workspace_fork_deployment_request_comment SET author_email = $1 WHERE author_email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"users.change_email",
ActionKind::Update,
"global",
Some(&old_email),
Some([("new_email", new_email.as_str())].into()),
)
.await?;
tx.commit().await?;
Ok(format!(
"changed email of user {old_email} to {new_email}{}",
username
.map(|u| format!(", keeping the instance username {u}"))
.unwrap_or_default()
))
}
lazy_static::lazy_static! {
pub static ref NEW_USER_WEBHOOK: Option<String> = std::env::var("NEW_USER_WEBHOOK").ok();
@@ -2677,7 +2677,7 @@ async fn create_pg_database(
if db_exists {
drop(client);
let _ = join_handle.await;
let _ = windmill_common::shutdown_pg_connection(join_handle).await;
return Err(Error::BadRequest(format!(
"Database '{}' already exists on the resource server",
req.target_dbname
@@ -2695,10 +2695,7 @@ async fn create_pg_database(
})?;
drop(client);
join_handle
.await
.map_err(|e| Error::internal_err(format!("join error: {}", e)))?
.map_err(|e| Error::internal_err(format!("tokio_postgres error: {}", e)))?;
windmill_common::shutdown_pg_connection(join_handle).await?;
}
Ok(format!("Created database '{}'", req.target_dbname))
@@ -2801,10 +2798,7 @@ async fn get_datatable_full_schema(
.map_err(Error::internal_err)?;
drop(client);
join_handle
.await
.map_err(|e| Error::internal_err(format!("join error: {}", e)))?
.map_err(|e| Error::internal_err(format!("tokio_postgres error: {}", e)))?;
windmill_common::shutdown_pg_connection(join_handle).await?;
Ok(Json(result))
}
@@ -6389,10 +6383,7 @@ async fn snapshot_datatable_schema(
.map_err(Error::internal_err)?;
drop(client);
join_handle
.await
.map_err(|e| Error::internal_err(format!("join error: {}", e)))?
.map_err(|e| Error::internal_err(format!("tokio_postgres error: {}", e)))?;
windmill_common::shutdown_pg_connection(join_handle).await?;
serde_json::to_value(schema)
.map_err(|e| Error::internal_err(format!("Failed to serialize schema: {}", e)))
@@ -1352,7 +1352,7 @@ pub async fn drop_forked_datatable_databases(
));
}
drop(client);
let _ = join_handle.await;
let _ = windmill_common::shutdown_pg_connection(join_handle).await;
}
Err(e) => {
errors.push(format!(
@@ -1681,7 +1681,7 @@ async fn drop_fork_ducklake_metadata_schema(
)
.await;
drop(client);
let _ = join_handle.await;
let _ = windmill_common::shutdown_pg_connection(join_handle).await;
res.map_err(|e| Error::internal_err(format!("{e:#}")))?;
Ok(())
}
+68
View File
@@ -732,6 +732,38 @@ paths:
schema:
type: string
/users/change_email/{email}:
post:
summary: global change user email (require super admin)
operationId: globalUserChangeEmail
tags:
- user
parameters:
- name: email
in: path
required: true
schema:
type: string
requestBody:
description: new email
required: true
content:
application/json:
schema:
type: object
properties:
new_email:
type: string
required:
- new_email
responses:
"200":
description: user email changed
content:
text/plain:
schema:
type: string
/users/delete/{email}:
delete:
summary: global delete user (require super admin)
@@ -10555,6 +10587,42 @@ paths:
$ref: "#/components/schemas/RunnableItem"
next_cursor:
type: string
/w/{workspace}/runnables/counts:
get:
summary: count runnables per owner (folder or user space)
operationId: countRunnablesByOwner
tags:
- script
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: kinds
in: query
description: comma-separated subset of script,flow,app (default all)
schema:
type: string
- name: include_without_main
in: query
description: include library scripts (no runnable main)
schema:
type: boolean
responses:
"200":
description: >-
number of runnables per owner prefix, empty owners omitted. Excludes
data-pipeline member scripts, which are listed as their folder's
single pipeline entry rather than as runnables.
content:
application/json:
schema:
type: object
required:
- counts
properties:
counts:
type: object
description: owner prefix (f/<folder> or u/<user>) to count
additionalProperties:
type: integer
/w/{workspace}/flows/list:
get:
summary: list all flows
+3
View File
@@ -96,6 +96,9 @@ lazy_static::lazy_static! {
(20260724094737, include_str!(
"../../migrations/20260724094737_runnables_sort_indexes.up.sql"
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY")),
(20260727093955, include_str!(
"../../migrations/20260727093955_runnable_owner_prefix_indexes.up.sql"
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY")),
].into_iter().collect();
}
+55 -17
View File
@@ -526,27 +526,17 @@ async fn cancel_job_api(
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
Json(CancelJob { reason }): Json<CancelJob>,
) -> error::Result<String> {
// App embed tokens (the sandboxed app iframe) may cancel ONLY jobs they launched
// — their app's component runs, stamped created_by == viewer. cancel_job_api has
// no other per-job ownership check, so without this an embed token (which carries
// the viewer's identity) could cancel any job by id. NotFound (not 403) so the
// untrusted app can't probe job existence.
// Cancelling needs the same per-job access as reading: own job, admin, or RLS-visible
// directly/through a flow ancestor — which also confines app embed tokens to the
// component runs they launched. No `view_token`: a share link grants read, never the
// right to kill someone else's run. Anonymous callers are instead confined to
// anonymous-created jobs by `cancel_job`'s `require_anonymous`.
if let Some(authed) = opt_authed.as_ref() {
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) {
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
id,
&w_id
)
.fetch_optional(&db)
.await?;
if created_by.as_deref() != Some(authed.username.as_str()) {
return Err(Error::NotFound(format!("Job {id} not found")));
}
}
require_job_update_read_access(&db, &user_db, authed, &w_id, &id, None).await?;
}
let tx = db.begin().await?;
@@ -656,13 +646,61 @@ async fn cancel_persistent_script_api(
Ok(())
}
/// Bounds the ancestor walk below so a cyclic `parent_job` chain cannot spin forever.
/// `cancel_job` itself is unbounded, so a chain longer than this would leave the two
/// disagreeing about which job gets killed — hence the fail-closed error.
const FORCE_CANCEL_MAX_ANCESTOR_DEPTH: i32 = 500;
/// The job a force-cancel of `id` actually kills: `cancel_job(force_cancel = true)` walks
/// up to the highest still-queued ancestor and cancels that one instead. Falls back to
/// `id` when it is not queued (the cancel is then a no-op anyway).
async fn force_cancel_target(db: &DB, w_id: &str, id: Uuid) -> error::Result<Uuid> {
let target = sqlx::query!(
r#"WITH RECURSIVE queued_ancestors AS (
SELECT j.id, j.parent_job, 0 AS depth
FROM v2_job j JOIN v2_job_queue q USING (id)
WHERE j.id = $1 AND j.workspace_id = $2
UNION ALL
SELECT j.id, j.parent_job, a.depth + 1
FROM queued_ancestors a
JOIN v2_job j ON j.id = a.parent_job AND j.workspace_id = $2
JOIN v2_job_queue q ON q.id = j.id
WHERE a.depth < $3
)
SELECT id AS "id!", depth AS "depth!" FROM queued_ancestors ORDER BY depth DESC LIMIT 1"#,
id,
w_id,
FORCE_CANCEL_MAX_ANCESTOR_DEPTH,
)
.fetch_optional(db)
.await?;
match target {
None => Ok(id),
// Truncated: we cannot prove which job the cancel would reach, so refuse rather
// than authorize an ancestor that may not be the one killed.
Some(r) if r.depth >= FORCE_CANCEL_MAX_ANCESTOR_DEPTH => Err(Error::internal_err(format!(
"flow nesting above job {id} is too deep to authorize a force cancel"
))),
Some(r) => Ok(r.id),
}
}
async fn force_cancel(
OptAuthed(opt_authed): OptAuthed,
tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
Json(CancelJob { reason }): Json<CancelJob>,
) -> error::Result<String> {
// Same per-job access as `cancel_job_api`, but on the job force-cancel actually kills.
// Read visibility is inherited *down* the flow chain, so gating on `id` would let a
// caller who can only see an inner step kill a root flow hidden from them.
if let Some(authed) = opt_authed.as_ref() {
let target = force_cancel_target(&db, &w_id, id).await?;
require_job_update_read_access(&db, &user_db, authed, &w_id, &target, None).await?;
}
let tx = db.begin().await?;
let audit_author: AuditAuthor = match opt_authed.as_ref() {
+280 -38
View File
@@ -30,6 +30,7 @@ use axum::{
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use windmill_common::{
db::UserDB,
error::{Error, JsonResult},
@@ -38,7 +39,9 @@ use windmill_types::scripts::ScriptHash;
use windmill_types::user_drafts::DraftUserRef;
pub fn workspaced_service() -> Router {
Router::new().route("/list", get(list_runnables))
Router::new()
.route("/list", get(list_runnables))
.route("/counts", get(count_runnables_by_owner))
}
#[derive(Deserialize)]
@@ -169,6 +172,46 @@ fn escape_like(s: &str) -> String {
.replace('_', "\\_")
}
/// The `domain:action` scope grant of a fine-grained token (e.g.
/// `scripts:read:f/foo/*`) as a predicate on `{alias}.path`, with its values
/// appended to `binds` (placeholders numbered from `base + 1`). `None` means the
/// token is unscoped for that domain and needs no predicate; an empty grant
/// yields `false` so the branch matches nothing. RLS doesn't honor token scopes,
/// so this has to be pushed into SQL.
fn scope_path_predicate(
authed: &ApiAuthed,
domain: &str,
alias: &str,
base: usize,
binds: &mut Vec<String>,
) -> Option<String> {
match build_scope_path_filter(authed, domain, "read") {
ScopePathFilter::AllowAll => None,
ScopePathFilter::Restricted { exact, prefix } => {
let mut terms: Vec<String> = vec![];
for e in exact {
binds.push(e);
terms.push(format!("{}.path = ${}", alias, base + binds.len()));
}
for pre in prefix {
binds.push(pre.clone());
let pe = format!("${}", base + binds.len());
binds.push(format!("{}/%", escape_like(&pre)));
let pl = format!("${}", base + binds.len());
terms.push(format!(
"({}.path = {} OR {}.path LIKE {})",
alias, pe, alias, pl
));
}
Some(if terms.is_empty() {
"false".to_string()
} else {
format!("({})", terms.join(" OR "))
})
}
}
}
/// The three UNION-ALL branch SELECTs, each projecting the shared `RunnableItem`
/// column set (NULL for columns that don't apply to that kind). `$1`=workspace,
/// `$2`=username (favorites), `$3`=email (drafts). Kind-specific and per-request
@@ -329,55 +372,21 @@ async fn list_runnables(
None => None,
};
// Fine-grained scoped tokens (e.g. `scripts:read:f/foo/*`) must be confined to
// their granted paths. RLS alone doesn't honor token scopes, so push the
// per-domain path grant into SQL (empty grant -> the branch matches nothing).
// Unscoped sessions -> AllowAll -> no predicate.
let scope_where = |filter: ScopePathFilter, binds: &mut Vec<String>| -> Option<String> {
match filter {
ScopePathFilter::AllowAll => None,
ScopePathFilter::Restricted { exact, prefix } => {
let mut terms: Vec<String> = vec![];
for e in exact {
binds.push(e);
terms.push(format!("o.path = ${}", 3 + binds.len()));
}
for pre in prefix {
binds.push(pre.clone());
let pe = format!("${}", 3 + binds.len());
binds.push(format!("{}/%", escape_like(&pre)));
let pl = format!("${}", 3 + binds.len());
terms.push(format!("(o.path = {} OR o.path LIKE {})", pe, pl));
}
Some(if terms.is_empty() {
"false".to_string()
} else {
format!("({})", terms.join(" OR "))
})
}
}
};
// Only push scope binds for kinds whose branch is actually included: a scoped token
// with e.g. `kinds=script` omits the flow/app branches, so binding their scope values
// (which no SQL references) would make the parameter count mismatch and 500.
let script_scope = if kinds.contains(&"script") {
scope_where(
build_scope_path_filter(&authed, "scripts", "read"),
&mut binds,
)
scope_path_predicate(&authed, "scripts", "o", 3, &mut binds)
} else {
None
};
let flow_scope = if kinds.contains(&"flow") {
scope_where(
build_scope_path_filter(&authed, "flows", "read"),
&mut binds,
)
scope_path_predicate(&authed, "flows", "o", 3, &mut binds)
} else {
None
};
let app_scope = if kinds.contains(&"app") {
scope_where(build_scope_path_filter(&authed, "apps", "read"), &mut binds)
scope_path_predicate(&authed, "apps", "o", 3, &mut binds)
} else {
None
};
@@ -535,3 +544,236 @@ async fn list_runnables(
Ok(Json(ListRunnablesResponse { items, next_cursor }))
}
#[derive(Deserialize)]
struct CountRunnablesQuery {
/// Comma-separated subset of `script,flow,app`; omitted means all.
kinds: Option<String>,
/// Include library scripts (no runnable main). Ignored for flows/apps.
include_without_main: Option<bool>,
}
#[derive(Serialize)]
struct RunnableCountsResponse {
/// Owner prefix (`f/<folder>` or `u/<user>`) -> number of visible runnables,
/// counting what the tree lists as a row (see the pipeline note below).
/// Owners with none are omitted so the tree can hide them.
counts: HashMap<String, i64>,
}
#[derive(sqlx::FromRow)]
struct OwnerCount {
owner: String,
count: i64,
}
/// A byte-ordered range covering exactly the paths under `o.owner`: any
/// `owner/<rest>` is >= `owner || '/'` and, since '/' (0x2F) is immediately
/// followed by '0' (0x30), < `owner || '0'`. `~>=~` / `~<~` are the
/// text_pattern_ops operators, which is what lets `idx_<kind>_owner_prefix`
/// answer the count with an index-only scan — the default opclass sorts by the
/// database collation and cannot serve a byte-prefix range.
fn owner_prefix_range(alias: &str) -> String {
format!("{alias}.path ~>=~ (o.owner || '/') AND {alias}.path ~<~ (o.owner || '0')")
}
/// Per-owner runnable counts for the homepage tree, so every folder / user node
/// can show its size and the empty ones can be dropped without loading them.
///
/// Runs off the non-RLS pool and re-derives visibility from `path` alone: an
/// owner is readable whole or not at all (admin, folder in the caller's read
/// set, or the caller's own user space). That is what makes the count an
/// index-only prefix scan — RLS instead applies `split_part`/`current_setting`
/// predicates per row, which no index serves. The one case `path` cannot express
/// is an item shared individually out of an otherwise unreadable owner; those
/// owners are recovered by a second `extra_perms` pass over the GIN indexes.
///
/// The share pass's GIN indexes are on `extra_perms` alone, so on a multi-tenant
/// instance its bitmap matches grantee keys (`g/all` exists in every workspace)
/// across workspaces and `workspace_id` is only a recheck. Scoping the index
/// would need `btree_gin`, a contrib extension self-hosted installs can't be
/// assumed to have; the RLS policies already scan these indexes the same way.
async fn count_runnables_by_owner(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(q): Query<CountRunnablesQuery>,
) -> JsonResult<RunnableCountsResponse> {
let kinds: Vec<&str> = match q.kinds.as_deref() {
None | Some("") => vec!["script", "flow", "app"],
// Deduplicated: every kind becomes its own count subquery, so a repeated
// entry would both double that kind's count and multiply the scans.
Some(csv) => {
let mut ks: Vec<&str> = vec![];
for k in csv.split(',').map(|s| s.trim()) {
if ["script", "flow", "app"].contains(&k) && !ks.contains(&k) {
ks.push(k);
}
}
ks
}
};
if kinds.is_empty() {
return Ok(Json(RunnableCountsResponse { counts: HashMap::new() }));
}
let with_libs = q.include_without_main.unwrap_or(false) && !authed.is_operator;
// Per-kind predicates shared by both passes. `alias` is the branch's table
// alias; `binds` collects the scope values, numbered from `base + 1`.
let kind_filters =
|kind: &str, alias: &str, base: usize, binds: &mut Vec<String>| -> Vec<String> {
let mut w = vec![format!("{alias}.workspace_id = $1")];
match kind {
"script" => {
w.push(format!("{alias}.archived = false"));
// `pipeline` members are always excluded: the tree folds them into
// their folder's single "Pipeline" entry and never lists them as
// rows, so counting them would promise items that never appear.
// `lib` follows the caller's include_without_main, as in the listing.
let mut hidden = vec!["'pipeline'"];
if !with_libs {
hidden.push("'lib'");
}
w.push(format!(
"({alias}.auto_kind IS NULL OR {alias}.auto_kind NOT IN ({}))",
hidden.join(", ")
));
if let Some(s) = scope_path_predicate(&authed, "scripts", alias, base, binds) {
w.push(s);
}
}
"flow" => {
w.push(format!("{alias}.archived = false"));
if let Some(s) = scope_path_predicate(&authed, "flows", alias, base, binds) {
w.push(s);
}
}
_ => {
if let Some(s) = scope_path_predicate(&authed, "apps", alias, base, binds) {
w.push(s);
}
}
}
w
};
let table_of = |kind: &str| match kind {
"script" => "script",
"flow" => "flow",
_ => "app",
};
// `SELECT owner, count(*) ... GROUP BY owner` over a set of per-kind path
// selects — the shape both the admin sweep and the share pass end in.
let grouped_by_owner = |branches: Vec<String>| -> String {
format!(
"SELECT split_part(p.path, '/', 1) || '/' || split_part(p.path, '/', 2) AS owner, \
count(*)::bigint AS count \
FROM ({}) p GROUP BY 1",
branches.join(" UNION ALL ")
)
};
let mut counts: HashMap<String, i64> = HashMap::new();
if authed.is_admin {
// Admins read the whole workspace, so one grouped scan per kind is the
// cheapest shape. Enumerating owners and prefix-scanning each instead
// would cost one scan per folder AND per member, growing with headcount
// rather than with content.
// $1 = workspace.
let mut binds: Vec<String> = vec![];
let branches: Vec<String> = kinds
.iter()
.map(|kind| {
let alias = "t";
let w = kind_filters(kind, alias, 1, &mut binds);
format!(
"SELECT {alias}.path FROM {} {alias} WHERE {}",
table_of(kind),
w.join(" AND ")
)
})
.collect();
let sql = grouped_by_owner(branches);
let mut query = sqlx::query_as::<_, OwnerCount>(&sql).bind(&w_id);
for b in &binds {
query = query.bind(b);
}
for r in query.fetch_all(&db).await? {
counts.insert(r.owner, r.count);
}
counts.retain(|_, c| *c > 0);
return Ok(Json(RunnableCountsResponse { counts }));
}
// Pass 1 — prefix counts for the owners the caller reads wholesale: their
// folders and their own user space, a set bounded by the grants they hold.
// $1 = workspace, $2 = readable folder names, $3 = username.
let mut binds: Vec<String> = vec![];
let terms: Vec<String> = kinds
.iter()
.map(|kind| {
let alias = "t";
let mut w = kind_filters(kind, alias, 3, &mut binds);
w.push(owner_prefix_range(alias));
format!(
"(SELECT count(*) FROM {} {alias} WHERE {})",
table_of(kind),
w.join(" AND ")
)
})
.collect();
let sql = format!(
"WITH owners(owner) AS ( \
SELECT 'f/' || name FROM folder WHERE workspace_id = $1 AND name = ANY($2) \
UNION SELECT 'u/' || $3 \
) \
SELECT o.owner AS owner, ({})::bigint AS count FROM owners o",
terms.join(" + ")
);
let readable_folders: Vec<String> = authed.folders.iter().map(|f| f.0.clone()).collect();
let mut query = sqlx::query_as::<_, OwnerCount>(&sql)
.bind(&w_id)
.bind(&readable_folders)
.bind(&authed.username);
for b in &binds {
query = query.bind(b);
}
for r in query.fetch_all(&db).await? {
counts.insert(r.owner, r.count);
}
// Pass 2 — owners the caller only reaches through an individual share.
// $1 = workspace, $2 = the caller's grantee keys.
let mut grantees = vec![format!("u/{}", authed.username)];
grantees.extend(authed.groups.iter().map(|g| format!("g/{}", g)));
let mut binds: Vec<String> = vec![];
let branches: Vec<String> = kinds
.iter()
.map(|kind| {
let alias = "t";
let mut w = kind_filters(kind, alias, 2, &mut binds);
w.push(format!("{alias}.extra_perms ?| $2"));
format!(
"SELECT {alias}.path FROM {} {alias} WHERE {}",
table_of(kind),
w.join(" AND ")
)
})
.collect();
let sql = grouped_by_owner(branches);
let mut query = sqlx::query_as::<_, OwnerCount>(&sql)
.bind(&w_id)
.bind(&grantees);
for b in &binds {
query = query.bind(b);
}
for r in query.fetch_all(&db).await? {
// Owners already in `counts` were counted whole in pass 1, shares
// included — only the ones missing there are added here.
counts.entry(r.owner).or_insert(r.count);
}
counts.retain(|_, c| *c > 0);
Ok(Json(RunnableCountsResponse { counts }))
}
+4
View File
@@ -130,6 +130,10 @@ opentelemetry-appender-tracing = { workspace = true, optional = true }
tonic = { workspace = true, optional = true }
equivalent = "1.0.2"
[dev-dependencies]
# `test-util` is not part of tokio's `full`; it is what lets tests pause the clock.
tokio = { workspace = true, features = ["test-util"] }
[target.'cfg(not(target_env = "msvc"))'.dependencies]
tikv-jemalloc-ctl = { optional = true, workspace = true }
+45 -5
View File
@@ -35,6 +35,7 @@ pub mod auth;
pub mod bench;
pub mod cache;
pub mod client;
pub mod data_metrics;
pub mod db;
#[cfg(all(feature = "enterprise", feature = "private"))]
mod db_entra_ee;
@@ -62,7 +63,6 @@ pub mod instance_config;
pub mod job_metrics;
pub mod log_context;
pub mod materialization;
pub mod data_metrics;
pub mod min_version;
pub mod notify_events;
pub mod runtime_assets;
@@ -1068,6 +1068,49 @@ impl PgDatabase {
}
}
/// How long a `tokio_postgres` connection task gets to wind down once its `Client` is dropped.
const PG_CONNECTION_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
/// Wind down the task driving a `tokio_postgres` connection after its `Client` has been dropped,
/// surfacing whatever error the connection ended with. A teardown that has to be aborted is
/// reported as success — the work the client did is already done and complete.
///
/// The task only finishes once the exchange the client left behind (its Terminate, and any
/// still-unanswered request) has been settled by the peer. A connection proxy that stops
/// replying leaves that pending forever, so waiting on the task without a deadline pins the
/// caller and the socket for the lifetime of the process. Aborting past the grace period drops
/// the stream, which is the only cleanup the task owes.
pub async fn shutdown_pg_connection(
join_handle: tokio::task::JoinHandle<Result<(), tokio_postgres::Error>>,
) -> error::Result<()> {
let abort_handle = join_handle.abort_handle();
match tokio::time::timeout(PG_CONNECTION_SHUTDOWN_GRACE, join_handle).await {
Ok(Ok(Ok(()))) => Ok(()),
Ok(Ok(Err(e))) => Err(error::Error::internal_err(format!(
"tokio_postgres error: {}",
e
))),
Ok(Err(e)) => Err(error::Error::internal_err(format!("join error: {}", e))),
Err(_) => {
tracing::warn!(
"Postgres connection did not close within {}s of its client being dropped, aborting it",
PG_CONNECTION_SHUTDOWN_GRACE.as_secs()
);
abort_handle.abort();
Ok(())
}
}
}
#[cfg(test)]
mod pg_connection_shutdown_tests {
#[tokio::test(start_paused = true)]
async fn gives_up_on_a_connection_task_that_never_finishes() {
let never_finishes = tokio::spawn(std::future::pending());
assert!(super::shutdown_pg_connection(never_finishes).await.is_ok());
}
}
/// Validate a database name to prevent SQL injection.
/// Must start with a letter, contain only alphanumeric characters, underscores, or hyphens, and be <= 63 chars.
pub fn validate_dbname(dbname: &str) -> error::Result<()> {
@@ -1219,10 +1262,7 @@ pub async fn create_custom_instance_database(
}
drop(client);
join_handle
.await
.map_err(|e| error::Error::internal_err(format!("join error: {}", e)))?
.map_err(|e| error::Error::internal_err(format!("tokio_postgres error: {}", e)))?;
shutdown_pg_connection(join_handle).await?;
// Register in global_settings
let status_json = serde_json::json!({
+48 -19
View File
@@ -4830,8 +4830,37 @@ fn pg_action_to_string(action: &str) -> String {
}
}
/// Rows of a simple-protocol result, dropping the framing messages.
fn simple_query_rows(
messages: Vec<tokio_postgres::SimpleQueryMessage>,
) -> Vec<tokio_postgres::SimpleQueryRow> {
messages
.into_iter()
.filter_map(|m| match m {
tokio_postgres::SimpleQueryMessage::Row(row) => Some(row),
_ => None,
})
.collect()
}
fn required_str<'a>(
row: &'a tokio_postgres::SimpleQueryRow,
column: &str,
) -> Result<&'a str, String> {
row.try_get(column)
.map_err(|e| format!("Failed to read column {}: {}", column, e))?
.ok_or_else(|| format!("Unexpected NULL in column {}", column))
}
/// Introspect a PostgreSQL database and return the full schema.
/// Takes a connected tokio_postgres Client.
///
/// Both statements go through the simple query protocol. The extended protocol allocates a
/// named prepared statement per call and closes it when the statement handle drops; behind a
/// transaction-pooling proxy those names are shared with, and outlive, other sessions on the
/// same backend, and the exchange then stalls with no reply — the connection never becomes
/// idle again and the request hangs. Neither statement takes parameters, so nothing here
/// needs the extended protocol.
pub async fn pg_get_full_schema(
client: &tokio_postgres::Client,
) -> Result<FullDatabaseSchema, String> {
@@ -4840,7 +4869,7 @@ pub async fn pg_get_full_schema(
// per-column correlated subqueries — on large catalogs those subqueries run
// once per column and make the introspection time out.
let column_rows = client
.query(
.simple_query(
"SELECT
ns.nspname AS schema_name,
c.relname AS table_name,
@@ -4862,13 +4891,13 @@ pub async fn pg_get_full_schema(
AND NOT a.attisdropped
AND ns.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY ns.nspname, c.relname, a.attnum",
&[],
)
.await
.map(simple_query_rows)
.map_err(|e| format!("Failed to query columns: {}", e))?;
let fk_rows = client
.query(
.simple_query(
"SELECT
ns.nspname AS schema_name,
c.relname AS table_name,
@@ -4890,21 +4919,21 @@ pub async fn pg_get_full_schema(
WHERE con.contype = 'f'
AND ns.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY ns.nspname, c.relname, con.conname, u.ord",
&[],
)
.await
.map(simple_query_rows)
.map_err(|e| format!("Failed to query foreign keys: {}", e))?;
let mut result: FullDatabaseSchema = std::collections::HashMap::new();
for row in &column_rows {
let schema_name: &str = row.get("schema_name");
let table_name: &str = row.get("table_name");
let column_name: &str = row.get("column_name");
let datatype: &str = row.get("datatype");
let schema_name = required_str(row, "schema_name")?;
let table_name = required_str(row, "table_name")?;
let column_name = required_str(row, "column_name")?;
let datatype = required_str(row, "datatype")?;
let default_value: Option<&str> = row.get("default_value");
let nullable: bool = row.get("nullable");
let is_primary_key: bool = row.get("is_primary_key");
let nullable = required_str(row, "nullable")? == "t";
let is_primary_key = required_str(row, "is_primary_key")? == "t";
let pk_constraint_name: Option<&str> = row.get("pk_constraint_name");
let schema_tables = result.entry(schema_name.to_string()).or_default();
@@ -4937,15 +4966,15 @@ pub async fn pg_get_full_schema(
> = std::collections::HashMap::new();
for row in &fk_rows {
let schema_name: &str = row.get("schema_name");
let table_name: &str = row.get("table_name");
let fk_name: &str = row.get("fk_constraint_name");
let source_column: &str = row.get("source_column");
let ref_schema: &str = row.get("ref_schema");
let ref_table: &str = row.get("ref_table");
let ref_column: &str = row.get("ref_column");
let on_delete: &str = row.get("on_delete");
let on_update: &str = row.get("on_update");
let schema_name = required_str(row, "schema_name")?;
let table_name = required_str(row, "table_name")?;
let fk_name = required_str(row, "fk_constraint_name")?;
let source_column = required_str(row, "source_column")?;
let ref_schema = required_str(row, "ref_schema")?;
let ref_table = required_str(row, "ref_table")?;
let ref_column = required_str(row, "ref_column")?;
let on_delete = required_str(row, "on_delete")?;
let on_update = required_str(row, "on_update")?;
let target_table = if ref_schema == schema_name {
ref_table.to_string()
+30
View File
@@ -6,6 +6,15 @@
* LICENSE-AGPL for a copy of the license.
*/
lazy_static::lazy_static! {
/// Matches the non-quoted, non-IP-literal subset of the `proper_email` CHECK constraint
/// carried by `usr` and `workspace_invite`, so anything accepted here is accepted by those
/// tables too.
pub static ref VALID_EMAIL: regex::Regex = regex::Regex::new(
r"^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*@([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$"
).unwrap();
}
pub const SUPERADMIN_SECRET_EMAIL: &str = "superadmin_secret@windmill.dev";
pub const SUPERADMIN_NOTIFICATION_EMAIL: &str = "superadmin_notification@windmill.dev";
pub const SUPERADMIN_SYNC_EMAIL: &str = "superadmin_sync@windmill.dev";
@@ -167,6 +176,27 @@ mod tests {
assert_eq!(username_to_permissioned_as("group-my-team"), "g/my-team");
}
#[test]
fn test_valid_email() {
for email in [
"alice@example.com",
"alice.bob+tag@sub.example.co.uk",
"a_b-c!#$%&'*+/=?^_`{|}~@example.com",
] {
assert!(VALID_EMAIL.is_match(email), "{email} should be valid");
}
for email in [
"alice",
"alice@example",
"alice@@example.com",
"alice @example.com",
"alice@example.com\nbob@example.com",
"",
] {
assert!(!VALID_EMAIL.is_match(email), "{email} should be invalid");
}
}
#[test]
fn test_compute_highest_workspace_role_admin_wins() {
let user_groups = vec!["ops".to_string(), "admins".to_string()];
+2 -2
View File
@@ -1866,7 +1866,7 @@ async fn inspect_fork_catalog(
);
let table_rows = client.query(&qt, &[]).await;
drop(client);
let _ = join_handle.await;
let _ = crate::shutdown_pg_connection(join_handle).await;
existing_schemas.extend(
same_catalog_res
@@ -1920,7 +1920,7 @@ async fn inspect_fork_catalog(
let join_handle = tokio::spawn(async move { connection.await });
let res = query_schemas(&client, schemas).await;
drop(client);
let _ = join_handle.await;
let _ = crate::shutdown_pg_connection(join_handle).await;
res.map_err(|e| Error::internal_err(format!("{e}")))
}
.await;
+16 -7
View File
@@ -24,9 +24,10 @@ use windmill_ai::{
ai_providers::AIProvider,
image_handler::upload_image_to_s3,
providers::create_query_builder,
proxy::{common_outbound_headers, resource_replaces_credential, CREDENTIAL_HEADERS},
query_builder::{BuildRequestArgs, ParsedResponse},
types::*,
utils::{pinned_ai_client_for, should_use_structured_output_tool, AI_HTTP_HEADERS},
utils::{pinned_ai_client_for, should_use_structured_output_tool},
};
use windmill_common::{
cache,
@@ -977,12 +978,24 @@ pub async fn run_agent(
let endpoint =
query_builder.get_endpoint(base_url, args.provider.get_model(), output_type);
let auth_headers = query_builder.get_auth_headers(api_key, base_url, output_type);
// A resource carrying its own credential owns authentication; the
// built-in one is dropped so the two do not both reach the endpoint.
// Non-credential headers (`anthropic-version`) stay either way.
let auth_headers: Vec<_> = auth_headers
.into_iter()
.filter(|(header_name, _)| {
let carries_credential = CREDENTIAL_HEADERS
.iter()
.any(|name| header_name.eq_ignore_ascii_case(name));
!carries_credential || !resource_replaces_credential(&credentials, header_name)
})
.collect();
let timeout = resolve_job_timeout(conn, &job.workspace_id, job.id, job.timeout)
.await
.0;
let resource_headers = &credentials.custom_headers;
let trailing_headers = common_outbound_headers(&credentials).collect::<Vec<_>>();
// `endpoint` derives from the user-controlled provider base_url, so pin
// DNS to the SSRF-validated address: the connect must not rebind to an
@@ -1000,11 +1013,7 @@ pub async fn run_agent(
req = req.header(*header_name, header_value.clone());
}
for (header_name, header_value) in AI_HTTP_HEADERS.iter() {
req = req.header(header_name.as_str(), header_value.as_str());
}
for (header_name, header_value) in resource_headers {
for (header_name, header_value) in &trailing_headers {
req = req.header(header_name.as_str(), header_value.as_str());
}
@@ -1799,6 +1799,10 @@ pub async fn handle_bun_job(
format!("argsObjToArr(args)")
};
// Kept comment-free — this string is written out per job.
// `_takePendingSuspend` returns a StepSuspend the body caught and swallowed
// (it is an `Error`), so honour it instead of reporting a `complete` whose
// step never reached the checkpoint. Optional: npm clients may predate it.
let wrapper_content = if is_wac_v2 {
format!(
r#"
@@ -1843,6 +1847,10 @@ async function run() {{
try {{
const result = await workflowFn(...argsArr);
setWorkflowCtx(null);
const swallowed = ctx._takePendingSuspend?.();
if (swallowed) {{
throw swallowed;
}}
// Flush any unawaited tasks (e.g. forgotten await on last statement)
const trailing = ctx._flushPending();
if (trailing.length > 0) {{
+49 -20
View File
@@ -262,6 +262,20 @@ export async function findResourceFile(path: string) {
return validCandidates[0];
}
// The separator is whatever the local filesystem uses, so both are accepted:
// on Windows these paths reach us as `my_script__mod\script.yaml`.
const MODULE_ENTRY_META_RE = /([\\/])script\.(yaml|json|lock)$/;
/**
* Whether a path is a module folder's own metadata file (`__mod/script.yaml`).
* `isModuleEntryPoint` already pins the file to `script.*` directly under
* `__mod/`, so this only narrows it to the metadata extensions: a `script.yaml`
* nested deeper in the module tree is a module file, not the script's metadata.
*/
function isModuleEntryMetadata(p: string): boolean {
return isModuleEntryPoint(p) && MODULE_ENTRY_META_RE.test(p);
}
export async function handleScriptMetadata(
path: string,
workspace: Workspace,
@@ -276,12 +290,7 @@ export async function handleScriptMetadata(
const isFlatMeta = path.endsWith(".script.json") ||
path.endsWith(".script.yaml") ||
path.endsWith(".script.lock");
// Folder layout: my_script__mod/script.yaml
const isFolderMeta = !isFlatMeta && isScriptModulePath(path) && (
path.endsWith("/script.yaml") ||
path.endsWith("/script.json") ||
path.endsWith("/script.lock")
);
const isFolderMeta = !isFlatMeta && isModuleEntryMetadata(path);
if (isFlatMeta || isFolderMeta) {
const contentPath = await findContentFile(path);
return handleFile(
@@ -872,17 +881,34 @@ async function createScript(
return performance.now() - start;
}
/**
* A script metadata file could not be paired with exactly one script file on
* disk, so nothing can be deployed for it. Distinct from a deploy that reached
* the remote and was rejected: callers that can carry on with the rest of a
* changeset catch this specifically.
*/
export class UnresolvableScriptContentFileError extends Error {}
export async function findContentFile(filePath: string) {
// Folder layout: __mod/script.yaml -> __mod/script.ts
const isModuleFolderMeta =
filePath.endsWith("/script.yaml") || filePath.endsWith("/script.json") || filePath.endsWith("/script.lock");
const candidates = isModuleFolderMeta
? exts.map((x) => filePath.replace(/\/script\.(yaml|json|lock)$/, "/script" + x))
: filePath.endsWith("script.json")
? exts.map((x) => filePath.replace(".script.json", x))
: filePath.endsWith("script.lock")
? exts.map((x) => filePath.replace(".script.lock", x))
: exts.map((x) => filePath.replace(".script.yaml", x));
const isModuleFolderMeta = isModuleEntryMetadata(filePath);
const toCandidate = (ext: string) =>
isModuleFolderMeta
? filePath.replace(MODULE_ENTRY_META_RE, "$1script" + ext)
: filePath.endsWith("script.json")
? filePath.replace(".script.json", ext)
: filePath.endsWith("script.lock")
? filePath.replace(".script.lock", ext)
: filePath.replace(".script.yaml", ext);
// Every branch above is a no-op on a path that is neither flat nor
// module-entry metadata, which would make toCandidate the identity function
// and "resolve" the input to itself.
if (!isModuleFolderMeta && !/\.script\.(yaml|json|lock)$/.test(filePath)) {
throw new UnresolvableScriptContentFileError(
`${filePath} is not a script metadata file — no script file can be resolved from it.`
);
}
const candidates = exts.map(toCandidate);
const validCandidates = (
await Promise.all(
@@ -899,14 +925,17 @@ export async function findContentFile(filePath: string) {
.filter((x) => x.file)
.map((x) => x.path);
if (validCandidates.length > 1) {
throw new Error(
"No content path given and more than one candidate found: " +
validCandidates.join(", ")
throw new UnresolvableScriptContentFileError(
`Multiple script files found next to ${filePath}: ${validCandidates.join(", ")}` +
`cannot tell which one the metadata belongs to. Keep exactly one.`
);
}
if (validCandidates.length < 1) {
throw new Error(
`No content path given and no content file found for ${filePath}.`
throw new UnresolvableScriptContentFileError(
`No script file found next to ${filePath} — a script cannot be deployed from its metadata alone. ` +
`Add the matching script file (e.g. ${toCandidate(".ts")} or ${toCandidate(
".py"
)}) or remove ${filePath}.`
);
}
return validCandidates[0];
+62 -2
View File
@@ -40,6 +40,7 @@ import {
findContentFile,
findResourceFile,
handleScriptMetadata,
UnresolvableScriptContentFileError,
removeExtensionToPath,
filePathExtensionFromContentType,
} from "../script/script.ts";
@@ -4407,6 +4408,10 @@ export async function push(
log.info(`Parallelizing ${parallelizationFactor} changes at a time`);
}
// Changes that could not be applied but do not invalidate the rest of the
// push. Reported at the end, and the push exits non-zero for them.
const failedChanges: { path: string; error: string }[] = [];
// Create a pool of workers that processes items as they become available
const pool = new Set();
// Process folder.meta groups first (sequentially), then items in parallel.
@@ -4650,6 +4655,42 @@ export async function push(
);
continue;
}
// A script deploys through its content file, which is normally in
// the same group — but not always (excludes can filter it out).
// Resolving it from disk keeps the deploy idempotent (via
// alreadySynced) and stops an unaccompanied metadata file from
// being counted as a change that reached the remote.
if (!isRawAppFile(change.path)) {
let handled = false;
try {
handled = await handleScriptMetadata(
change.path,
workspace,
alreadySynced,
opts.message,
rawWorkspaceDependencies,
codebases,
opts,
permissionedAsContext,
);
} catch (e) {
if (!(e instanceof UnresolvableScriptContentFileError)) {
throw e;
}
// Nothing deployable here, but the rest of the changeset is
// unaffected — record it so the push reports a failure at the
// end instead of aborting midway with a partial deploy.
failedChanges.push({
path: change.path,
error: e.message,
});
log.error(e.message);
continue;
}
if (handled) {
continue;
}
}
if (
!isRawAppFile(change.path) &&
(change.path.endsWith(".script.json") ||
@@ -5165,11 +5206,16 @@ export async function push(
);
}
}
const pushedCount = changes.length - failedChanges.length;
if (opts.jsonOutput) {
const result = {
success: true,
success: failedChanges.length === 0,
lock_jobs: lockJobs,
message: `All ${changes.length} changes pushed to the remote workspace ${workspace.workspaceId} named ${workspace.name}`,
...(failedChanges.length > 0 ? { failed: failedChanges } : {}),
message:
failedChanges.length > 0
? `${pushedCount} of ${changes.length} changes pushed to the remote workspace ${workspace.workspaceId} named ${workspace.name}; ${failedChanges.length} failed`
: `All ${changes.length} changes pushed to the remote workspace ${workspace.workspaceId} named ${workspace.name}`,
changes: changes.map((change) => ({
type: change.name,
path: change.path,
@@ -5191,6 +5237,15 @@ export async function push(
duration_ms: Math.round(performance.now() - start),
};
console.log(JSON.stringify(result, null, 2));
} else if (failedChanges.length > 0) {
log.error(
colors.bold.red.underline(
`\n${pushedCount} of ${changes.length} changes pushed to the remote workspace ${
workspace.workspaceId
} named ${workspace.name}; ${failedChanges.length} failed:\n` +
failedChanges.map((f) => ` - ${f.path}`).join("\n"),
),
);
} else {
log.info(
colors.bold.green.underline(
@@ -5204,6 +5259,11 @@ export async function push(
),
);
}
if (failedChanges.length > 0) {
// Not process.exit: under Node a piped stdout write is async, so exiting
// here would truncate the JSON result mid-object for CI consumers.
process.exitCode = 1;
}
} else {
// Dry-run with no changes reaches here (a ui/ diff would have made changes
// non-empty and returned above); never mutate the remote in that case.
File diff suppressed because one or more lines are too long
+80
View File
@@ -17,6 +17,8 @@ import {
import {
writeModulesToDisk,
readModulesFromDisk,
findContentFile,
UnresolvableScriptContentFileError,
} from "../src/commands/script/script.ts";
import { getTypeStrFromPath } from "../src/types.ts";
@@ -321,3 +323,81 @@ describe("module lock file handling", () => {
// This is intentional — cleanup happens at the sync level
});
});
// =============================================================================
// findContentFile
// =============================================================================
describe("findContentFile", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "wm-find-content-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("resolves the entry point of a module folder", async () => {
const modDir = path.join(tempDir, "my_script__mod");
fs.mkdirSync(modDir, { recursive: true });
fs.writeFileSync(path.join(modDir, "script.ts"), "export async function main() {}\n");
fs.writeFileSync(path.join(modDir, "script.yaml"), "summary: ''\n");
expect(await findContentFile(path.join(modDir, "script.yaml"))).toBe(
path.join(modDir, "script.ts")
);
});
// `isModuleEntryPoint` treats `\` as a separator on every platform, so
// findContentFile must too or it rejects every Windows module path. Runnable
// on POSIX because there the backslash is merely part of the file name.
test("resolves a module entry point written with a backslash separator", async () => {
const meta = path.join(tempDir, "my_script__mod") + "\\script.yaml";
const content = path.join(tempDir, "my_script__mod") + "\\script.ts";
fs.mkdirSync(path.dirname(meta), { recursive: true });
fs.writeFileSync(content, "export async function main() {}\n");
fs.writeFileSync(meta, "summary: ''\n");
expect(await findContentFile(meta)).toBe(content);
});
// sync push catches UnresolvableScriptContentFileError to record the change as
// failed and carry on; any other error aborts the whole push, so every branch
// that simply cannot pair a metadata file with one script file must use it.
test("signals every unpairable case with UnresolvableScriptContentFileError", async () => {
const dir = path.join(tempDir, "f", "test");
fs.mkdirSync(dir, { recursive: true });
// no script file at all
const orphan = path.join(dir, "orphan.script.yaml");
fs.writeFileSync(orphan, "summary: ''\n");
await expect(findContentFile(orphan)).rejects.toBeInstanceOf(
UnresolvableScriptContentFileError
);
// two script files, so the metadata cannot name one
const ambiguous = path.join(dir, "both.script.yaml");
fs.writeFileSync(ambiguous, "summary: ''\n");
fs.writeFileSync(path.join(dir, "both.ts"), "export async function main() {}\n");
fs.writeFileSync(path.join(dir, "both.py"), "def main():\n pass\n");
await expect(findContentFile(ambiguous)).rejects.toBeInstanceOf(
UnresolvableScriptContentFileError
);
});
// A script.yaml nested deeper in the module tree is a module file, not the
// script's metadata — resolving it to itself would make the caller deploy it
// as if it were a script.
test("rejects a script.yaml nested below the module entry point", async () => {
const nested = path.join(tempDir, "my_script__mod", "config");
fs.mkdirSync(nested, { recursive: true });
const nestedMeta = path.join(nested, "script.yaml");
fs.writeFileSync(nestedMeta, "nested: data\n");
await expect(findContentFile(nestedMeta)).rejects.toThrow(
"is not a script metadata file"
);
});
});
@@ -0,0 +1,90 @@
/**
* A script change that `sync push` counts must reach the remote. Its content
* file is not guaranteed to be in the changeset here `excludes` keeps it out
* of the sync, leaving the `.script.yaml` as the only change so asserting the
* deployed content, not just the exit code, is what pins the guarantee.
*/
import { expect, test } from "bun:test";
import { writeFile } from "node:fs/promises";
import { withTestBackend } from "./test_backend.ts";
import { createLocalScript } from "./test_fixtures.ts";
test(
"sync push deploys a script whose metadata is the only changed file",
{ timeout: 120000 },
async () => {
await withTestBackend(async (backend, tempDir) => {
await writeFile(
`${tempDir}/wmill.yaml`,
`defaultTs: bun\nincludes: ["f/**"]\nexcludes: ["**/*.py"]\n`,
);
await createLocalScript(
tempDir,
"f/test",
"metadata_only",
"python3",
'def main():\n return "deployed"\n',
);
const result = await backend.runCLICommand(
["sync", "push", "--yes"],
tempDir,
);
expect(result.code).toBe(0);
const res = await backend.apiRequest!(
`/api/w/${backend.workspace}/scripts/get/p/f/test/metadata_only`,
);
expect(res.status).toBe(200);
expect((await res.json()).content).toContain("deployed");
});
},
);
// A metadata file with no script file anywhere is unpushable, but the rest of
// the changeset is unaffected: the push must neither abort partway nor claim
// success. `orphan` sorts between `alpha` and `zeta`, so an abort would leave
// `zeta` undeployed.
test(
"sync push reports failure for an unpushable script without dropping the rest",
{ timeout: 120000 },
async () => {
await withTestBackend(async (backend, tempDir) => {
await writeFile(
`${tempDir}/wmill.yaml`,
`defaultTs: bun\nincludes: ["f/**"]\nexcludes: []\n`,
);
for (const name of ["alpha", "zeta"]) {
await createLocalScript(
tempDir,
"f/test",
name,
"python3",
`def main():\n return "${name}"\n`,
);
}
await writeFile(
`${tempDir}/f/test/orphan.script.yaml`,
`summary: orphan\ndescription: ''\nlock: ''\nkind: script\nschema: {}\n`,
);
const result = await backend.runCLICommand(
["sync", "push", "--yes"],
tempDir,
);
expect(result.code).toBe(1);
// The push echoes local paths with the platform separator (`\` on Windows).
expect((result.stdout + result.stderr).replaceAll("\\", "/")).toContain(
"f/test/orphan.script.yaml",
);
for (const name of ["alpha", "zeta"]) {
const res = await backend.apiRequest!(
`/api/w/${backend.workspace}/scripts/get/p/f/test/${name}`,
);
expect(res.status).toBe(200);
}
});
},
);
@@ -0,0 +1,101 @@
<script lang="ts">
import { UserService } from '$lib/gen'
import { Button } from './common'
import { sendUserToast } from '$lib/toast'
import Alert from './common/alert/Alert.svelte'
import TextInput from './text_input/TextInput.svelte'
import { createEventDispatcher } from 'svelte'
import { userStore } from '$lib/stores'
interface Props {
email: string
username?: string | undefined
noPadding?: boolean
}
let { email, username = undefined, noPadding = false }: Props = $props()
let editedEmail: string | undefined = $state(undefined)
// Lowercased like the backend normalizes it, so a capitalization-only edit is not offered as a
// change the backend would then reject.
let newEmail = $derived((editedEmail ?? email).trim().toLowerCase())
let isSelf = $derived(email === $userStore?.email)
let changed = $derived(!!newEmail && newEmail !== email.toLowerCase())
let loading = $state(false)
const dispatch = createEventDispatcher()
async function changeEmail() {
if (!changed || loading) {
return
}
loading = true
try {
await UserService.globalUserChangeEmail({ email, requestBody: { new_email: newEmail } })
sendUserToast(`Changed email of ${email} to ${newEmail}`)
dispatch('changed')
} catch (err) {
sendUserToast(err.body ?? err.message ?? 'Failed to change email', true)
} finally {
loading = false
}
}
</script>
<div class="flex flex-col max-w-2xl {noPadding ? '' : 'p-4'}">
<span class="text-xs font-semibold text-emphasis mb-1 leading-6">Email</span>
<TextInput
inputProps={{
type: 'email',
disabled: isSelf,
onclick: (e) => {
e.stopPropagation()
},
onkeydown: (e) => {
e.stopPropagation()
},
onkeypress: (e) => {
e.stopPropagation()
if (e.key === 'Enter') {
changeEmail()
}
}
}}
bind:value={() => editedEmail ?? email, (v) => (editedEmail = String(v))}
/>
{#if isSelf}
<div class="text-2xs text-tertiary mt-1">
You cannot change your own email — ask another superadmin.
</div>
{/if}
{#if changed && !isSelf}
<Alert type="warning" title="Last resort operation" class="mt-2 mb-2" size="xs">
Changing the email of an existing account is a last resort. Prefer it only when the address
itself has to change and the account must be kept.
<br />
<br />
The account keeps its instance-wide username{username ? ` (${username})` : ''}, role,
workspace memberships, drafts and tokens. But past runs and audit logs keep the previous
email, pending password reset links stop working, and the account inherits any instance group
membership or workspace invite already addressed to the new email — including one that grants
a role.
<br />
<br />
If this user signs in through SSO or is managed by SCIM, update the identity provider first: otherwise
their next login recreates the previous email as a separate, empty account.
</Alert>
{/if}
<Button
variant="default"
unifiedSize="md"
btnClasses="mt-2"
disabled={!changed || isSelf}
{loading}
on:click={() => changeEmail()}
>
Update email
</Button>
</div>
@@ -8,6 +8,7 @@
import Popover from './meltComponents/Popover.svelte'
import { offset, flip, shift } from 'svelte-floating-ui/dom'
import ChangeInstanceUsernameInner from './ChangeInstanceUsernameInner.svelte'
import ChangeInstanceEmailInner from './ChangeInstanceEmailInner.svelte'
import { UserService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import TextInput from './text_input/TextInput.svelte'
@@ -66,6 +67,12 @@
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-8 max-w-sm p-4">
<ChangeInstanceEmailInner
{email}
{username}
noPadding
on:changed={() => dispatch('refresh')}
/>
{#if automateUsernameCreation && username}
<ChangeInstanceUsernameInner {email} {username} on:renamed noPadding />
{/if}
@@ -974,6 +974,48 @@ set_flow_json({
})
\`\`\`
**Example - Flow with while loop:**
In a while loop, \`flow_input.iter.value\` equals \`flow_input.iter.index\` (a plain number: 0, 1, 2, ...) — it never carries state, so \`flow_input.iter.value.count\` is always undefined and a counter built on it never advances. To carry state across iterations, a step reads its own previous-iteration result via \`results.<its_own_id>\` with a first-iteration fallback (e.g. \`results.tick ?? flow_input.start\`) — but then the loop's \`stop_after_if\` MUST sit on that inner step: a body that is exactly one plain step with the stop condition on the loop module runs on a fast path where \`results.<step_id>\` is null every iteration and the loop never terminates (bodies with 2+ steps, or whose single step has its own \`stop_after_if\`, retry or similar, resolve \`results\` across iterations regardless of stop placement). For plain counters, deriving from \`flow_input.iter.index\` works in every configuration. \`stop_after_if\` is evaluated after each iteration — on the loop module \`result\` is the last iteration's result (the return of the iteration's final step); on an inner step it is that step's result.
\`\`\`javascript
set_flow_json({
modules: [
{
id: "count_up",
summary: "Increment until target",
value: {
type: "whileloopflow",
skip_failures: false,
modules: [
{
id: "tick",
summary: "Compute current count",
value: {
type: "rawscript",
language: "bun",
content: "export async function main(count: number, target: number) { return { count, done: count >= target }; }",
input_transforms: {
count: { type: "javascript", expr: "flow_input.iter.index + 1" },
target: { type: "javascript", expr: "flow_input.target" }
}
}
}
]
},
stop_after_if: { expr: "result.done", skip_if_stopped: false }
}
],
schema: {
type: "object",
properties: {
target: { type: "number", description: "Stop when the count reaches this value" }
},
required: ["target"]
}
})
\`\`\`
**Example - Flow with branches (branchone):**
\`\`\`javascript
set_flow_json({
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,310 @@
import { describe, expect, it } from 'vitest'
import {
RESERVED_LOCALS,
createRawAppFromFlow,
createRawAppFromScript
} from './createRawAppFromScript'
describe('createRawAppFromScript', () => {
it('builds a path runnable and a form calling it', () => {
const app = createRawAppFromScript('u/dev/greet_user', 'Greet a user', {
type: 'object',
order: ['name', 'age', 'mode'],
required: ['name', 'age'],
properties: {
name: { type: 'string' },
age: { type: 'integer', default: 42 },
mode: { type: 'string', enum: ['fast', 'slow'] }
}
})
expect(app.summary).toBe('Greet a user')
expect(app.value.runnables).toEqual({
greet_user: {
name: 'u/dev/greet_user',
type: 'path',
runType: 'script',
path: 'u/dev/greet_user',
schema: expect.objectContaining({ type: 'object' }),
fields: {}
}
})
const appTsx = app.value.files['/App.tsx']
expect(appTsx).toContain("const [name, setName] = useState('')")
expect(appTsx).toContain("const [ageText, setAgeText] = useState('42')")
// Required args are passed unconditionally so they satisfy the non-optional
// type `genWmillTs` derives from the same schema.
expect(appTsx).toContain('age: Number(ageText)')
expect(appTsx).toContain('mode,')
expect(appTsx).toContain('await backend.greet_user({')
expect(appTsx).toContain('<option value="slow">slow</option>')
})
it('lets an emptied optional number input mean "unset"', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
properties: { n: { type: 'number' } }
})
expect(app.value.files['/App.tsx']).toContain("n: nText === '' ? undefined : Number(nText)")
})
it('renames arguments that would shadow the component locals', () => {
const app = createRawAppFromFlow('u/dev/f', 'Flow', {
type: 'object',
required: ['result'],
properties: { result: { type: 'string' } }
})
const appTsx = app.value.files['/App.tsx']
expect(appTsx).toContain("const [result_, setResult_] = useState('')")
expect(appTsx).toContain('result: result_')
expect(app.value.runnables['f'].runType).toBe('flow')
})
it('keeps every generated binding unique and syntactically valid', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
order: ['foo', 'setFoo', 'class'],
properties: {
foo: { type: 'string' },
setFoo: { type: 'string' },
class: { type: 'string' }
}
})
const appTsx = app.value.files['/App.tsx']
expect(appTsx).toContain("const [foo, setFoo] = useState('')")
expect(appTsx).toContain("const [setFoo_, setSetFoo_] = useState('')")
expect(appTsx).toContain("const [class_, setClass_] = useState('')")
const declared = [...appTsx.matchAll(/const \[(\w+), (\w+)\]/g)].flatMap((m) => [m[1], m[2]])
expect(new Set(declared).size).toBe(declared.length)
})
it('does not let an argument shadow a global the generated body calls', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
order: ['JSON', 'eval'],
properties: { JSON: { type: 'string' }, eval: { type: 'string' } }
})
const appTsx = app.value.files['/App.tsx']
expect(appTsx).toContain("const [JSON_, setJSON_] = useState('')")
expect(appTsx).toContain("const [eval_, setEval_] = useState('')")
// The result panel must still reach the real global.
expect(appTsx).toContain('JSON.stringify(result, null, 2)')
})
it('keeps the undefined sentinel reachable when an argument is named undefined', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
order: ['undefined', 'n'],
properties: { undefined: { type: 'string' }, n: { type: 'number' } }
})
const appTsx = app.value.files['/App.tsx']
expect(appTsx).toContain("const [undefined_, setUndefined_] = useState('')")
expect(appTsx).toContain('undefined: undefined_')
// The omitted-optional sentinel and the render guards must still be the
// real global, not the field's value.
expect(appTsx).toContain("nText === '' ? undefined : Number(nText)")
expect(appTsx).toContain('{result !== undefined &&')
expect(appTsx).toContain('{error !== undefined &&')
})
it('masks password arguments and declares them sensitive on the runnable', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
required: ['token'],
properties: { token: { type: 'string', password: true }, plain: { type: 'string' } }
})
expect(app.value.files['/App.tsx']).toContain('type="password"')
// The policy's `sensitive_inputs` is derived from these fields, so without
// them the secret is stored in the job args in the clear.
expect(app.value.runnables['s'].fields).toEqual({
token: { type: 'user', value: undefined, sensitive: true }
})
})
it('passes a __proto__ argument as an own property', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
required: ['__proto__'],
// Computed key: a plain `__proto__:` here would set this literal's
// prototype and the property would not exist at all.
properties: { ['__proto__']: { type: 'number' } }
})
expect(app.value.files['/App.tsx']).toContain('["__proto__"]: Number(__proto__Text)')
})
it('omits blank optional text instead of sending an empty string', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
required: ['req'],
properties: { req: { type: 'string' }, opt: { type: 'string' } }
})
const appTsx = app.value.files['/App.tsx']
expect(appTsx).toContain("opt: opt === '' ? undefined : opt")
expect(appTsx).toContain('req,')
// The required marker has to be enforced, not just drawn.
expect(appTsx).toMatch(/type="text"\n\t+required\n\t+value=\{req\}/)
})
it('lets a resource argument through as a user-supplied resource', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
required: ['db'],
properties: { db: { type: 'object', format: 'resource-postgresql' } }
})
// Without `allowUserResources` the backend swaps the submitted `$res:`
// reference for a placeholder and the argument never resolves.
expect(app.value.runnables['s'].fields).toEqual({
db: { type: 'user', value: undefined, allowUserResources: true }
})
const appTsx = app.value.files['/App.tsx']
// Empty, not a bare `$res:`: an untouched optional field must read as
// omitted and an untouched required one must trip the `required` check.
expect(appTsx).toContain("const [db, setDb] = useState('')")
expect(appTsx).toContain('resource path, e.g. $res:u/user/my_postgresql')
})
it('omits untouched optional object and array fields', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
required: ['reqObj'],
properties: {
reqObj: { type: 'object' },
optObj: { type: 'object' },
optArr: { type: 'array' }
}
})
const appTsx = app.value.files['/App.tsx']
// A pre-filled `{}` could never reach the omission branch, so an untouched
// field would override the runnable's own default.
expect(appTsx).toContain("const [optObjText, setOptObjText] = useState('')")
expect(appTsx).toContain("const [optArrText, setOptArrText] = useState('')")
expect(appTsx).toContain("const [reqObjText, setReqObjText] = useState('{}')")
})
it('quotes argument names that cannot be bare property keys', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
required: ["user's-name"],
properties: { "user's-name": { type: 'string' } }
})
// `str` would pick a template literal here, which is not a legal property
// name, so the module would not compile.
expect(app.value.files['/App.tsx']).toContain('"user\'s-name": user_s_name')
})
// Every global the template names is an identifier a schema property could
// shadow, so each one has to be reserved.
it('reserves every global the generated source names', () => {
const app = createRawAppFromScript('u/dev/s', 'T', {
type: 'object',
required: ['a', 'tags'],
properties: {
a: { type: 'string' },
b: { type: 'number' },
c: { type: 'object' },
d: { type: 'boolean' },
tags: { type: 'array', items: { type: 'string' }, enum: ['x', 'y'] },
res: { type: 'object', format: 'resource-postgresql' },
pw: { type: 'string', password: true }
}
})
const src = app.value.files['/App.tsx']
const globals = [
'Array',
'BigInt',
'Boolean',
'Date',
'Error',
'Infinity',
'JSON',
'Map',
'Math',
'NaN',
'Number',
'Object',
'Promise',
'RegExp',
'Set',
'String',
'Symbol',
'console',
'globalThis',
'isNaN',
'parseFloat',
'parseInt',
'undefined'
]
const named = globals.filter((g) => new RegExp(`(?<![.\\w'"])${g}\\b`).test(src))
expect(named.filter((g) => !RESERVED_LOCALS.includes(g))).toEqual([])
})
it('keeps an array enum an array', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
required: ['tags'],
properties: {
tags: { type: 'array', items: { type: 'string' }, enum: ['a', 'b'], default: ['a'] }
}
})
const appTsx = app.value.files['/App.tsx']
// `genWmillTs` types this `string[]`, so scalar state would not compile.
expect(appTsx).toContain("const [tags, setTags] = useState(['a'] as string[])")
// `required` does constrain a `<select multiple>`, so the marker is enforced.
expect(appTsx).toMatch(/multiple\n\t+required/)
expect(appTsx).toContain('[...e.target.selectedOptions].map((o) => o.value)')
expect(appTsx).toContain('tags,')
})
it('lets an optional enum fall back to the runnable default', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
required: ['req'],
properties: {
req: { type: 'string', enum: ['x', 'y'] },
opt: { type: 'string', enum: ['x', 'y'] }
}
})
const appTsx = app.value.files['/App.tsx']
expect(appTsx).toContain("const [req, setReq] = useState('x')")
expect(appTsx).toContain("const [opt, setOpt] = useState('')")
expect(appTsx).toContain("opt: opt === '' ? undefined : opt")
expect(appTsx).toContain('<option value=""></option>')
})
it('passes enum values through JSX unchanged', () => {
const app = createRawAppFromScript('u/dev/s', 'Tom &amp; Jerry', {
type: 'object',
properties: { mode: { type: 'string', enum: ['a&amp;b', 'plain'] } }
})
const appTsx = app.value.files['/App.tsx']
// A plain attribute/text would let JSX decode the entity, so the runnable
// would receive `a&b` instead of the schema's `a&amp;b`.
expect(appTsx).toContain("<option value={'a&amp;b'}>{'a&amp;b'}</option>")
expect(appTsx).toContain('<h1 className="app-title">{\'Tom &amp; Jerry\'}</h1>')
})
it('keeps value and label apart for labeled enums', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
properties: {
mode: {
type: 'string',
enum: [
{ value: 'fast', label: 'Fast (cached)' },
{ value: 'slow', label: 'Slow (fresh)' }
]
}
}
})
const appTsx = app.value.files['/App.tsx']
expect(appTsx).toContain('<option value="fast">Fast (cached)</option>')
expect(appTsx).toContain('<option value="slow">Slow (fresh)</option>')
// Optional, so it seeds blank and offers an empty option; see the
// optional-enum case below.
expect(appTsx).toContain("const [mode, setMode] = useState('')")
expect(appTsx).not.toContain('[object Object]')
})
})
@@ -0,0 +1,720 @@
import { forbiddenIds } from '$lib/components/flows/idUtils'
import { react19Template } from '$lib/components/raw_apps/templates'
/** Shape the `/apps_raw/add` import handoff consumes (`$importStore` /
* `sessionStorage.rawAppImport`). */
export type RawAppImport = {
summary: string
value: {
files: Record<string, string>
runnables: Record<string, any>
}
}
type FieldKind =
| 'text'
| 'password'
| 'resource'
| 'number'
| 'boolean'
| 'enum'
| 'multienum'
| 'json'
/** Schema enums are either bare values or `{ value, label }` pairs; the option
* submits `value` and displays `label`. */
type EnumOption = { value: string; label: string }
type Field = {
/** Schema property name, i.e. the runnable argument key. */
key: string
kind: FieldKind
/** React state variable holding this field's input value. */
local: string
setter: string
label: string
description: string | undefined
required: boolean
/** Schema `password` flag: masks the input and marks the runnable field
* sensitive, which is what puts it in the policy's `sensitive_inputs`. */
sensitive: boolean
/** `format: resource-*`. Without the matching field flag the backend replaces
* a submitted `$res:` reference with a placeholder, so the arg never
* resolves. */
allowUserResources: boolean
enumValues: EnumOption[]
/** `useState(...)` initial value, as TS source. */
init: string
/** Value passed to `backend.<runnable>({ ... })`, as TS source. */
arg: string
}
// Every identifier the generated component references: its own locals and
// setters, plus the globals its body calls. A runnable argument with one of
// these names gets suffixed, so it can neither redeclare nor shadow them (a
// field named `JSON` would otherwise turn `JSON.stringify(result)` into a call
// on the field's value).
export const RESERVED_LOCALS = [
'React',
'useState',
'backend',
'App',
'run',
'e',
'result',
'setResult',
'error',
'setError',
'running',
'setRunning',
'JSON',
'Number',
'String',
'Error',
'undefined'
]
// Reserved words can't name a `const`, so an argument called `class` or `new`
// gets suffixed like a collision would be. `eval` and `arguments` are illegal
// binding names in a module (always strict), so they belong here too.
const JS_KEYWORDS = [
'arguments',
'await',
'break',
'case',
'catch',
'class',
'const',
'continue',
'debugger',
'default',
'delete',
'do',
'else',
'enum',
'eval',
'export',
'extends',
'false',
'finally',
'for',
'function',
'if',
'implements',
'import',
'in',
'instanceof',
'interface',
'let',
'new',
'null',
'package',
'private',
'protected',
'public',
'return',
'static',
'super',
'switch',
'this',
'throw',
'true',
'try',
'typeof',
'var',
'void',
'while',
'with',
'yield'
]
/** `u/admin/my_script` -> `my_script`, sanitized to the identifier the raw-app
* editor accepts as a runnable id (letters, digits, underscores). */
function runnableIdFromPath(path: string): string {
const segment = path.split('/').filter(Boolean).pop() ?? ''
const cleaned = segment.replace(/[^A-Za-z0-9_]/g, '_')
const id = /^[A-Za-z_]/.test(cleaned) ? cleaned : `run_${cleaned}`
return cleaned === '' || forbiddenIds.includes(id) ? 'a' : id
}
/** Reserves a state variable and its setter together: an argument named `foo`
* claims both `foo` and `setFoo`, so a sibling argument named `setFoo` is
* pushed to `setFoo_` instead of redeclaring the first one's setter. */
function allocLocal(base: string, taken: string[]): { local: string; setter: string } {
const cleaned = base.replace(/[^A-Za-z0-9_$]/g, '_')
let local = /^[A-Za-z_$]/.test(cleaned) ? cleaned : `_${cleaned}`
let setter = setterName(local)
while (taken.includes(local) || taken.includes(setter) || JS_KEYWORDS.includes(local)) {
local = `${local}_`
setter = setterName(local)
}
taken.push(local, setter)
return { local, setter }
}
function setterName(local: string): string {
return `set${local.charAt(0).toUpperCase()}${local.slice(1)}`
}
function isResourceProp(prop: any): boolean {
return typeof prop?.format === 'string' && prop.format.startsWith('resource-')
}
/** Options live on the property for a scalar enum and on `items` for an array
* one; either shape may carry them. */
function enumSource(prop: any): any[] {
if (Array.isArray(prop?.enum) && prop.enum.length > 0) return prop.enum
if (Array.isArray(prop?.items?.enum) && prop.items.enum.length > 0) return prop.items.enum
return []
}
function fieldKind(prop: any): FieldKind {
if (isResourceProp(prop)) return 'resource'
if (enumSource(prop).length > 0) {
// An array-typed enum is a multiselect: `genWmillTs` types it `string[]`,
// so a scalar `<select>` would generate state the runnable call rejects.
return prop?.type === 'array' ? 'multienum' : 'enum'
}
if (prop?.type === 'boolean') return 'boolean'
if (prop?.type === 'number' || prop?.type === 'integer') return 'number'
if (prop?.type === 'string') return prop.password === true ? 'password' : 'text'
return 'json'
}
// Newline excluded: a template literal can carry it verbatim.
// eslint-disable-next-line no-control-regex
const CONTROL_CHARS = /[\u0000-\u0009\u000b-\u001f\u007f]/
/** TS string literal. Prefers the quoting that keeps the generated source
* readable, falling back to `JSON.stringify`'s escaping. */
function str(value: string): string {
if (value.includes('\\') || value.includes('\r') || CONTROL_CHARS.test(value)) {
return JSON.stringify(value)
}
if (!value.includes("'") && !value.includes('\n')) return `'${value}'`
if (!value.includes('`') && !value.includes('${')) return `\`${value}\``
return JSON.stringify(value)
}
/** JSX text that would otherwise be swallowed by the parser (`{`, `<`, `}`) or
* rewritten by it (`&` starts an entity, so a literal `&amp;` would decode to
* `&`) goes through an expression container, which JSX copies verbatim. */
function jsxText(value: string): string {
return /^[^{}<>&]*$/.test(value) ? value : `{${str(value)}}`
}
/** JSX attribute value: a plain quoted string when it can be, an expression
* container otherwise. Entities decode in attributes too, so `&` disqualifies
* the plain form an enum value must reach the runnable unchanged. */
function jsxAttr(value: string): string {
return /^[^"{}<>&\n]*$/.test(value) ? `"${value}"` : `{${str(value)}}`
}
/** The schema's own description, plus what the control can't convey on its own:
* which resource type to point at, and that an object-typed secret is stored
* encrypted even though its textarea shows it in the clear (the platform's own
* ArgInput says the same rather than masking it). */
function fieldHint(prop: any, kind: FieldKind): string | undefined {
const own =
typeof prop.description === 'string' && prop.description !== '' ? prop.description : undefined
let extra: string | undefined
if (kind === 'resource') {
extra = `resource path, e.g. $res:u/user/my_${String(prop.format).slice('resource-'.length)}`
} else if (kind === 'json' && prop.password === true) {
extra = 'stored as a secret on submit'
}
if (!extra) return own
return own ? `${own} (${extra})` : extra
}
function toFields(schema: Record<string, any> | undefined): Field[] {
const properties: Record<string, any> = schema?.properties ?? {}
const order: string[] = Array.isArray(schema?.order) ? schema.order : []
const keys = [
...order.filter((k) => k in properties),
...Object.keys(properties).filter((k) => !order.includes(k))
]
const required: string[] = Array.isArray(schema?.required) ? schema.required : []
const taken = [...RESERVED_LOCALS]
return keys.map((key) => {
const prop = properties[key] ?? {}
const kind = fieldKind(prop)
const isRequired = required.includes(key)
const { local, setter } = allocLocal(
kind === 'number' || kind === 'json' ? `${key}Text` : key,
taken
)
const enumValues: EnumOption[] = enumSource(prop).map((v: any) =>
v != undefined && typeof v === 'object'
? { value: String(v.value), label: String(v.label ?? v.value) }
: { value: String(v), label: String(v) }
)
let init: string
let arg: string
if (kind === 'boolean') {
init = prop.default === true ? 'true' : 'false'
arg = local
} else if (kind === 'number') {
init = str(prop.default != undefined ? String(prop.default) : '')
// An emptied input means "unset", which only type-checks when the
// argument is optional.
arg = isRequired ? `Number(${local})` : `${local} === '' ? undefined : Number(${local})`
} else if (kind === 'json') {
// Only a required field is pre-filled with an empty collection: a
// non-blank optional textarea can never take the omission branch, so an
// untouched one would override the runnable's own default.
const seed =
prop.default != undefined
? JSON.stringify(prop.default, null, 2)
: isRequired
? JSON.stringify(prop.type === 'array' ? [] : {}, null, 2)
: ''
init = str(seed)
arg = isRequired
? `JSON.parse(${local})`
: `${local}.trim() === '' ? undefined : JSON.parse(${local})`
} else if (kind === 'multienum') {
const defaults = Array.isArray(prop.default) ? prop.default.map((v: any) => String(v)) : []
// Annotated: `useState([])` alone infers `never[]`.
init = `[${defaults.map(str).join(', ')}] as string[]`
arg = isRequired ? local : `${local}.length === 0 ? undefined : ${local}`
} else if (kind === 'enum') {
// An optional enum gets a blank option so the runnable's own default
// stays reachable; a required one always carries a real selection.
const seed = typeof prop.default === 'string' ? prop.default : ''
init = str(seed !== '' || !isRequired ? seed : (enumValues[0]?.value ?? ''))
arg = isRequired ? local : `${local} === '' ? undefined : ${local}`
} else if (kind === 'resource') {
// Starts empty, never at a bare `$res:`: an untouched optional field has
// to read as omitted, and an untouched required one has to trip the
// browser's `required` check rather than ask the backend to resolve an
// empty path. The hint carries the expected shape instead.
init = str(typeof prop.default === 'string' ? prop.default : '')
arg = isRequired ? local : `${local} === '' ? undefined : ${local}`
} else {
init = str(typeof prop.default === 'string' ? prop.default : '')
// Blank optional text is "unset", so the runnable's own default applies
// rather than an empty string overriding it.
arg = isRequired ? local : `${local} === '' ? undefined : ${local}`
}
return {
key,
kind,
local,
setter,
label: typeof prop.title === 'string' && prop.title !== '' ? prop.title : key,
description: fieldHint(prop, kind),
required: isRequired,
sensitive: prop.password === true,
allowUserResources: isResourceProp(prop),
enumValues,
init,
arg
}
})
}
function fieldInput(field: Field): string {
// The `*` marker is only decorative without this: the browser has to block
// the submit, else an empty required field posts '' (or NaN) and fails
// server-side. Two exemptions: `required` on a checkbox would force it on, and
// a required single `<select>` always carries a real selection already. A
// `<select multiple>` can be empty, so it does take the attribute.
const req =
field.required && field.kind !== 'boolean' && field.kind !== 'enum'
? '\n\t\t\t\t\t\trequired'
: ''
switch (field.kind) {
case 'boolean':
return `<input
className="field-checkbox"
type="checkbox"
checked={${field.local}}
onChange={(e) => ${field.setter}(e.target.checked)}
/>`
case 'number':
return `<input
className="field-input"
type="number"${req}
value={${field.local}}
onChange={(e) => ${field.setter}(e.target.value)}
/>`
case 'enum':
return `<select
className="field-input"
value={${field.local}}
onChange={(e) => ${field.setter}(e.target.value)}
>
${field.required ? '' : ' <option value=""></option>\n'}${field.enumValues.map((o) => ` <option value=${jsxAttr(o.value)}>${jsxText(o.label)}</option>`).join('\n')}
</select>`
case 'multienum':
// `size` is resolved here and the handler spreads rather than calling
// `Math.min` / `Array.from`: every global the template names is one more
// identifier an argument could shadow, so the template names none.
return `<select
className="field-input field-multiselect"
multiple${req}
size={${Math.min(field.enumValues.length, 6)}}
value={${field.local}}
onChange={(e) =>
${field.setter}([...e.target.selectedOptions].map((o) => o.value))
}
>
${field.enumValues.map((o) => ` <option value=${jsxAttr(o.value)}>${jsxText(o.label)}</option>`).join('\n')}
</select>`
case 'json':
return `<textarea
className="field-input field-textarea"
rows={4}${req}
value={${field.local}}
onChange={(e) => ${field.setter}(e.target.value)}
/>`
case 'resource':
return `<input
className="field-input"
type="text"${req}
value={${field.local}}
onChange={(e) => ${field.setter}(e.target.value)}
/>`
case 'password':
return `<input
className="field-input"
type="password"
autoComplete="off"${req}
value={${field.local}}
onChange={(e) => ${field.setter}(e.target.value)}
/>`
default:
return `<input
className="field-input"
type="text"${req}
value={${field.local}}
onChange={(e) => ${field.setter}(e.target.value)}
/>`
}
}
function fieldJsx(field: Field): string {
const label = `<span className="field-label">
${jsxText(field.label)}${field.required ? '\n\t\t\t\t\t\t<span className="field-required">*</span>' : ''}
</span>`
const description = field.description
? `\n <span className="field-description">${jsxText(field.description)}</span>`
: ''
return ` <label className="field">
${label}${description}
${fieldInput(field)}
</label>`
}
function generateAppTsx(opts: {
runnableId: string
fields: Field[]
title: string
subtitle: string
}): string {
const { runnableId, fields, title, subtitle } = opts
const states = fields
.map((f) => ` const [${f.local}, ${f.setter}] = useState(${f.init})`)
.join('\n')
const args = fields
.map((f) => {
// Shorthand is an own property even for `__proto__`; the `key: value`
// form is not — there it sets the prototype and the argument never
// reaches the runnable, so that one key needs a computed key.
if (f.arg === f.key) return ` ${f.key}`
// `str` may pick a template literal, which is not a legal property
// name, so keys always take JSON quoting rather than `str`.
const quoted = JSON.stringify(f.key)
const key =
f.key === '__proto__'
? `[${quoted}]`
: /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(f.key)
? f.key
: quoted
return ` ${key}: ${f.arg}`
})
.join(',\n')
const callArgs = fields.length === 0 ? '{}' : `{\n${args}\n }`
const form =
fields.length === 0
? ' <p className="app-empty">This runnable takes no arguments.</p>\n'
: fields.map(fieldJsx).join('\n') + '\n'
return `import React, { useState } from 'react'
import { backend } from './wmill'
import './index.css'
const App = () => {
${states}${states === '' ? '' : '\n'} const [result, setResult] = useState(undefined as any)
const [error, setError] = useState(undefined as string | undefined)
const [running, setRunning] = useState(false)
async function run() {
setRunning(true)
setError(undefined)
try {
setResult(await backend.${runnableId}(${callArgs}))
} catch (e) {
setResult(undefined)
setError(e instanceof Error ? e.message : String(e))
}
setRunning(false)
}
return (
<main className="app">
<h1 className="app-title">${jsxText(title)}</h1>
<p className="app-subtitle">${jsxText(subtitle)}</p>
<form
className="app-form"
onSubmit={(e) => {
e.preventDefault()
run()
}}
>
${form} <button className="run-button" type="submit" disabled={running}>
{running ? 'Running...' : 'Run'}
</button>
</form>
{error !== undefined && <pre className="app-error">{error}</pre>}
{result !== undefined && <pre className="app-result">{JSON.stringify(result, null, 2)}</pre>}
</main>
)
}
export default App
`
}
const indexCss = `/* The app renders inside an iframe whose host page has its own theme the
iframe cannot read, so paint an opaque surface here: without one the host's
background shows through and dark text lands on it unreadable. */
:root {
color-scheme: light;
--bg: #ffffff;
--fg: #18181b;
--muted: #71717a;
--border: #d4d4d8;
--surface: #f4f4f5;
--accent: #18181b;
--accent-fg: #ffffff;
--required: #dc2626;
--error-bg: #fef2f2;
--error-fg: #b91c1c;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--bg: #18181b;
--fg: #f4f4f5;
--muted: #a1a1aa;
--border: #3f3f46;
--surface: #27272a;
--accent: #f4f4f5;
--accent-fg: #18181b;
--required: #f87171;
--error-bg: #431a1a;
--error-fg: #fca5a5;
}
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--fg);
}
.app {
max-width: 640px;
margin: 0 auto;
padding: 24px 16px 48px;
font-family:
ui-sans-serif,
system-ui,
sans-serif;
color: var(--fg);
}
.app-title {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.app-subtitle {
margin: 4px 0 24px;
font-size: 0.8rem;
color: var(--muted);
}
.app-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field-label {
font-size: 0.8rem;
font-weight: 500;
}
.field-required {
color: var(--required);
margin-left: 2px;
}
.field-description {
font-size: 0.75rem;
color: var(--muted);
}
.field-input {
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 8px;
font-size: 0.875rem;
font-family: inherit;
background: var(--bg);
color: var(--fg);
}
.field-multiselect {
min-height: 72px;
}
.field-textarea {
font-family: ui-monospace, monospace;
resize: vertical;
}
.field-checkbox {
align-self: flex-start;
width: 16px;
height: 16px;
accent-color: var(--accent);
}
.app-empty {
font-size: 0.875rem;
color: var(--muted);
margin: 0;
}
.run-button {
align-self: flex-start;
border: none;
border-radius: 6px;
background: var(--accent);
color: var(--accent-fg);
padding: 8px 16px;
font-size: 0.875rem;
cursor: pointer;
}
.run-button:disabled {
opacity: 0.6;
cursor: default;
}
.app-error,
.app-result {
margin-top: 24px;
padding: 12px;
border-radius: 6px;
font-size: 0.8rem;
white-space: pre-wrap;
overflow-x: auto;
}
.app-error {
background: var(--error-bg);
color: var(--error-fg);
}
.app-result {
background: var(--surface);
}
`
function createRawApp(opts: {
path: string
summary: string | undefined
schema: Record<string, any> | undefined
runType: 'script' | 'flow'
}): RawAppImport {
const { path, summary, schema, runType } = opts
const runnableId = runnableIdFromPath(path)
const fields = toFields(schema)
const title = summary && summary !== '' ? summary : path
// `updateRawAppPolicy` derives the policy's `sensitive_inputs` and
// `allow_user_resources` from the runnable's fields, so an argument has to be
// declared here or the secret lands in the job args in the clear / the
// submitted `$res:` reference is replaced by a placeholder.
const runnableFields = Object.fromEntries(
fields
.filter((f) => f.sensitive || f.allowUserResources)
.map((f) => [
f.key,
{
type: 'user',
value: undefined,
...(f.sensitive ? { sensitive: true } : {}),
...(f.allowUserResources ? { allowUserResources: true } : {})
}
])
)
return {
summary: title,
value: {
files: {
...react19Template,
'/App.tsx': generateAppTsx({
runnableId,
fields,
title,
subtitle: `${runType} · ${path}`
}),
'/index.css': indexCss
},
runnables: {
[runnableId]: {
name: path,
type: 'path',
runType,
path,
schema: schema ?? {},
fields: runnableFields
}
}
}
}
}
/** React app scaffold that runs `path` from a form built off its schema. */
export function createRawAppFromScript(
path: string,
summary: string | undefined,
schema: Record<string, any> | undefined
): RawAppImport {
return createRawApp({ path, summary, schema, runType: 'script' })
}
export function createRawAppFromFlow(
path: string,
summary: string | undefined,
schema: Record<string, any> | undefined
): RawAppImport {
return createRawApp({ path, summary, schema, runType: 'flow' })
}
@@ -117,10 +117,32 @@
})
}
})
// Prune all additionalAssetsMap entries from deleted modules
// Ids the flow was loaded with. Only those modules can carry asset metadata predating
// the assets feature; anything appearing later was added in this session, so writing
// its assets adds nothing on top of a change the user has already made. The editor
// only mounts once the flow is loaded, so reading the prop here is the loaded state.
const loadedModuleIds = new Set(getAllModules(modules).map((m) => m.id))
// Analyzing is a flow-wide action: offer it once, then apply that answer to every
// other module of the flow rather than asking again for each one. A prompt left
// unanswered stays 'offered' and keeps those modules untouched for the session.
let flowAnalysis: 'unoffered' | 'offered' | 'accepted' = 'unoffered'
// Last inference per module, keyed on everything inferAssets depends on. The watchers
// below are re-created whenever a module is added or removed and the selection watch
// fires on creation, so a miss here means a re-parse on every structural edit.
type InferredAssets = Extract<Awaited<ReturnType<typeof inferAssets>>, { status: 'ok' }>
let analyzed: Record<string, { key: string; result: InferredAssets }> = {}
// Prune per-module caches from deleted modules
$effect(() => {
if (!flowGraphAssetsCtx) return
const modulesSet = new Set(allModules.map((m) => m.id))
for (const key of Object.keys(analyzed)) {
if (!modulesSet.has(key)) delete analyzed[key]
}
for (const key of [...loadedModuleIds]) {
if (!modulesSet.has(key)) loadedModuleIds.delete(key)
}
if (!flowGraphAssetsCtx) return
for (const key of Object.keys(flowGraphAssetsCtx.val.additionalAssetsMap)) {
if (!modulesSet.has(key)) {
delete flowGraphAssetsCtx.val.additionalAssetsMap[key]
@@ -129,6 +151,7 @@
})
function analyzeEntireFlow() {
flowAnalysis = 'accepted'
for (const mod of allModules) {
if (mod.value.type === 'rawscript') {
parseAndUpdateRawScriptModule(mod.value, mod.id)
@@ -136,27 +159,37 @@
}
}
async function parseAndUpdateRawScriptModule(
v: RawScript,
modId: string,
isUserEdit: boolean = true
) {
console.log('Parsing assets for RawScript module', modId)
let inferAssetsResult = await inferAssets(v.language, v.content)
if (inferAssetsResult.status === 'error') return
if (flowGraphAssetsCtx) flowGraphAssetsCtx.val.sqlQueries[modId] = inferAssetsResult.sql_queries
let newAssets = inferAssetsResult.assets as AssetWithAltAccessType[]
async function parseAndUpdateRawScriptModule(v: RawScript, modId: string, prompt = false) {
const key = JSON.stringify([v.language, v.content])
let inferred = analyzed[modId]?.key === key ? analyzed[modId].result : undefined
if (!inferred) {
const inferAssetsResult = await inferAssets(v.language, v.content)
if (inferAssetsResult.status === 'error') return
inferred = inferAssetsResult
analyzed[modId] = { key, result: inferred }
}
// Copy before handing anything to the flow store: stored values become reactive
// proxies, and a later replay of this same inference would mutate the cache.
const { assets, sql_queries } = structuredClone(inferred)
if (flowGraphAssetsCtx) flowGraphAssetsCtx.val.sqlQueries[modId] = sql_queries
let newAssets = assets as AssetWithAltAccessType[]
for (const asset of newAssets) {
const old = v.assets?.find((a) => assetEq(a, asset))
if (old?.alt_access_type) asset.alt_access_type = old.alt_access_type
}
const normalizedAssets = newAssets.length > 0 ? newAssets : undefined
if (!deepEqual(v.assets, normalizedAssets)) {
if (!isUserEdit && normalizedAssets && normalizedAssets.length > 0) {
if (prompt && flowAnalysis !== 'accepted' && normalizedAssets?.length) {
if (flowAnalysis === 'offered') return
flowAnalysis = 'offered'
// Long-lived because it is the only entry point to analyzeEntireFlow and it is
// offered once: a toast the user misses cannot be brought back without a reload.
sendUserToast(
'Assets were detected in this step. Analyze entire flow for assets?',
'warning',
[{ label: 'Analyze entire flow', callback: () => analyzeEntireFlow() }]
[{ label: 'Analyze entire flow', callback: () => analyzeEntireFlow() }],
undefined,
20000
)
} else {
v.assets = normalizedAssets
@@ -181,7 +214,11 @@
// Also recompute if the module is selected
watch([() => selectedId === mod.id], () => {
if (selectedId === mod.id)
parseAndUpdateRawScriptModule(modValue, mod.id, modValue.assets !== undefined)
parseAndUpdateRawScriptModule(
modValue,
mod.id,
modValue.assets === undefined && loadedModuleIds.has(mod.id)
)
})
}
}
@@ -476,6 +476,10 @@
function collapseOwner(owner: string): void {
openOwners.delete(owner)
}
// The `f/<folder>` / `u/<user>` prefix a path belongs to.
function ownerOf(path: string): string {
return path.split('/').slice(0, 2).join('/')
}
// Reload the merged list once and re-fetch the owners that are currently expanded so
// they don't go blank. Used both for row mutations (create/edit/archive/move/share)
// and for in-place scope changes (sort/archive/library/kind): those keep the tree in
@@ -484,14 +488,15 @@
// change (owner/search) has switched the tree out of lazy mode, there's nothing to
// re-fetch — the tree remounts and groups the global stream instead.
// For row mutations (create/edit/archive/move/share): the non_empty-filtered
// owner lists depend on workspace content, so a mutation can change them —
// moving an item into a previously-empty folder must surface that folder's
// node, and emptying a folder must drop it. Scope changes (sort/kind/archived
// via the reload $effect) go through reloadItems directly: content is unchanged
// there, and the resources already refetch on their own `archived` dep.
// owner lists and the per-owner counts depend on workspace content, so a
// mutation can change them — moving an item into a previously-empty folder must
// surface that folder's node, and emptying a folder must drop it. Scope changes
// (sort/kind/archived via the reload $effect) go through reloadItems directly:
// content is unchanged there, and the resources key on the scope themselves.
async function onItemMutated(): Promise<void> {
folderNamesRes.refetch()
usernamesRes.refetch()
void ownerCountsRes.refetch()
await reloadItems()
}
@@ -500,6 +505,16 @@
// late responses can't overwrite the fresh ones.
treeGen++
const toReload = treeLazyMode ? [...openOwners] : []
// Only the open owners are re-fetched below; a collapsed one keeps its rows as a
// cache, which this reload invalidates. Left in place they would outlive the scope
// they were loaded for: the owner stays grouped (so it keeps a node the new counts
// say is empty) and, since it is still marked loaded, expanding it again shows the
// previous scope's items instead of re-fetching. Drop them and let expand reload.
if (treeLazyMode) {
const open = new Set(toReload)
treeOwnerItems = treeOwnerItems.filter((x) => open.has(ownerOf(x.path)))
ownerLoad = Object.fromEntries(Object.entries(ownerLoad).filter(([o]) => open.has(o)))
}
await loadRunnables(true)
// force: the owners are still marked loaded, so re-fetch their first page and
// swap it in place (loadOwnerItems replaces each owner's rows atomically — the
@@ -720,7 +735,57 @@
let treeLazyMode = $derived(
treeView && !searching && ownerFilter == undefined && labelFilter == undefined
)
let treeInjectFolders = $derived(treeLazyMode ? (folderNamesRes.current ?? []) : [])
// How many runnables each owner (`f/<folder>` / `u/<user>`) holds for this user,
// in one request. It labels every node up front — a lazy owner's own count is
// unknown until it's expanded — and lets the tree drop the owners holding
// nothing instead of listing every workspace folder. Owners with none are
// omitted from the response, so an absent key means empty. Skipped outside lazy
// mode (the other modes group already-loaded rows) and in the archived view,
// which the endpoint doesn't count.
let ownerCountsRes = resource(
[
() => $workspaceStore,
() => treeLazyMode,
() => archived,
() => itemKind,
() => includeWithoutMain
],
async ([ws, lazyMode, showArchived, kind, withoutMain]) => {
if (!ws || !lazyMode || showArchived) return undefined
try {
const res = await ScriptService.countRunnablesByOwner({
workspace: ws,
kinds: kind !== 'all' ? kind : undefined,
includeWithoutMain: withoutMain ? true : undefined
})
return res.counts
} catch {
// Best-effort: without counts the tree shows every owner, as before.
return undefined
}
}
)
let ownerCounts = $derived(ownerCountsRes.current)
// The counts decide which owners the tree renders, so drawing it before they land
// would show every workspace folder and then prune it away. Hold the skeleton
// until the first response instead — it is fetched in parallel with the listing,
// so it costs no extra wait in practice. Only the first load gates: `current`
// survives a refetch, so an in-place scope change refreshes without flashing.
let treeCountsPending = $derived(
treeLazyMode && ownerCountsRes.current == undefined && ownerCountsRes.loading
)
// Owners the counts found the user has something in, split by kind. They cover
// what the folder/username lists miss: an item shared individually out of a
// folder or user space the user is otherwise not a member of.
function countOwners(kind: 'f' | 'u'): string[] {
return Object.keys(ownerCounts ?? {})
.filter((k) => k.startsWith(`${kind}/`))
.map((k) => k.slice(2))
}
let treeInjectFolders = $derived(
treeLazyMode ? [...new Set([...(folderNamesRes.current ?? []), ...countOwners('f')])] : []
)
let treeInjectUsers = $derived.by(() => {
// "Only f/*" hides every user namespace; "u/<you> and f/*" keeps just your own.
if (!treeLazyMode) return []
@@ -731,7 +796,10 @@
// and it must not vanish under a name sort whose first page is all folders.
if ($userStore?.username) s.add($userStore.username)
// Other users only when no user-folder restriction is active.
if (!filterUserFolders) for (const u of usernamesRes.current ?? []) s.add(u)
if (!filterUserFolders) {
for (const u of usernamesRes.current ?? []) s.add(u)
for (const u of countOwners('u')) s.add(u)
}
return [...s]
})
// The bottom "load more" only pages the *global* stream, which in lazy mode holds
@@ -1453,7 +1521,7 @@
{/if}
</div>
<div>
{#if filteredItems == undefined}
{#if filteredItems == undefined || treeCountsPending}
<div class="mt-4"></div>
<Skeleton layout={[[2], 1]} />
{#each new Array(6) as _}
@@ -1491,6 +1559,8 @@
pipelineFolders={visiblePipelineFolders}
allFolders={treeInjectFolders}
allUsers={treeInjectUsers}
ownerCounts={treeLazyMode ? ownerCounts : undefined}
selfUsername={$userStore?.username}
ownerLoad={treeLazyMode ? ownerLoad : undefined}
onExpandOwner={treeLazyMode ? loadOwnerItems : undefined}
onCollapseOwner={treeLazyMode ? collapseOwner : undefined}

Some files were not shown because too many files have changed in this diff Show More