diff --git a/ai_evals/cases/flow.yaml b/ai_evals/cases/flow.yaml index fa2ca3bee7..3a4f203b48 100644 --- a/ai_evals/cases/flow.yaml +++ b/ai_evals/cases/flow.yaml @@ -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 diff --git a/ai_evals/fixtures/frontend/flow/expected/test10_while_loop_counter.json b/ai_evals/fixtures/frontend/flow/expected/test10_while_loop_counter.json deleted file mode 100644 index 6bcd8671fb..0000000000 --- a/ai_evals/fixtures/frontend/flow/expected/test10_while_loop_counter.json +++ /dev/null @@ -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" - ] - } -} diff --git a/backend/.sqlx/query-03fdb7d0d4d95a98fdbdcf7dd1a9289119b9ce719d6a6c2975e96d7785819fc9.json b/backend/.sqlx/query-03fdb7d0d4d95a98fdbdcf7dd1a9289119b9ce719d6a6c2975e96d7785819fc9.json new file mode 100644 index 0000000000..d228ed6ae2 --- /dev/null +++ b/backend/.sqlx/query-03fdb7d0d4d95a98fdbdcf7dd1a9289119b9ce719d6a6c2975e96d7785819fc9.json @@ -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" +} diff --git a/backend/.sqlx/query-099067cf6c08642b205074771ef23d7ab950015d70f7065f5645f1e76d6a25ee.json b/backend/.sqlx/query-099067cf6c08642b205074771ef23d7ab950015d70f7065f5645f1e76d6a25ee.json new file mode 100644 index 0000000000..d71eee6337 --- /dev/null +++ b/backend/.sqlx/query-099067cf6c08642b205074771ef23d7ab950015d70f7065f5645f1e76d6a25ee.json @@ -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" +} diff --git a/backend/.sqlx/query-0da2425ff8ee737104cd9f2650f1ffba1511715ac1f9270939d79fb82f8e88d0.json b/backend/.sqlx/query-0da2425ff8ee737104cd9f2650f1ffba1511715ac1f9270939d79fb82f8e88d0.json new file mode 100644 index 0000000000..36e9ac3e04 --- /dev/null +++ b/backend/.sqlx/query-0da2425ff8ee737104cd9f2650f1ffba1511715ac1f9270939d79fb82f8e88d0.json @@ -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" +} diff --git a/backend/.sqlx/query-0f49203698dce2d77724c3d2fbaf729c6b2c2f9dd9475b778dbce66f28b48e69.json b/backend/.sqlx/query-0f49203698dce2d77724c3d2fbaf729c6b2c2f9dd9475b778dbce66f28b48e69.json new file mode 100644 index 0000000000..b630ea6c5d --- /dev/null +++ b/backend/.sqlx/query-0f49203698dce2d77724c3d2fbaf729c6b2c2f9dd9475b778dbce66f28b48e69.json @@ -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" +} diff --git a/backend/.sqlx/query-1159c2beb23057e3123847efa2b5d2d7cd7c89b47cbf5000fb74a9cdaa4b62df.json b/backend/.sqlx/query-1159c2beb23057e3123847efa2b5d2d7cd7c89b47cbf5000fb74a9cdaa4b62df.json new file mode 100644 index 0000000000..b67713ccc0 --- /dev/null +++ b/backend/.sqlx/query-1159c2beb23057e3123847efa2b5d2d7cd7c89b47cbf5000fb74a9cdaa4b62df.json @@ -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" +} diff --git a/backend/.sqlx/query-162ab0cc9094d7031229a65f47d730282d17bc8a71a0e234c9edf81b85199dd1.json b/backend/.sqlx/query-162ab0cc9094d7031229a65f47d730282d17bc8a71a0e234c9edf81b85199dd1.json new file mode 100644 index 0000000000..e3f8e5d178 --- /dev/null +++ b/backend/.sqlx/query-162ab0cc9094d7031229a65f47d730282d17bc8a71a0e234c9edf81b85199dd1.json @@ -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" +} diff --git a/backend/.sqlx/query-1b828adc60798b096ebf5db75fc1ade1ffffff492dc02caafe002fd868d90a8d.json b/backend/.sqlx/query-1b828adc60798b096ebf5db75fc1ade1ffffff492dc02caafe002fd868d90a8d.json new file mode 100644 index 0000000000..1b3398b6be --- /dev/null +++ b/backend/.sqlx/query-1b828adc60798b096ebf5db75fc1ade1ffffff492dc02caafe002fd868d90a8d.json @@ -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" +} diff --git a/backend/.sqlx/query-24e8f955d2a9a3bf502736749563e82974a5744dbb4942c1f2b2dff774594252.json b/backend/.sqlx/query-24e8f955d2a9a3bf502736749563e82974a5744dbb4942c1f2b2dff774594252.json new file mode 100644 index 0000000000..70b6bf874e --- /dev/null +++ b/backend/.sqlx/query-24e8f955d2a9a3bf502736749563e82974a5744dbb4942c1f2b2dff774594252.json @@ -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" +} diff --git a/backend/.sqlx/query-273e18a8fe284da79b0929678f2e07d2db3240c1501d99e6b3d2fccfd7405d06.json b/backend/.sqlx/query-273e18a8fe284da79b0929678f2e07d2db3240c1501d99e6b3d2fccfd7405d06.json new file mode 100644 index 0000000000..026b4bdf0d --- /dev/null +++ b/backend/.sqlx/query-273e18a8fe284da79b0929678f2e07d2db3240c1501d99e6b3d2fccfd7405d06.json @@ -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" +} diff --git a/backend/.sqlx/query-287a9f01a5b789b7724d7209f8f399638dd57f24fb407cbcb4fe4a510a74d3af.json b/backend/.sqlx/query-287a9f01a5b789b7724d7209f8f399638dd57f24fb407cbcb4fe4a510a74d3af.json new file mode 100644 index 0000000000..193a6ec42a --- /dev/null +++ b/backend/.sqlx/query-287a9f01a5b789b7724d7209f8f399638dd57f24fb407cbcb4fe4a510a74d3af.json @@ -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" +} diff --git a/backend/.sqlx/query-299a5d12779185a51b6e6815208dfe0aae64c39637208d081462b84d8636aa42.json b/backend/.sqlx/query-299a5d12779185a51b6e6815208dfe0aae64c39637208d081462b84d8636aa42.json new file mode 100644 index 0000000000..8935d39227 --- /dev/null +++ b/backend/.sqlx/query-299a5d12779185a51b6e6815208dfe0aae64c39637208d081462b84d8636aa42.json @@ -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" +} diff --git a/backend/.sqlx/query-2b3ffacd09b0fa4bb458f18adbd7cb0f57455338d28e3f164a105a51510c1cc1.json b/backend/.sqlx/query-2b3ffacd09b0fa4bb458f18adbd7cb0f57455338d28e3f164a105a51510c1cc1.json new file mode 100644 index 0000000000..9188048a22 --- /dev/null +++ b/backend/.sqlx/query-2b3ffacd09b0fa4bb458f18adbd7cb0f57455338d28e3f164a105a51510c1cc1.json @@ -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" +} diff --git a/backend/.sqlx/query-2f74858ebe82499e06283b0d22cc19ecf4e8c9a9ea6a76977bb17aefe9eb8f27.json b/backend/.sqlx/query-2f74858ebe82499e06283b0d22cc19ecf4e8c9a9ea6a76977bb17aefe9eb8f27.json new file mode 100644 index 0000000000..0b61f2f930 --- /dev/null +++ b/backend/.sqlx/query-2f74858ebe82499e06283b0d22cc19ecf4e8c9a9ea6a76977bb17aefe9eb8f27.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM tutorial_progress WHERE email = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "2f74858ebe82499e06283b0d22cc19ecf4e8c9a9ea6a76977bb17aefe9eb8f27" +} diff --git a/backend/.sqlx/query-32e6b8ee457bcf28ed052f56ac8d24ad333ee5c75086ab1f07ed952cf6aecea9.json b/backend/.sqlx/query-32e6b8ee457bcf28ed052f56ac8d24ad333ee5c75086ab1f07ed952cf6aecea9.json new file mode 100644 index 0000000000..c40d60ddbe --- /dev/null +++ b/backend/.sqlx/query-32e6b8ee457bcf28ed052f56ac8d24ad333ee5c75086ab1f07ed952cf6aecea9.json @@ -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" +} diff --git a/backend/.sqlx/query-34f1e04d3ff0ffe7fe4e142bb54e00431bc7fb4a4b20dacdce79316d7a84ba8f.json b/backend/.sqlx/query-34f1e04d3ff0ffe7fe4e142bb54e00431bc7fb4a4b20dacdce79316d7a84ba8f.json new file mode 100644 index 0000000000..7d28aad8ea --- /dev/null +++ b/backend/.sqlx/query-34f1e04d3ff0ffe7fe4e142bb54e00431bc7fb4a4b20dacdce79316d7a84ba8f.json @@ -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" +} diff --git a/backend/.sqlx/query-35384f86bf1058a228307655c545bfbe91032050603e882bca0d7352a0f08a62.json b/backend/.sqlx/query-35384f86bf1058a228307655c545bfbe91032050603e882bca0d7352a0f08a62.json new file mode 100644 index 0000000000..a703ab8c9d --- /dev/null +++ b/backend/.sqlx/query-35384f86bf1058a228307655c545bfbe91032050603e882bca0d7352a0f08a62.json @@ -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" +} diff --git a/backend/.sqlx/query-35b7c715362239c09765d3717340216154b4bda0bd5729e93318fd6527fbbbc4.json b/backend/.sqlx/query-35b7c715362239c09765d3717340216154b4bda0bd5729e93318fd6527fbbbc4.json new file mode 100644 index 0000000000..5109912701 --- /dev/null +++ b/backend/.sqlx/query-35b7c715362239c09765d3717340216154b4bda0bd5729e93318fd6527fbbbc4.json @@ -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" +} diff --git a/backend/.sqlx/query-3b7e46f80cf1e543a76f90c74080a3e2d1cf04812aa1091e3c64f262988da099.json b/backend/.sqlx/query-3b7e46f80cf1e543a76f90c74080a3e2d1cf04812aa1091e3c64f262988da099.json new file mode 100644 index 0000000000..94b6083732 --- /dev/null +++ b/backend/.sqlx/query-3b7e46f80cf1e543a76f90c74080a3e2d1cf04812aa1091e3c64f262988da099.json @@ -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" +} diff --git a/backend/.sqlx/query-3c5e00d0e79fa518a95b05a00e75dbd96d7852ad340759252de4dcb57ad22a43.json b/backend/.sqlx/query-3c5e00d0e79fa518a95b05a00e75dbd96d7852ad340759252de4dcb57ad22a43.json new file mode 100644 index 0000000000..6cfca6e125 --- /dev/null +++ b/backend/.sqlx/query-3c5e00d0e79fa518a95b05a00e75dbd96d7852ad340759252de4dcb57ad22a43.json @@ -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" +} diff --git a/backend/.sqlx/query-3fec727489a5166c3036593d7089fd252e2c779762cb359f98a7d17e657a926f.json b/backend/.sqlx/query-3fec727489a5166c3036593d7089fd252e2c779762cb359f98a7d17e657a926f.json new file mode 100644 index 0000000000..8c1515f633 --- /dev/null +++ b/backend/.sqlx/query-3fec727489a5166c3036593d7089fd252e2c779762cb359f98a7d17e657a926f.json @@ -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" +} diff --git a/backend/.sqlx/query-44356ac660ef1113f21b3ec24d67de7e64cda8f1e8419c231ecc090447614c4e.json b/backend/.sqlx/query-44356ac660ef1113f21b3ec24d67de7e64cda8f1e8419c231ecc090447614c4e.json new file mode 100644 index 0000000000..5d865507b9 --- /dev/null +++ b/backend/.sqlx/query-44356ac660ef1113f21b3ec24d67de7e64cda8f1e8419c231ecc090447614c4e.json @@ -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" +} diff --git a/backend/.sqlx/query-5ae3532928770d6bd9fc57a3ca489f819d8845ddb54f09f53880a55cd2304c8c.json b/backend/.sqlx/query-5ae3532928770d6bd9fc57a3ca489f819d8845ddb54f09f53880a55cd2304c8c.json new file mode 100644 index 0000000000..739065fab5 --- /dev/null +++ b/backend/.sqlx/query-5ae3532928770d6bd9fc57a3ca489f819d8845ddb54f09f53880a55cd2304c8c.json @@ -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" +} diff --git a/backend/.sqlx/query-5c3e4a9fd4ac5ce75326230ecbbd40bb126ae3e2dff1824904da91b2f7d900cd.json b/backend/.sqlx/query-5c3e4a9fd4ac5ce75326230ecbbd40bb126ae3e2dff1824904da91b2f7d900cd.json new file mode 100644 index 0000000000..d8f2889bdb --- /dev/null +++ b/backend/.sqlx/query-5c3e4a9fd4ac5ce75326230ecbbd40bb126ae3e2dff1824904da91b2f7d900cd.json @@ -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" +} diff --git a/backend/.sqlx/query-5d1bcca26a78968351da909b23cb5fd0d90ed946263db6ffda99ca1cd9e727f9.json b/backend/.sqlx/query-5d1bcca26a78968351da909b23cb5fd0d90ed946263db6ffda99ca1cd9e727f9.json new file mode 100644 index 0000000000..0cf33d83dd --- /dev/null +++ b/backend/.sqlx/query-5d1bcca26a78968351da909b23cb5fd0d90ed946263db6ffda99ca1cd9e727f9.json @@ -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" +} diff --git a/backend/.sqlx/query-5e74efd5f9152deb9511d2f1efc084cbf6aa8f252d86a50fa3a4f4e93bf373aa.json b/backend/.sqlx/query-5e74efd5f9152deb9511d2f1efc084cbf6aa8f252d86a50fa3a4f4e93bf373aa.json new file mode 100644 index 0000000000..7463702412 --- /dev/null +++ b/backend/.sqlx/query-5e74efd5f9152deb9511d2f1efc084cbf6aa8f252d86a50fa3a4f4e93bf373aa.json @@ -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" +} diff --git a/backend/.sqlx/query-629a6f38411c49c13bfb7773cef87ef0b833f57e23392ba0f9c6d2a165a4c9fe.json b/backend/.sqlx/query-629a6f38411c49c13bfb7773cef87ef0b833f57e23392ba0f9c6d2a165a4c9fe.json new file mode 100644 index 0000000000..e4065a22a7 --- /dev/null +++ b/backend/.sqlx/query-629a6f38411c49c13bfb7773cef87ef0b833f57e23392ba0f9c6d2a165a4c9fe.json @@ -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" +} diff --git a/backend/.sqlx/query-639f87dbe115bbd3d9eb9a70e74b4c8b3a71829aa9048fc2459e1f6603956e5d.json b/backend/.sqlx/query-639f87dbe115bbd3d9eb9a70e74b4c8b3a71829aa9048fc2459e1f6603956e5d.json new file mode 100644 index 0000000000..a86b2ba4b6 --- /dev/null +++ b/backend/.sqlx/query-639f87dbe115bbd3d9eb9a70e74b4c8b3a71829aa9048fc2459e1f6603956e5d.json @@ -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" +} diff --git a/backend/.sqlx/query-647c12e69360e6a34d01a563e6e84b26192c18cb4ede596233c00187b789d9ec.json b/backend/.sqlx/query-647c12e69360e6a34d01a563e6e84b26192c18cb4ede596233c00187b789d9ec.json new file mode 100644 index 0000000000..2b72dd883a --- /dev/null +++ b/backend/.sqlx/query-647c12e69360e6a34d01a563e6e84b26192c18cb4ede596233c00187b789d9ec.json @@ -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" +} diff --git a/backend/.sqlx/query-65ee4ace29b39818594efdfa16453e4806c4dca776f80789a57d4d85e16aa197.json b/backend/.sqlx/query-65ee4ace29b39818594efdfa16453e4806c4dca776f80789a57d4d85e16aa197.json new file mode 100644 index 0000000000..5d557f14d9 --- /dev/null +++ b/backend/.sqlx/query-65ee4ace29b39818594efdfa16453e4806c4dca776f80789a57d4d85e16aa197.json @@ -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" +} diff --git a/backend/.sqlx/query-73910791d9eca97624c1b1b9dd65cc861d0339a4238bce6928af49601a6ad4d1.json b/backend/.sqlx/query-73910791d9eca97624c1b1b9dd65cc861d0339a4238bce6928af49601a6ad4d1.json new file mode 100644 index 0000000000..cbf6a2dfa5 --- /dev/null +++ b/backend/.sqlx/query-73910791d9eca97624c1b1b9dd65cc861d0339a4238bce6928af49601a6ad4d1.json @@ -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" +} diff --git a/backend/.sqlx/query-774db0fcbc3905a8c80f271612efcaf7268011f8868028cad6d5339419cb7aef.json b/backend/.sqlx/query-774db0fcbc3905a8c80f271612efcaf7268011f8868028cad6d5339419cb7aef.json new file mode 100644 index 0000000000..6817825419 --- /dev/null +++ b/backend/.sqlx/query-774db0fcbc3905a8c80f271612efcaf7268011f8868028cad6d5339419cb7aef.json @@ -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" +} diff --git a/backend/.sqlx/query-883be05a1a02dde8d9056094a4f17491791832d5bd206ab99a8b96ad30a0a3f5.json b/backend/.sqlx/query-883be05a1a02dde8d9056094a4f17491791832d5bd206ab99a8b96ad30a0a3f5.json new file mode 100644 index 0000000000..870afd4671 --- /dev/null +++ b/backend/.sqlx/query-883be05a1a02dde8d9056094a4f17491791832d5bd206ab99a8b96ad30a0a3f5.json @@ -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" +} diff --git a/backend/.sqlx/query-8bd1c2f216b4c525888bb94f94e09c3c519eb4bccea78954cb25ff945807883c.json b/backend/.sqlx/query-8bd1c2f216b4c525888bb94f94e09c3c519eb4bccea78954cb25ff945807883c.json new file mode 100644 index 0000000000..ff3fb07cca --- /dev/null +++ b/backend/.sqlx/query-8bd1c2f216b4c525888bb94f94e09c3c519eb4bccea78954cb25ff945807883c.json @@ -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" +} diff --git a/backend/.sqlx/query-9d6375303333eab6424dfba2b8fc4afb0d3fc87b79818b5087919a7ae604545f.json b/backend/.sqlx/query-9d6375303333eab6424dfba2b8fc4afb0d3fc87b79818b5087919a7ae604545f.json new file mode 100644 index 0000000000..b92ad418b7 --- /dev/null +++ b/backend/.sqlx/query-9d6375303333eab6424dfba2b8fc4afb0d3fc87b79818b5087919a7ae604545f.json @@ -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" +} diff --git a/backend/.sqlx/query-a3233c8a26f4ca47e957d55e77d9e3ccd51dcf9d259527e0b1f20d6e7f43f80b.json b/backend/.sqlx/query-a3233c8a26f4ca47e957d55e77d9e3ccd51dcf9d259527e0b1f20d6e7f43f80b.json new file mode 100644 index 0000000000..60f2c4ad34 --- /dev/null +++ b/backend/.sqlx/query-a3233c8a26f4ca47e957d55e77d9e3ccd51dcf9d259527e0b1f20d6e7f43f80b.json @@ -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" +} diff --git a/backend/.sqlx/query-a6a9a8013ac8ea8ecba8a39c3c0bfb0b5ccdd31740cd63f2d174b3cd1e18019d.json b/backend/.sqlx/query-a6a9a8013ac8ea8ecba8a39c3c0bfb0b5ccdd31740cd63f2d174b3cd1e18019d.json new file mode 100644 index 0000000000..c10a455d24 --- /dev/null +++ b/backend/.sqlx/query-a6a9a8013ac8ea8ecba8a39c3c0bfb0b5ccdd31740cd63f2d174b3cd1e18019d.json @@ -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" +} diff --git a/backend/.sqlx/query-a718367f00f7dc0280ab297ff66a939f930eb2a43beac528fd3b5fb6add5e251.json b/backend/.sqlx/query-a718367f00f7dc0280ab297ff66a939f930eb2a43beac528fd3b5fb6add5e251.json new file mode 100644 index 0000000000..eedd656a9a --- /dev/null +++ b/backend/.sqlx/query-a718367f00f7dc0280ab297ff66a939f930eb2a43beac528fd3b5fb6add5e251.json @@ -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" +} diff --git a/backend/.sqlx/query-ab07fa69343f1ab5e2c89911c314d027a05302085c838aa1c2d84406f9fffdb2.json b/backend/.sqlx/query-ab07fa69343f1ab5e2c89911c314d027a05302085c838aa1c2d84406f9fffdb2.json new file mode 100644 index 0000000000..1a0d20f9b6 --- /dev/null +++ b/backend/.sqlx/query-ab07fa69343f1ab5e2c89911c314d027a05302085c838aa1c2d84406f9fffdb2.json @@ -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" +} diff --git a/backend/.sqlx/query-acf96371f08812a15760436f43f05290ec39617e18e65f05fa4a9b002560a6d5.json b/backend/.sqlx/query-acf96371f08812a15760436f43f05290ec39617e18e65f05fa4a9b002560a6d5.json new file mode 100644 index 0000000000..995c9ab14f --- /dev/null +++ b/backend/.sqlx/query-acf96371f08812a15760436f43f05290ec39617e18e65f05fa4a9b002560a6d5.json @@ -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" +} diff --git a/backend/.sqlx/query-aefab48bd35a3bbfdb6d5a86d017acdd2d3759e33b1c75e54aee140d8db562d6.json b/backend/.sqlx/query-aefab48bd35a3bbfdb6d5a86d017acdd2d3759e33b1c75e54aee140d8db562d6.json new file mode 100644 index 0000000000..8a27afe088 --- /dev/null +++ b/backend/.sqlx/query-aefab48bd35a3bbfdb6d5a86d017acdd2d3759e33b1c75e54aee140d8db562d6.json @@ -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" +} diff --git a/backend/.sqlx/query-c22b71a3a40710e44e66aa48bda41de76b2cc0d0495c7d32f565d2548bf8c860.json b/backend/.sqlx/query-c22b71a3a40710e44e66aa48bda41de76b2cc0d0495c7d32f565d2548bf8c860.json new file mode 100644 index 0000000000..1bea38eb84 --- /dev/null +++ b/backend/.sqlx/query-c22b71a3a40710e44e66aa48bda41de76b2cc0d0495c7d32f565d2548bf8c860.json @@ -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" +} diff --git a/backend/.sqlx/query-c9513c08e03089a581a8bcc5178094fc42098ac26facbd010006ae06da284131.json b/backend/.sqlx/query-c9513c08e03089a581a8bcc5178094fc42098ac26facbd010006ae06da284131.json new file mode 100644 index 0000000000..26acaae551 --- /dev/null +++ b/backend/.sqlx/query-c9513c08e03089a581a8bcc5178094fc42098ac26facbd010006ae06da284131.json @@ -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" +} diff --git a/backend/.sqlx/query-c9d78891f8ccdfc12c8315653187cc8d2d4a2df257e5fd723016c6344174d4a1.json b/backend/.sqlx/query-c9d78891f8ccdfc12c8315653187cc8d2d4a2df257e5fd723016c6344174d4a1.json new file mode 100644 index 0000000000..c7fef53db0 --- /dev/null +++ b/backend/.sqlx/query-c9d78891f8ccdfc12c8315653187cc8d2d4a2df257e5fd723016c6344174d4a1.json @@ -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" +} diff --git a/backend/.sqlx/query-d24faa96edde6ff6cb7c443775552419ff11b6671a58e3aa0c75a7b48973437a.json b/backend/.sqlx/query-d24faa96edde6ff6cb7c443775552419ff11b6671a58e3aa0c75a7b48973437a.json new file mode 100644 index 0000000000..0cb9a640b5 --- /dev/null +++ b/backend/.sqlx/query-d24faa96edde6ff6cb7c443775552419ff11b6671a58e3aa0c75a7b48973437a.json @@ -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" +} diff --git a/backend/.sqlx/query-d5f94950c15ee592d5580cf001b37560f2093f9569a2d8a4a702004041976961.json b/backend/.sqlx/query-d5f94950c15ee592d5580cf001b37560f2093f9569a2d8a4a702004041976961.json new file mode 100644 index 0000000000..fd2e29667c --- /dev/null +++ b/backend/.sqlx/query-d5f94950c15ee592d5580cf001b37560f2093f9569a2d8a4a702004041976961.json @@ -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" +} diff --git a/backend/.sqlx/query-d7597022d6fc01601bff5f243fb38080fc6b65e95a3aad630c7abe3edcedbc98.json b/backend/.sqlx/query-d7597022d6fc01601bff5f243fb38080fc6b65e95a3aad630c7abe3edcedbc98.json new file mode 100644 index 0000000000..072e317008 --- /dev/null +++ b/backend/.sqlx/query-d7597022d6fc01601bff5f243fb38080fc6b65e95a3aad630c7abe3edcedbc98.json @@ -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" +} diff --git a/backend/.sqlx/query-d94140411f0ddefd74732e9a755249a044397b6fbfe164bc3dfd007ccd490388.json b/backend/.sqlx/query-d94140411f0ddefd74732e9a755249a044397b6fbfe164bc3dfd007ccd490388.json new file mode 100644 index 0000000000..ef1532c103 --- /dev/null +++ b/backend/.sqlx/query-d94140411f0ddefd74732e9a755249a044397b6fbfe164bc3dfd007ccd490388.json @@ -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" +} diff --git a/backend/.sqlx/query-d9b33bb1cf7894d5c61caa599975cc39b961191259bbccc233edca59bc7252b9.json b/backend/.sqlx/query-d9b33bb1cf7894d5c61caa599975cc39b961191259bbccc233edca59bc7252b9.json new file mode 100644 index 0000000000..ef8eb1982a --- /dev/null +++ b/backend/.sqlx/query-d9b33bb1cf7894d5c61caa599975cc39b961191259bbccc233edca59bc7252b9.json @@ -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" +} diff --git a/backend/.sqlx/query-d9b877b970b8e7e5d895d9b4f83ba945776b17c153b77cb2933864e9cd313439.json b/backend/.sqlx/query-d9b877b970b8e7e5d895d9b4f83ba945776b17c153b77cb2933864e9cd313439.json new file mode 100644 index 0000000000..69fe5d68df --- /dev/null +++ b/backend/.sqlx/query-d9b877b970b8e7e5d895d9b4f83ba945776b17c153b77cb2933864e9cd313439.json @@ -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" +} diff --git a/backend/.sqlx/query-de902e3eded6c244ea4b4594c30e32b9e86b1902bfa504d60f0b3bef8f1e8ea6.json b/backend/.sqlx/query-de902e3eded6c244ea4b4594c30e32b9e86b1902bfa504d60f0b3bef8f1e8ea6.json new file mode 100644 index 0000000000..07e885bdb6 --- /dev/null +++ b/backend/.sqlx/query-de902e3eded6c244ea4b4594c30e32b9e86b1902bfa504d60f0b3bef8f1e8ea6.json @@ -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" +} diff --git a/backend/.sqlx/query-df893ad32fd18b55d975247289d32fb80cbd8668c7c50c1f0b2ebd5faf50dce1.json b/backend/.sqlx/query-df893ad32fd18b55d975247289d32fb80cbd8668c7c50c1f0b2ebd5faf50dce1.json new file mode 100644 index 0000000000..ae3a1e200f --- /dev/null +++ b/backend/.sqlx/query-df893ad32fd18b55d975247289d32fb80cbd8668c7c50c1f0b2ebd5faf50dce1.json @@ -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" +} diff --git a/backend/.sqlx/query-e084010d1ff7492b77156c01bdaebae14f8c39ba0715fc1000fb8ff1bf78169f.json b/backend/.sqlx/query-e084010d1ff7492b77156c01bdaebae14f8c39ba0715fc1000fb8ff1bf78169f.json new file mode 100644 index 0000000000..83676487a7 --- /dev/null +++ b/backend/.sqlx/query-e084010d1ff7492b77156c01bdaebae14f8c39ba0715fc1000fb8ff1bf78169f.json @@ -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" +} diff --git a/backend/.sqlx/query-ececc8132f985b3e2d8301d5dc24356953e6e6cd71f28384b6f51231a35c7947.json b/backend/.sqlx/query-ececc8132f985b3e2d8301d5dc24356953e6e6cd71f28384b6f51231a35c7947.json new file mode 100644 index 0000000000..51f7848a28 --- /dev/null +++ b/backend/.sqlx/query-ececc8132f985b3e2d8301d5dc24356953e6e6cd71f28384b6f51231a35c7947.json @@ -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" +} diff --git a/backend/.sqlx/query-eeb0ef2be5db28fa722a977dfbecfe0dddc1d33b3f98c649cd91d9a3a5785e34.json b/backend/.sqlx/query-eeb0ef2be5db28fa722a977dfbecfe0dddc1d33b3f98c649cd91d9a3a5785e34.json new file mode 100644 index 0000000000..7577ef485a --- /dev/null +++ b/backend/.sqlx/query-eeb0ef2be5db28fa722a977dfbecfe0dddc1d33b3f98c649cd91d9a3a5785e34.json @@ -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" +} diff --git a/backend/.sqlx/query-eff07725675fa97c91f86564c13658316f5c3dff23704feca654dedc035adacf.json b/backend/.sqlx/query-eff07725675fa97c91f86564c13658316f5c3dff23704feca654dedc035adacf.json new file mode 100644 index 0000000000..e0ea48787d --- /dev/null +++ b/backend/.sqlx/query-eff07725675fa97c91f86564c13658316f5c3dff23704feca654dedc035adacf.json @@ -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" +} diff --git a/backend/.sqlx/query-f8c3c8cc0b3bb90adf611bab39f71cdd9a96e35508c852ab9c1dcbae3decbaba.json b/backend/.sqlx/query-f8c3c8cc0b3bb90adf611bab39f71cdd9a96e35508c852ab9c1dcbae3decbaba.json new file mode 100644 index 0000000000..2c79b75f3e --- /dev/null +++ b/backend/.sqlx/query-f8c3c8cc0b3bb90adf611bab39f71cdd9a96e35508c852ab9c1dcbae3decbaba.json @@ -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" +} diff --git a/backend/.sqlx/query-f8fa9ba552697292752e80a224e9c85bbed7cb0196c40927bf5a542416cf3b15.json b/backend/.sqlx/query-f8fa9ba552697292752e80a224e9c85bbed7cb0196c40927bf5a542416cf3b15.json new file mode 100644 index 0000000000..e515a1d1b1 --- /dev/null +++ b/backend/.sqlx/query-f8fa9ba552697292752e80a224e9c85bbed7cb0196c40927bf5a542416cf3b15.json @@ -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" +} diff --git a/backend/.sqlx/query-fbd24c0bc6c364d643f81f0a1baa4db6beaa12b133932ce0fd6c16a771a5a594.json b/backend/.sqlx/query-fbd24c0bc6c364d643f81f0a1baa4db6beaa12b133932ce0fd6c16a771a5a594.json new file mode 100644 index 0000000000..673e7cf2d7 --- /dev/null +++ b/backend/.sqlx/query-fbd24c0bc6c364d643f81f0a1baa4db6beaa12b133932ce0fd6c16a771a5a594.json @@ -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" +} diff --git a/backend/migrations/20260727093955_runnable_owner_prefix_indexes.down.sql b/backend/migrations/20260727093955_runnable_owner_prefix_indexes.down.sql new file mode 100644 index 0000000000..9196be958c --- /dev/null +++ b/backend/migrations/20260727093955_runnable_owner_prefix_indexes.down.sql @@ -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; diff --git a/backend/migrations/20260727093955_runnable_owner_prefix_indexes.up.sql b/backend/migrations/20260727093955_runnable_owner_prefix_indexes.up.sql new file mode 100644 index 0000000000..ff7484eeda --- /dev/null +++ b/backend/migrations/20260727093955_runnable_owner_prefix_indexes.up.sql @@ -0,0 +1,24 @@ +-- Indexes backing the homepage tree's per-owner runnable counts +-- (/runnables/counts), which counts each `f/` / `u/` 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); diff --git a/backend/tests/fixtures/jobs_read_auth.sql b/backend/tests/fixtures/jobs_read_auth.sql index 456ac8c801..03ee69b1f4 100644 --- a/backend/tests/fixtures/jobs_read_auth.sql +++ b/backend/tests/fixtures/jobs_read_auth.sql @@ -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'); diff --git a/backend/tests/jobs_read_auth.rs b/backend/tests/jobs_read_auth.rs index 1eab0bffee..e95748d8de 100644 --- a/backend/tests/jobs_read_auth.rs +++ b/backend/tests/jobs_read_auth.rs @@ -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) -> 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) -> 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(()) } diff --git a/backend/tests/runnables_owner_counts.rs b/backend/tests/runnables_owner_counts.rs new file mode 100644 index 0000000000..853d0eca17 --- /dev/null +++ b/backend/tests/runnables_owner_counts.rs @@ -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::>().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 { + let base = format!("http://localhost:{port}/api/w/test-workspace/runnables/list"); + let mut out: HashMap = HashMap::new(); + let mut cursor: Option = 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 { + 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 { + counts_endpoint_q(port, token, "").await +} + +async fn insert_script( + db: &Pool, + 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) -> 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(()) +} diff --git a/backend/windmill-ai/src/ai_providers.rs b/backend/windmill-ai/src/ai_providers.rs index 551ba3129b..216d10bab9 100644 --- a/backend/windmill-ai/src/ai_providers.rs +++ b/backend/windmill-ai/src/ai_providers.rs @@ -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 `/` 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. diff --git a/backend/windmill-ai/src/providers/anthropic.rs b/backend/windmill-ai/src/providers/anthropic.rs index a255f47a6d..52ca0085f9 100644 --- a/backend/windmill-ai/src/providers/anthropic.rs +++ b/backend/windmill-ai/src/providers/anthropic.rs @@ -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")); } } diff --git a/backend/windmill-ai/src/proxy.rs b/backend/windmill-ai/src/proxy.rs index fe4a0229d4..99c6188d3c 100644 --- a/backend/windmill-ai/src/proxy.rs +++ b/backend/windmill-ai/src/proxy.rs @@ -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 ` +/// 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 + '_ { + 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 { 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![&( + "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 = [ diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index 59d9cd80b3..5219b41e9b 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -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> = Cache::new(500); + static ref TOKEN_EMAIL_CACHE: Cache, 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 { 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 { .flatten() .flatten(); // email column is nullable, so we get Option> - TOKEN_EMAIL_CACHE.insert(t_hash, email.clone()); + TOKEN_EMAIL_CACHE.insert(t_hash, (email.clone(), std::time::Instant::now())); email } diff --git a/backend/windmill-api-integration-tests/tests/users.rs b/backend/windmill-api-integration-tests/tests/users.rs index db711c24e0..7b3457cde1 100644 --- a/backend/windmill-api-integration-tests/tests/users.rs +++ b/backend/windmill-api-integration-tests/tests/users.rs @@ -511,3 +511,152 @@ async fn test_user_endpoints(db: Pool) -> anyhow::Result<()> { Ok(()) } + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_change_user_email(db: Pool) -> 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(()) +} diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 8351ec0d2f..3d474ff4b1 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -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(()) } diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 0bf133ab79..2c739f4fbd 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -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, + Extension(db): Extension, + Json(ce): Json, +) -> Result { + 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 = std::env::var("NEW_USER_WEBHOOK").ok(); diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 9089b6e8b6..c8f67c9d2e 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -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))) diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 19494c41fc..8755bc0a05 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -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(()) } diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index c8b4638e99..0687a73194 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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/ or u/) to count + additionalProperties: + type: integer /w/{workspace}/flows/list: get: summary: list all flows diff --git a/backend/windmill-api/src/db.rs b/backend/windmill-api/src/db.rs index aa94fd80c4..9f3cb5892b 100644 --- a/backend/windmill-api/src/db.rs +++ b/backend/windmill-api/src/db.rs @@ -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(); } diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 4ee13226c5..f030aaa3d1 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -526,27 +526,17 @@ async fn cancel_job_api( OptAuthed(opt_authed): OptAuthed, opt_tokened: OptTokened, Extension(db): Extension, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, Json(CancelJob { reason }): Json, ) -> error::Result { - // 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 { + 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, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, Json(CancelJob { reason }): Json, ) -> error::Result { + // 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() { diff --git a/backend/windmill-api/src/runnables.rs b/backend/windmill-api/src/runnables.rs index 02d28f06e9..d89f8b6875 100644 --- a/backend/windmill-api/src/runnables.rs +++ b/backend/windmill-api/src/runnables.rs @@ -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, +) -> Option { + match build_scope_path_filter(authed, domain, "read") { + ScopePathFilter::AllowAll => None, + ScopePathFilter::Restricted { exact, prefix } => { + let mut terms: Vec = 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| -> Option { - match filter { - ScopePathFilter::AllowAll => None, - ScopePathFilter::Restricted { exact, prefix } => { - let mut terms: Vec = 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, + /// Include library scripts (no runnable main). Ignored for flows/apps. + include_without_main: Option, +} + +#[derive(Serialize)] +struct RunnableCountsResponse { + /// Owner prefix (`f/` or `u/`) -> 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, +} + +#[derive(sqlx::FromRow)] +struct OwnerCount { + owner: String, + count: i64, +} + +/// A byte-ordered range covering exactly the paths under `o.owner`: any +/// `owner/` is >= `owner || '/'` and, since '/' (0x2F) is immediately +/// followed by '0' (0x30), < `owner || '0'`. `~>=~` / `~<~` are the +/// text_pattern_ops operators, which is what lets `idx__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, + Path(w_id): Path, + Query(q): Query, +) -> JsonResult { + 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| -> Vec { + 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 { + 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 = 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 = vec![]; + let branches: Vec = 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 = vec![]; + let terms: Vec = 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 = 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 = vec![]; + let branches: Vec = 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 })) +} diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index e45bba05c3..e2c3233183 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -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 } diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 08a4ea93ed..1eda7503af 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -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>, +) -> 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!({ diff --git a/backend/windmill-common/src/query_builders.rs b/backend/windmill-common/src/query_builders.rs index 063d3958de..c0eedadbc4 100644 --- a/backend/windmill-common/src/query_builders.rs +++ b/backend/windmill-common/src/query_builders.rs @@ -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, +) -> Vec { + 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 { @@ -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() diff --git a/backend/windmill-common/src/users.rs b/backend/windmill-common/src/users.rs index 738dc84d14..1837e4dc4b 100644 --- a/backend/windmill-common/src/users.rs +++ b/backend/windmill-common/src/users.rs @@ -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()]; diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 135b8b9558..fb73ba7e2c 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -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; diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 8605bca374..507cb0420b 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -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::>(); // `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()); } diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 0f9f73ad1d..77bdff64e0 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -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) {{ diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 82fe63719b..c699e3d58f 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -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]; diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index f10195670a..3e6dd464fc 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -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. diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index b85ae6fec9..f28d1c67ad 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -5185,16 +5185,17 @@ value: - \`flow_input.property\` - Access flow input parameters - \`results.step_id\` - Access output from a previous step only when that step result is in scope - \`results.step_id.property\` - Access specific property from a previous step output only when that step result is in scope -- \`flow_input.iter.value\` - Current iteration value when inside a loop (\`forloopflow\` or \`whileloopflow\`) +- \`flow_input.iter.value\` - Current iteration value inside a \`forloopflow\`; in a \`whileloopflow\` it is just the iteration index (a plain number, same as \`flow_input.iter.index\`) - \`flow_input.iter.index\` - Current loop index when inside a loop (\`forloopflow\` or \`whileloopflow\`) ## Loop Structure Rules -- For \`whileloopflow\`, use module-level \`stop_after_if\` on the loop module itself when the loop should stop after an iteration result -- Do NOT put \`stop_after_if\` inside \`value\` of a \`whileloopflow\` +- For \`whileloopflow\`, break the loop with a module-level \`stop_after_if\`: on the loop module itself, or on an inner step (required when that step carries state via its own \`results\` — see below) +- \`stop_after_if\` is always a sibling of \`id\` and \`value\` on a flow module — never a direct key of the loop's \`value\` object - \`stop_after_all_iters_if\` is for checks after the whole loop finishes, not the normal per-iteration break condition -- When a \`whileloopflow\` carries state forward between iterations, use \`flow_input.iter.value\` as the current loop value and provide an explicit first-iteration fallback when needed -- Use \`flow_input.iter.index\` only when the loop logic is truly based on the iteration index, not as a replacement for the current loop value +- \`flow_input.iter.value\` in a \`whileloopflow\` is just the iteration index (same number as \`flow_input.iter.index\`) — it never carries state, so \`flow_input.iter.value.\` is always undefined and a loop whose stop condition depends on it never terminates +- To carry state across iterations, a step reads its own previous-iteration result via \`results.\` with a first-iteration fallback (e.g. \`results.b ?? flow_input.start\`) — but then the loop's \`stop_after_if\` MUST sit on that inner step, not on the loop module: a body that is exactly one plain step with the stop condition on the loop module runs on a fast path where \`results.\` is null on 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 state that is just a counter, derive it from the index instead (e.g. \`flow_input.iter.index + 1\`) — that works in every configuration, including with \`stop_after_if\` on the loop module - If the user asks for a final scalar/object after a loop, add a normal step after the loop that extracts the final value from the loop result instead of returning the whole loop result array Correct \`whileloopflow\` shape: @@ -5212,9 +5213,9 @@ Correct \`whileloopflow\` shape: value: type: rawscript input_transforms: - state: + count: type: javascript - expr: flow_input.iter && flow_input.iter.value !== undefined ? flow_input.iter.value : flow_input.initial_state + expr: flow_input.iter.index + 1 - id: return_final_state value: type: rawscript @@ -5224,6 +5225,26 @@ Correct \`whileloopflow\` shape: expr: results.loop_until_done[results.loop_until_done.length - 1] \`\`\` +Correct \`whileloopflow\` shape carrying state via \`results\` (stop condition on the inner step): + +\`\`\`yaml +- id: loop_until_done + value: + type: whileloopflow + skip_failures: false + modules: + - id: advance_state + stop_after_if: + expr: result.done === true + skip_if_stopped: false + value: + type: rawscript + input_transforms: + state: + type: javascript + expr: results.advance_state ?? flow_input.initial_state +\`\`\` + Incorrect \`whileloopflow\` patterns: \`\`\`yaml @@ -5238,7 +5259,8 @@ Incorrect \`whileloopflow\` patterns: input_transforms: state: type: javascript - expr: flow_input.iter.index + # iter.value is a number (the iteration index); there is no previous-iteration state + expr: flow_input.iter.value.count \`\`\` \`\`\`yaml @@ -5432,7 +5454,7 @@ Reference a specific resource using \`$res:\` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`, +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`, "raw-app": `--- name: raw-app description: MUST use when creating raw apps. diff --git a/cli/test/script_modules_unit.test.ts b/cli/test/script_modules_unit.test.ts index c797111e00..501d384141 100644 --- a/cli/test/script_modules_unit.test.ts +++ b/cli/test/script_modules_unit.test.ts @@ -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" + ); + }); +}); diff --git a/cli/test/sync_push_script_metadata_only.test.ts b/cli/test/sync_push_script_metadata_only.test.ts new file mode 100644 index 0000000000..5ef01ae049 --- /dev/null +++ b/cli/test/sync_push_script_metadata_only.test.ts @@ -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); + } + }); + }, +); diff --git a/frontend/src/lib/components/ChangeInstanceEmailInner.svelte b/frontend/src/lib/components/ChangeInstanceEmailInner.svelte new file mode 100644 index 0000000000..704908afa4 --- /dev/null +++ b/frontend/src/lib/components/ChangeInstanceEmailInner.svelte @@ -0,0 +1,101 @@ + + +
+ Email + { + 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} +
+ You cannot change your own email — ask another superadmin. +
+ {/if} + + {#if changed && !isSelf} + + 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. +
+
+ 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. +
+
+ 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. +
+ {/if} + + +
diff --git a/frontend/src/lib/components/InstanceNameEditor.svelte b/frontend/src/lib/components/InstanceNameEditor.svelte index cb710cddce..3b5cce4945 100644 --- a/frontend/src/lib/components/InstanceNameEditor.svelte +++ b/frontend/src/lib/components/InstanceNameEditor.svelte @@ -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()}
+ dispatch('refresh')} + /> {#if automateUsernameCreation && username} {/if} diff --git a/frontend/src/lib/components/copilot/chat/flow/core.ts b/frontend/src/lib/components/copilot/chat/flow/core.ts index ff4143fe98..2e6fea35a7 100644 --- a/frontend/src/lib/components/copilot/chat/flow/core.ts +++ b/frontend/src/lib/components/copilot/chat/flow/core.ts @@ -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.\` 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.\` 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({ diff --git a/frontend/src/lib/components/copilot/chat/flow/openFlow.json b/frontend/src/lib/components/copilot/chat/flow/openFlow.json index 539a0e89e5..110cc49301 100644 --- a/frontend/src/lib/components/copilot/chat/flow/openFlow.json +++ b/frontend/src/lib/components/copilot/chat/flow/openFlow.json @@ -1 +1 @@ -{"openapi":"3.0.3","info":{"version":"1.756.0","title":"OpenFlow Spec","contact":{"name":"Ruben Fiszel","email":"ruben@windmill.dev","url":"https://windmill.dev"},"license":{"name":"Apache 2.0","url":"https://www.apache.org/licenses/LICENSE-2.0.html"}},"paths":{},"externalDocs":{"description":"documentation portal","url":"https://windmill.dev"},"components":{"schemas":{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution — modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind"}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type"}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw",null]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw",null]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}]},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}}} \ No newline at end of file +{"openapi":"3.0.3","info":{"version":"1.765.0","title":"OpenFlow Spec","contact":{"name":"Ruben Fiszel","email":"ruben@windmill.dev","url":"https://windmill.dev"},"license":{"name":"Apache 2.0","url":"https://www.apache.org/licenses/LICENSE-2.0.html"}},"paths":{},"externalDocs":{"description":"documentation portal","url":"https://windmill.dev"},"components":{"schemas":{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution — modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind"}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type"}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw",null]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw",null]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}]},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}}} \ No newline at end of file diff --git a/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts b/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts index 5a30bedf78..cce01b6244 100644 --- a/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts +++ b/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts @@ -1,6 +1,6 @@ import { z } from "zod" -export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.enum(["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in each iteration. Use stop_after_if to control when the loop ends"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("Automatic context management"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Explicit message history")]).describe("Conversation memory configuration"), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("Short description of what this tool does (shown to the AI)").optional(), "description": z.string().describe("Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script.").optional(), "value": z.any().superRefine((x, ctx) => { +export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.enum(["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in each iteration"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("Automatic context management"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Explicit message history")]).describe("Conversation memory configuration"), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("Short description of what this tool does (shown to the AI)").optional(), "description": z.string().describe("Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script.").optional(), "value": z.any().superRefine((x, ctx) => { const schemas = [z.intersection(z.object({ "tool_type": z.literal("flowmodule") }), z.lazy(() => flowModuleValueSchema)).describe("A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module"), z.object({ "tool_type": z.literal("mcp"), "resource_path": z.string().describe("Path to the MCP resource/server configuration"), "include_tools": z.array(z.string()).describe("Whitelist of specific tools to include from this MCP server").optional(), "exclude_tools": z.array(z.string()).describe("Blacklist of tools to exclude from this MCP server").optional() }).describe("Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers"), z.object({ "tool_type": z.literal("websearch") }).describe("A tool implemented as a websearch tool. The AI can call this like any other websearch tool")]; const errors = schemas.reduce( (errors, schema) => @@ -20,7 +20,7 @@ export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "i }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task"), "type": z.literal("aiagent"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)").optional(), "omit_output_from_conversation": z.boolean().describe("If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.").default(false), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type") -export const flowModuleSchema = z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.enum(["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in each iteration. Use stop_after_if to control when the loop ends"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("Automatic context management"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Explicit message history")]).describe("Conversation memory configuration"), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("Short description of what this tool does (shown to the AI)").optional(), "description": z.string().describe("Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script.").optional(), "value": z.any().superRefine((x, ctx) => { +export const flowModuleSchema = z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.enum(["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in each iteration"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("Automatic context management"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Explicit message history")]).describe("Conversation memory configuration"), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("Short description of what this tool does (shown to the AI)").optional(), "description": z.string().describe("Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script.").optional(), "value": z.any().superRefine((x, ctx) => { const schemas = [z.intersection(z.object({ "tool_type": z.literal("flowmodule") }), z.lazy(() => flowModuleValueSchema)).describe("A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module"), z.object({ "tool_type": z.literal("mcp"), "resource_path": z.string().describe("Path to the MCP resource/server configuration"), "include_tools": z.array(z.string()).describe("Whitelist of specific tools to include from this MCP server").optional(), "exclude_tools": z.array(z.string()).describe("Blacklist of tools to exclude from this MCP server").optional() }).describe("Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers"), z.object({ "tool_type": z.literal("websearch") }).describe("A tool implemented as a websearch tool. The AI can call this like any other websearch tool")]; const errors = schemas.reduce( (errors, schema) => @@ -37,7 +37,7 @@ export const flowModuleSchema = z.object({ "id": z.string().describe("Unique ide message: "Invalid input: Should pass single schema", }); } - }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task"), "type": z.literal("aiagent"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)").optional(), "omit_output_from_conversation": z.boolean().describe("If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.").default(false), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type"), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch") + }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task"), "type": z.literal("aiagent"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)").optional(), "omit_output_from_conversation": z.boolean().describe("If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.").default(false), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type"), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch") export const flowModulesSchema = z.array(flowModuleSchema) diff --git a/frontend/src/lib/components/details/createAppFromScript.ts b/frontend/src/lib/components/details/createAppFromScript.ts deleted file mode 100644 index 07308dbf20..0000000000 --- a/frontend/src/lib/components/details/createAppFromScript.ts +++ /dev/null @@ -1,1098 +0,0 @@ -import { ccomponents } from '../apps/editor/component' - -export function createAppFromScript(path: string, schema: Record | undefined) { - return { - grid: [ - { - '3': { - fixed: false, - x: 0, - y: 2, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 2, - w: 12, - h: 21, - fullHeight: false - }, - data: { - type: 'verticalsplitpanescomponent', - configuration: {}, - panes: [50, 50], - customCss: structuredClone(ccomponents['verticalsplitpanescomponent'].customCss), - numberOfSubgrids: 2, - id: 'a' - }, - id: 'a' - }, - { - '3': { - fixed: false, - x: 0, - y: 8, - fullHeight: false, - w: 6, - h: 2 - }, - '12': { - fixed: false, - x: 0, - y: 0, - fullHeight: false, - w: 12, - h: 2 - }, - data: { - type: 'containercomponent', - configuration: {}, - customCss: { - container: { - class: '!p-0', - style: '' - } - }, - actions: undefined, - numberOfSubgrids: 1, - id: 'topbar' - }, - id: 'topbar' - } - ], - fullscreen: false, - unusedInlineScripts: [], - hiddenInlineScripts: [], - css: {}, - norefreshbar: false, - hideLegacyTopBar: true, - subgrids: { - 'a-0': [ - { - '3': { - fixed: false, - x: 0, - y: 1, - w: 3, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 19, - fullHeight: false - }, - data: { - type: 'schemaformcomponent', - configuration: { - displayType: { - type: 'static', - value: false - }, - largeGap: { - type: 'static', - value: false - } - }, - componentInput: { - type: 'static', - fieldType: 'schema', - value: schema - }, - customCss: structuredClone(ccomponents['schemaformcomponent'].customCss), - id: 'c' - }, - id: 'c' - }, - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 1, - h: 1, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 19, - w: 12, - h: 1, - fullHeight: false - }, - data: { - type: 'buttoncomponent', - configuration: { - label: { - type: 'static', - value: 'Submit' - }, - color: { - type: 'static', - value: 'dark' - }, - size: { - type: 'static', - value: 'xs' - }, - fillContainer: { - type: 'static', - value: false - }, - disabled: { - type: 'evalv2', - expr: '!c.valid', - connections: [ - { - componentId: 'c', - id: 'valid' - } - ] - }, - beforeIcon: { - type: 'static', - value: '' - }, - afterIcon: { - type: 'static', - value: '' - }, - triggerOnAppLoad: { - type: 'static', - value: false - }, - onSuccess: { - type: 'oneOf', - selected: 'none', - configuration: { - none: {}, - gotoUrl: { - url: { - type: 'static', - value: '' - }, - newTab: { - type: 'static', - value: true - } - }, - setTab: { - setTab: { - type: 'static', - value: [] - } - }, - sendToast: { - message: { - type: 'static', - value: '' - } - }, - openModal: { - modalId: { - type: 'static', - value: '' - } - }, - closeModal: { - modalId: { - type: 'static', - value: '' - } - }, - open: { - id: { - type: 'static', - value: '' - } - }, - close: { - id: { - type: 'static', - value: '' - } - } - } - }, - onError: { - type: 'oneOf', - selected: 'errorOverlay', - configuration: { - errorOverlay: {}, - gotoUrl: { - url: { - type: 'static', - value: '' - }, - newTab: { - type: 'static', - value: true - } - }, - setTab: { - setTab: { - type: 'static', - value: [] - } - }, - sendErrorToast: { - message: { - type: 'static', - value: '' - }, - appendError: { - type: 'static', - value: true - } - }, - openModal: { - modalId: { - type: 'static', - value: '' - } - }, - closeModal: { - modalId: { - type: 'static', - value: '' - } - }, - open: { - id: { - type: 'static', - value: '' - } - }, - close: { - id: { - type: 'static', - value: '' - } - } - } - } - }, - componentInput: { - type: 'runnable', - fieldType: 'any', - fields: convertSchemaToFields(schema), - runnable: { - type: 'path', - path: path, - runType: 'script', - schema: schema, - name: path - }, - autoRefresh: true, - recomputeOnInputChanged: true - }, - customCss: structuredClone(ccomponents['buttoncomponent'].customCss), - recomputeIds: [], - horizontalAlignment: 'right', - verticalAlignment: 'center', - id: 'd' - }, - id: 'd' - } - ], - 'a-1': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 20, - fullHeight: false - }, - data: { - type: 'tabscomponent', - configuration: { - tabsKind: { - type: 'static', - value: 'tabs' - } - }, - tabs: ['Result', 'Logs'], - customCss: structuredClone(ccomponents['tabscomponent'].customCss), - numberOfSubgrids: 2, - id: 'b', - disabledTabs: [ - { - type: 'static', - value: false, - fieldType: 'boolean' - }, - { - type: 'static', - value: false, - fieldType: 'boolean' - } - ] - }, - id: 'b' - } - ], - 'b-0': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 18, - fullHeight: false - }, - data: { - type: 'displaycomponent', - configuration: {}, - componentInput: { - type: 'connected', - fieldType: 'object', - connection: { - componentId: 'd', - path: 'result' - } - }, - customCss: structuredClone(ccomponents['displaycomponent'].customCss), - id: 'e' - }, - id: 'e' - } - ], - 'b-1': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 18, - fullHeight: false - }, - data: { - type: 'jobidlogcomponent', - configuration: { - jobId: { - type: 'connected', - connection: { - componentId: 'd', - path: 'jobId' - } - } - }, - customCss: structuredClone(ccomponents['jobidlogcomponent'].customCss), - id: 'f' - }, - id: 'f' - } - ], - 'topbar-0': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - fullHeight: false, - w: 6, - h: 1 - }, - '12': { - fixed: false, - x: 0, - y: 0, - fullHeight: false, - w: 6, - h: 1 - }, - data: { - type: 'textcomponent', - configuration: { - style: { - type: 'static', - value: 'Body' - }, - copyButton: { - type: 'static', - value: false - }, - tooltip: { - type: 'evalv2', - value: '', - fieldType: 'text', - expr: '`Author: ${ctx.author}`', - connections: [ - { - componentId: 'ctx', - id: 'author' - } - ] - }, - disableNoText: { - type: 'static', - value: true, - fieldType: 'boolean' - } - }, - componentInput: { - type: 'templatev2', - fieldType: 'template', - eval: '${ctx.summary}', - connections: [ - { - id: 'summary', - componentId: 'ctx' - } - ] - }, - customCss: { - text: { - class: 'text-xl font-semibold whitespace-nowrap truncate', - style: '' - }, - container: { - class: '', - style: '' - } - }, - actions: undefined, - horizontalAlignment: 'left', - verticalAlignment: 'center', - id: 'title' - }, - id: 'title' - }, - { - '3': { - fixed: false, - x: 0, - y: 1, - fullHeight: false, - w: 3, - h: 1 - }, - '12': { - fixed: false, - x: 6, - y: 0, - fullHeight: false, - w: 6, - h: 1 - }, - data: { - type: 'recomputeallcomponent', - configuration: {}, - customCss: { - container: { - style: '', - class: '' - } - }, - menuItems: [], - horizontalAlignment: 'right', - verticalAlignment: 'center', - id: 'recomputeall' - }, - id: 'recomputeall' - } - ] - } - } -} - -type Field = { - type: 'static' | 'connected' - value: any - fieldType?: string - format?: string - connection?: { - componentId: string - path: string - } - allowUserResources?: boolean -} - -function convertSchemaToFields(schema: Record | undefined): { [key: string]: Field } { - const fields: { [key: string]: Field } = {} - - if (!schema) { - return fields - } - - Object.entries(schema.properties).forEach(([fieldName, fieldInfo]: [string, any]) => { - fields[fieldName] = { - type: 'connected', - value: fieldInfo.default, - fieldType: fieldInfo.type, - format: fieldInfo.format, - allowUserResources: true, - connection: { - componentId: 'c', - path: `values.${fieldName}` - } - } - }) - - return fields -} - -export function createAppFromFlow(path: string, schema: Record | undefined) { - return { - grid: [ - { - '3': { - fixed: false, - x: 0, - y: 2, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 2, - w: 12, - h: 21, - fullHeight: false - }, - data: { - type: 'verticalsplitpanescomponent', - configuration: {}, - panes: [50, 50], - customCss: structuredClone(ccomponents['verticalsplitpanescomponent'].customCss), - numberOfSubgrids: 2, - id: 'a' - }, - id: 'a' - }, - { - '3': { - fixed: true, - x: 0, - y: 8, - fullHeight: false, - w: 6, - h: 2 - }, - '12': { - fixed: true, - x: 0, - y: 0, - fullHeight: false, - w: 12, - h: 2 - }, - data: { - type: 'containercomponent', - configuration: {}, - customCss: { - container: { - class: '!p-0', - style: '' - } - }, - numberOfSubgrids: 1, - id: 'topbar' - }, - id: 'topbar' - } - ], - fullscreen: false, - unusedInlineScripts: [], - hiddenInlineScripts: [], - css: {}, - norefreshbar: false, - hideLegacyTopBar: true, - subgrids: { - 'a-0': [ - { - '3': { - fixed: false, - x: 0, - y: 1, - w: 3, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 19, - fullHeight: false - }, - data: { - type: 'schemaformcomponent', - configuration: { - displayType: { - type: 'static', - value: false - }, - largeGap: { - type: 'static', - value: false - } - }, - componentInput: { - type: 'static', - fieldType: 'schema', - value: schema - }, - customCss: structuredClone(ccomponents['schemaformcomponent'].customCss), - id: 'c' - }, - id: 'c' - }, - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 1, - h: 1, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 19, - w: 12, - h: 1, - fullHeight: false - }, - data: { - type: 'buttoncomponent', - configuration: { - label: { - type: 'static', - value: 'Submit' - }, - color: { - type: 'static', - value: 'dark' - }, - size: { - type: 'static', - value: 'xs' - }, - fillContainer: { - type: 'static', - value: false - }, - disabled: { - type: 'evalv2', - expr: '!c.valid', - connections: [ - { - componentId: 'c', - id: 'valid' - } - ] - }, - beforeIcon: { - type: 'static', - value: '' - }, - afterIcon: { - type: 'static', - value: '' - }, - triggerOnAppLoad: { - type: 'static', - value: false - }, - onSuccess: { - type: 'oneOf', - selected: 'none', - configuration: { - none: {}, - gotoUrl: { - url: { - type: 'static', - value: '' - }, - newTab: { - type: 'static', - value: true - } - }, - setTab: { - setTab: { - type: 'static', - value: [] - } - }, - sendToast: { - message: { - type: 'static', - value: '' - } - }, - openModal: { - modalId: { - type: 'static', - value: '' - } - }, - closeModal: { - modalId: { - type: 'static', - value: '' - } - }, - open: { - id: { - type: 'static', - value: '' - } - }, - close: { - id: { - type: 'static', - value: '' - } - } - } - }, - onError: { - type: 'oneOf', - selected: 'errorOverlay', - configuration: { - errorOverlay: {}, - gotoUrl: { - url: { - type: 'static', - value: '' - }, - newTab: { - type: 'static', - value: true - } - }, - setTab: { - setTab: { - type: 'static', - value: [] - } - }, - sendErrorToast: { - message: { - type: 'static', - value: '' - }, - appendError: { - type: 'static', - value: true - } - }, - openModal: { - modalId: { - type: 'static', - value: '' - } - }, - closeModal: { - modalId: { - type: 'static', - value: '' - } - }, - open: { - id: { - type: 'static', - value: '' - } - }, - close: { - id: { - type: 'static', - value: '' - } - } - } - } - }, - componentInput: { - type: 'runnable', - fieldType: 'any', - fields: convertSchemaToFields(schema), - runnable: { - type: 'path', - path: path, - runType: 'flow', - schema: schema, - name: path - }, - autoRefresh: false, - recomputeOnInputChanged: false - }, - customCss: structuredClone(ccomponents['buttoncomponent'].customCss), - recomputeIds: [], - horizontalAlignment: 'right', - verticalAlignment: 'center', - id: 'd' - }, - id: 'd' - } - ], - 'a-1': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 20, - fullHeight: false - }, - data: { - type: 'tabscomponent', - configuration: { - tabsKind: { - type: 'static', - value: 'tabs' - } - }, - tabs: ['Result', 'Logs'], - customCss: structuredClone(ccomponents['tabscomponent'].customCss), - numberOfSubgrids: 2, - id: 'b', - disabledTabs: [ - { - type: 'static', - value: false, - fieldType: 'boolean' - }, - { - type: 'static', - value: false, - fieldType: 'boolean' - } - ] - }, - id: 'b' - } - ], - 'b-0': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 18, - fullHeight: false - }, - data: { - type: 'jobidflowstatuscomponent', - configuration: { - jobId: { - type: 'connected', - value: '', - connection: { - componentId: 'd', - path: 'jobId' - } - } - }, - customCss: structuredClone(ccomponents['jobidflowstatuscomponent'].customCss), - id: 'e' - }, - id: 'e' - } - ], - 'b-1': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 18, - fullHeight: false - }, - data: { - type: 'jobidlogcomponent', - configuration: { - jobId: { - type: 'connected', - connection: { - componentId: 'd', - path: 'jobId' - } - } - }, - customCss: structuredClone(ccomponents['jobidlogcomponent'].customCss), - id: 'f' - }, - id: 'f' - } - ], - 'topbar-0': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - fullHeight: false, - w: 6, - h: 1 - }, - '12': { - fixed: false, - x: 0, - y: 0, - fullHeight: false, - w: 6, - h: 1 - }, - data: { - type: 'textcomponent', - configuration: { - style: { - type: 'static', - value: 'Body' - }, - copyButton: { - type: 'static', - value: false - }, - tooltip: { - type: 'evalv2', - value: '', - fieldType: 'text', - expr: '`Author: ${ctx.author}`', - connections: [ - { - componentId: 'ctx', - id: 'author' - } - ] - }, - disableNoText: { - type: 'static', - value: true, - fieldType: 'boolean' - } - }, - componentInput: { - type: 'templatev2', - fieldType: 'template', - eval: '${ctx.summary}', - connections: [ - { - id: 'summary', - componentId: 'ctx' - } - ] - }, - customCss: { - text: { - class: 'text-xl font-semibold whitespace-nowrap truncate', - style: '' - }, - container: { - class: '', - style: '' - } - }, - horizontalAlignment: 'left', - verticalAlignment: 'center', - id: 'title' - }, - id: 'title' - }, - { - '3': { - fixed: false, - x: 0, - y: 1, - fullHeight: false, - w: 3, - h: 1 - }, - '12': { - fixed: false, - x: 6, - y: 0, - fullHeight: false, - w: 6, - h: 1 - }, - data: { - type: 'recomputeallcomponent', - configuration: {}, - customCss: { - container: { - style: '', - class: '' - } - }, - menuItems: [], - horizontalAlignment: 'right', - verticalAlignment: 'center', - id: 'recomputeall' - }, - id: 'recomputeall' - } - ] - } - } -} diff --git a/frontend/src/lib/components/details/createRawAppFromScript.test.ts b/frontend/src/lib/components/details/createRawAppFromScript.test.ts new file mode 100644 index 0000000000..a8f9a9e0e2 --- /dev/null +++ b/frontend/src/lib/components/details/createRawAppFromScript.test.ts @@ -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('') + }) + + 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(`(? !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 `` 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 `&` 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 | undefined): Field[] { + const properties: Record = 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 `` 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 ` ${field.setter}(e.target.checked)} + />` + case 'number': + return ` ${field.setter}(e.target.value)} + />` + case 'enum': + return `` + 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 `` + case 'json': + return `