From e7fc1b2e2e9b358b414d0ca0eaf2753e142dded7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 3 Oct 2026 09:33:44 +0200 Subject: [PATCH] feat: restricted job tokens per script and flow (#11484) * feat: restricted job tokens (job_token_scopes on scripts and flows) Co-Authored-By: Claude Opus 5.5 (1M context) * fix: admit flow-run reads, skip dedicated workers, gate on worker version Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep restricted jobs off flow runners, preserve scopes on rename and promotion Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep restricted jobs off every dedicated handoff, confine progress flow id Co-Authored-By: Claude Opus 5.5 (1M context) * fix: exclude restricted runnables from dedicated worker startup Co-Authored-By: Claude Opus 5.5 (1M context) * fix: gate restrictions on the release after 1.821.0 Co-Authored-By: Claude Opus 5.5 (1M context) * fix: store per-job scopes on job_perms instead of v2_job, pin inline runs to the checked version Co-Authored-By: Claude Opus 5.5 (1M context) * feat: step-level job_token_scopes for flow steps and agent tools Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * fix: fail closed on perms read errors, refuse restricted queue imports, gate step scopes in previews Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * fix: carry a job's scopes on its completion so a re-run keeps the caller's cap Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * fix: carry a zombie job's scopes into its completion Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * fix: leave a zombie for the next sweep when its scopes cannot be read Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * docs: correct the QueuedJobV2 completion comment Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * fix: validate step scopes in batch flows, fail closed on unvalidated step scopes Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * fix: refuse flows with step or tool restrictions at push while an older worker is live Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * fix: apply the step-scope worker gate to flow restarts Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * fix: list the job token toggle with the other step and flow settings Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * chore: pin the EE companion merged with EE main Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * perf: skip scope lookups for unrestricted jobs; list job token setting last Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * style: rustfmt scopes tests Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * fix: confine restricted job tokens to their own run lineage; drop remaining extra lookups Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * chore: update ee-repo-ref to 259ad3bfeef5285ba80eedc86309b11dca001220 This commit updates the EE repository reference after PR #843 was merged in windmill-ee-private. Previous ee-repo-ref: 2b77c0225dca441235daf7bf0a06ba968df0c927 New ee-repo-ref: 259ad3bfeef5285ba80eedc86309b11dca001220 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...cfca8913ba1129fdf4978a8a4738689786160.json | 19 + ...64f84a0f1a31cd6732c0ab994a2d6ac3c3eec.json | 152 ++++ ...7b17d6bb71f0ae69cd218e6a3fbc151d19d87.json | 23 + ...10a00333bbf3b8b43bbccc7a6e1926d82478c.json | 22 + ...d7ebd10a38cbe5724a554a12291c287b67f6d.json | 324 ++++++++ ...4585d9c2aba36fdf33bca9739049366bed107.json | 84 ++ ...96c69f22d9ea9dc3586099553a8f0e76aad82.json | 22 + ...fea97aec4d6d3ec65d3e7402811c7dddbbf64.json | 16 + ...d3abc2f67827597ef74fef473e309b1951544.json | 253 ++++++ ...b6c87f2994fa2d5f33e0f3ee9effe28614abe.json | 23 + ...cf6c41fb443f8bd91f12bce0e53f6e91bfb16.json | 331 ++++++++ ...51cbd7b8896984115d55318bb6f4100d72d9a.json | 24 + ...aa7f5755da9997419c4a9d07b30d4da318bea.json | 15 + ...20e1e07f7e50df82fb6c33f79e6faa5b77bab.json | 29 + ...cab7c9a4461d3bfbdc0fe7dbdd28ae2de924.json} | 10 +- ...f391947d5fc3da90ef98a7b793c2430eb7bc.json} | 10 +- ...64eb6af24e8d8aa99b846f21f2ed49c320bdc.json | 16 + ...8b8517561f5b55a30e44801d6a9123a762584.json | 23 + ...3007c6aeccf76bfd07384ffaccf50a24d13d4.json | 102 +++ ...28d84f1ca3c91de86d520dce06ac12d557bd5.json | 24 + ...affc87853ff2d97f567a8817260cfef43e00e.json | 65 ++ ...7099be925f1058c76c6af31e8a620607cf6a1.json | 30 + ...878286dcc2f23101c2e1c8c1d07b6f0deaecd.json | 159 ++++ ...737b3b4b662a89148cf6ac70eb3563ad7f0c5.json | 15 + ...d10fc10d9ec4dc2359f43d79fe255d101cad8.json | 15 + ...c26f01e117b75ab48e197c7d91d388865da74.json | 398 ++++++++++ ...084f90b3ac06139ffca98670215956150563a.json | 28 + backend/ee-repo-ref.txt | 2 +- .../20261002124918_job_token_scopes.down.sql | 3 + .../20261002124918_job_token_scopes.up.sql | 3 + backend/src/monitor.rs | 21 +- backend/summarized_schema.txt | 6 +- backend/tests/asset_trigger_dispatch.rs | 3 + backend/tests/batch_rerun.rs | 2 + backend/tests/bun_jobs.rs | 1 + backend/tests/caller_modules_strip.rs | 2 + backend/tests/dedicated_workers.rs | 38 + backend/tests/dependency_job_tag.rs | 1 + backend/tests/dependency_map.rs | 1 + backend/tests/error_handler.rs | 3 + backend/tests/flow_engine_parity.rs | 2 + backend/tests/job_payload.rs | 11 + backend/tests/job_token_scopes.rs | 354 +++++++++ backend/tests/list_jobs.rs | 3 + backend/tests/nativets_stress.rs | 1 + backend/tests/preview_native_tag.rs | 1 + backend/tests/python_jobs.rs | 1 + backend/tests/restart_at_step_api.rs | 4 + backend/tests/restart_flow_version_scope.rs | 1 + backend/tests/success_handler.rs | 1 + backend/tests/worker.rs | 9 + backend/windmill-api-auth/src/auth.rs | 40 +- backend/windmill-api-auth/src/lib.rs | 133 ++-- backend/windmill-api-auth/src/scopes.rs | 573 +++----------- backend/windmill-api-flows/src/flows.rs | 68 +- backend/windmill-api-jobs/src/execution.rs | 13 +- backend/windmill-api-jobs/src/job_metrics.rs | 23 + backend/windmill-api-jobs/src/jobs_export.rs | 21 + backend/windmill-api-scripts/src/scripts.rs | 58 +- .../src/datatable_migrations.rs | 2 + .../windmill-api-workspaces/src/workspaces.rs | 9 +- .../src/workspaces_extra.rs | 4 +- backend/windmill-api/openapi.yaml | 30 + backend/windmill-api/src/agent_runs.rs | 2 + backend/windmill-api/src/ai_evals/run.rs | 2 + backend/windmill-api/src/apps.rs | 7 + backend/windmill-api/src/apps_raw_bundle.rs | 2 + .../windmill-api/src/concurrency_groups.rs | 1 + backend/windmill-api/src/jobs.rs | 99 ++- backend/windmill-api/src/mcp/core.rs | 9 +- backend/windmill-api/src/offboarding.rs | 4 +- backend/windmill-api/src/users.rs | 4 +- backend/windmill-api/src/workspaces_export.rs | 5 +- backend/windmill-common/src/auth.rs | 32 +- backend/windmill-common/src/jobs.rs | 8 +- backend/windmill-common/src/lib.rs | 12 +- backend/windmill-common/src/min_version.rs | 4 + backend/windmill-common/src/scopes.rs | 748 ++++++++++++++++++ backend/windmill-common/src/scripts.rs | 6 +- backend/windmill-common/src/worker.rs | 2 +- .../src/trigger_dependents.rs | 1 + backend/windmill-queue/src/asset_dispatch.rs | 2 + backend/windmill-queue/src/jobs.rs | 187 +++-- backend/windmill-queue/src/schedule.rs | 8 +- backend/windmill-queue/tests/debounce_test.rs | 2 + .../windmill-queue/tests/native_retry_test.rs | 34 + backend/windmill-queue/tests/schedule_push.rs | 1 + backend/windmill-test-utils/src/lib.rs | 3 + .../windmill-trigger/src/trigger_helpers.rs | 1 + backend/windmill-types/src/flows.rs | 29 + backend/windmill-types/src/jobs.rs | 4 + backend/windmill-types/src/scripts.rs | 14 +- backend/windmill-worker/src/ai/tools.rs | 10 + backend/windmill-worker/src/ai_executor.rs | 5 + backend/windmill-worker/src/bun_executor.rs | 5 + backend/windmill-worker/src/common.rs | 1 + backend/windmill-worker/src/worker.rs | 38 +- backend/windmill-worker/src/worker_flow.rs | 35 +- .../windmill-worker/src/worker_lockfiles.rs | 1 + cli/src/commands/flow/flow.ts | 17 +- cli/src/commands/script/script.ts | 4 + cli/src/guidance/skills.gen.ts | 2 +- cli/windmill-utils-internal/src/deploy.ts | 7 + docs/auth-surface.md | 23 + .../src/lib/components/FlowBuilder.svelte | 8 +- .../components/JobTokenScopesSetting.svelte | 89 +++ .../src/lib/components/ScriptBuilder.svelte | 8 +- .../flows/content/FlowRunSettings.svelte | 10 + .../flows/content/FlowSettings.svelte | 9 + .../flows/header/FlowYamlEditor.svelte | 3 + frontend/src/lib/components/flows/types.ts | 1 + .../src/lib/components/flows/utils.svelte.ts | 4 + .../components/scripts/WacExportDrawer.svelte | 1 + .../components/settings/ScopeSelector.svelte | 7 +- frontend/src/lib/utils_draft_deploy.ts | 1 + openflow.openapi.yaml | 12 + system_prompts/auto-generated/flow.md | 2 +- system_prompts/auto-generated/prompts.ts | 2 +- .../auto-generated/skills/write-flow/SKILL.md | 2 +- 119 files changed, 4511 insertions(+), 709 deletions(-) create mode 100644 backend/.sqlx/query-05b610ad1809d487c32e85b0392cfca8913ba1129fdf4978a8a4738689786160.json create mode 100644 backend/.sqlx/query-095fa75c60724664f0355cd6e3a64f84a0f1a31cd6732c0ab994a2d6ac3c3eec.json create mode 100644 backend/.sqlx/query-10f166464a10fc35df2b6ff1b497b17d6bb71f0ae69cd218e6a3fbc151d19d87.json create mode 100644 backend/.sqlx/query-13f358b7c76cda0b24fcb17dbbe10a00333bbf3b8b43bbccc7a6e1926d82478c.json create mode 100644 backend/.sqlx/query-333f7116d660756c36dfc0aa2b8d7ebd10a38cbe5724a554a12291c287b67f6d.json create mode 100644 backend/.sqlx/query-4631200a37f7edec92f3796ab4d4585d9c2aba36fdf33bca9739049366bed107.json create mode 100644 backend/.sqlx/query-4cc81f5e04caefa4f170807c0ce96c69f22d9ea9dc3586099553a8f0e76aad82.json create mode 100644 backend/.sqlx/query-580271c5fe178f5e31f3e211cd7fea97aec4d6d3ec65d3e7402811c7dddbbf64.json create mode 100644 backend/.sqlx/query-5a975e486a0cdb0fae4dadd344ad3abc2f67827597ef74fef473e309b1951544.json create mode 100644 backend/.sqlx/query-60d53c73e67dd7b29cce910fd76b6c87f2994fa2d5f33e0f3ee9effe28614abe.json create mode 100644 backend/.sqlx/query-6445041033c95fcf52c79c33f34cf6c41fb443f8bd91f12bce0e53f6e91bfb16.json create mode 100644 backend/.sqlx/query-67db536d06c57f195641f178ce651cbd7b8896984115d55318bb6f4100d72d9a.json create mode 100644 backend/.sqlx/query-706f2227e34971d22a577b33f6aaa7f5755da9997419c4a9d07b30d4da318bea.json create mode 100644 backend/.sqlx/query-77e9cb16c25defc2423621a64f020e1e07f7e50df82fb6c33f79e6faa5b77bab.json rename backend/.sqlx/{query-1debd472c9ffd2fc78877484f93db51f9aabed54f9894eda8ad610053ad76ce6.json => query-83e9c830ee816d4e4e7193a67823cab7c9a4461d3bfbdc0fe7dbdd28ae2de924.json} (56%) rename backend/.sqlx/{query-df8586283178b0e684b9a0e359efb16e38fd68a5fe2b01cd77f74a725bbf37a3.json => query-aed606f8c8d91a924ac457b4eabdf391947d5fc3da90ef98a7b793c2430eb7bc.json} (76%) create mode 100644 backend/.sqlx/query-b40c9e2d637cf0f2f49fd4c720b64eb6af24e8d8aa99b846f21f2ed49c320bdc.json create mode 100644 backend/.sqlx/query-b48afe9913423a90955011fc6368b8517561f5b55a30e44801d6a9123a762584.json create mode 100644 backend/.sqlx/query-bfd01122256e922b00acf0e86593007c6aeccf76bfd07384ffaccf50a24d13d4.json create mode 100644 backend/.sqlx/query-c4eaa6e3b1d30a72ef1b54b2b7f28d84f1ca3c91de86d520dce06ac12d557bd5.json create mode 100644 backend/.sqlx/query-c921b1a03e3181bbac351545941affc87853ff2d97f567a8817260cfef43e00e.json create mode 100644 backend/.sqlx/query-ce3bccee14cd107631c4ddb4a467099be925f1058c76c6af31e8a620607cf6a1.json create mode 100644 backend/.sqlx/query-d51f555e80b27549b884f334087878286dcc2f23101c2e1c8c1d07b6f0deaecd.json create mode 100644 backend/.sqlx/query-df1fe2e7ef004b8de0e626f40fd737b3b4b662a89148cf6ac70eb3563ad7f0c5.json create mode 100644 backend/.sqlx/query-e81eaa501b4af1269a4929ec660d10fc10d9ec4dc2359f43d79fe255d101cad8.json create mode 100644 backend/.sqlx/query-eefb19c05e6d9650b4dd1e50e10c26f01e117b75ab48e197c7d91d388865da74.json create mode 100644 backend/.sqlx/query-f2146082f64fcdb4acdfe92e42f084f90b3ac06139ffca98670215956150563a.json create mode 100644 backend/migrations/20261002124918_job_token_scopes.down.sql create mode 100644 backend/migrations/20261002124918_job_token_scopes.up.sql create mode 100644 backend/tests/job_token_scopes.rs create mode 100644 backend/windmill-common/src/scopes.rs create mode 100644 frontend/src/lib/components/JobTokenScopesSetting.svelte diff --git a/backend/.sqlx/query-05b610ad1809d487c32e85b0392cfca8913ba1129fdf4978a8a4738689786160.json b/backend/.sqlx/query-05b610ad1809d487c32e85b0392cfca8913ba1129fdf4978a8a4738689786160.json new file mode 100644 index 0000000000..5c6ee3d506 --- /dev/null +++ b/backend/.sqlx/query-05b610ad1809d487c32e85b0392cfca8913ba1129fdf4978a8a4738689786160.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels, job_token_scopes, lock_error_logs, created_at)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, $4::text, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, COALESCE($5::jsonb, modules), labels, job_token_scopes, $6::text, clock_timestamp()\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Text", + "Text", + "Jsonb", + "Text" + ] + }, + "nullable": [] + }, + "hash": "05b610ad1809d487c32e85b0392cfca8913ba1129fdf4978a8a4738689786160" +} diff --git a/backend/.sqlx/query-095fa75c60724664f0355cd6e3a64f84a0f1a31cd6732c0ab994a2d6ac3c3eec.json b/backend/.sqlx/query-095fa75c60724664f0355cd6e3a64f84a0f1a31cd6732c0ab994a2d6ac3c3eec.json new file mode 100644 index 0000000000..852ce508ff --- /dev/null +++ b/backend/.sqlx/query-095fa75c60724664f0355cd6e3a64f84a0f1a31cd6732c0ab994a2d6ac3c3eec.json @@ -0,0 +1,152 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH inserted_job AS (\n INSERT INTO v2_job (\n id, -- 1\n workspace_id, -- 2\n raw_code, -- 3\n raw_lock, -- 4\n raw_flow, -- 5\n tag, -- 6\n parent_job, -- 7\n created_by, -- 8\n permissioned_as, -- 9\n runnable_id, -- 10\n runnable_path, -- 11\n args, -- 12\n kind, -- 13\n trigger, -- 14\n script_lang, -- 15\n same_worker, -- 16\n pre_run_error, -- 17\n permissioned_as_email, -- 18\n visible_to_owner, -- 19\n flow_innermost_root_job, -- 20\n root_job, -- 38\n concurrent_limit, -- 21\n concurrency_time_window_s, -- 22\n timeout, -- 23\n flow_step_id, -- 24\n cache_ttl, -- 25\n priority, -- 26\n trigger_kind, -- 39\n script_entrypoint_override, -- 12\n preprocessed, -- 27,\n labels -- 44\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,\n $19, $20, $38, $21, $22, $23, $24, $25, $26, $39::job_trigger_kind,\n ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27,\n -- $44 (payload labels) merged with the labels of the runnable's folder, if any\n -- ($45/$46 are runnable_path/workspace_id again, kept separate to avoid parameter type conflicts)\n (SELECT CASE WHEN fl.labels IS NULL THEN $44\n ELSE (SELECT array_agg(DISTINCT lbl) FROM unnest(COALESCE($44, ARRAY[]::TEXT[]) || fl.labels) lbl)\n END\n FROM folder_labels($46, $45) AS fl(labels)))\n ),\n inserted_runtime AS (\n INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null)\n ),\n inserted_job_perms AS (\n INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, end_user_email, job_token_scopes)\n values ($1, $32, $33, $34, $35, $36, $37, $2, $41, $47)\n ON CONFLICT (job_id) DO UPDATE SET email = EXCLUDED.email, username = EXCLUDED.username, is_admin = EXCLUDED.is_admin, is_operator = EXCLUDED.is_operator, folders = EXCLUDED.folders, groups = EXCLUDED.groups, workspace_id = EXCLUDED.workspace_id, end_user_email = EXCLUDED.end_user_email, job_token_scopes = EXCLUDED.job_token_scopes\n )\n INSERT INTO v2_job_queue\n (workspace_id, id, running, scheduled_for, started_at, tag, priority, cache_ignore_s3_path, runnable_settings_handle)\n VALUES ($2, $1, $28, COALESCE($29, now()), CASE WHEN $27 OR $40 THEN now() END, $30, $31, $42, $43)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Text", + "Text", + "Jsonb", + "Varchar", + "Uuid", + "Varchar", + "Varchar", + "Int8", + "Varchar", + "Jsonb", + { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + }, + "Varchar", + { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + }, + "Bool", + "Text", + "Varchar", + "Bool", + "Uuid", + "Int4", + "Int4", + "Int4", + "Varchar", + "Int4", + "Int2", + "Bool", + "Bool", + "Timestamptz", + "Varchar", + "Int2", + "Varchar", + "Varchar", + "Bool", + "Bool", + "JsonbArray", + "TextArray", + "Uuid", + { + "Custom": { + "name": "job_trigger_kind", + "kind": { + "Enum": [ + "webhook", + "http", + "websocket", + "kafka", + "email", + "nats", + "schedule", + "app", + "ui", + "postgres", + "sqs", + "gcp", + "mqtt", + "nextcloud", + "google", + "ci_test", + "github", + "azure", + "asset", + "freshness", + "amqp" + ] + } + } + }, + "Bool", + "Varchar", + "Bool", + "Int8", + "TextArray", + "Text", + "Text", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "095fa75c60724664f0355cd6e3a64f84a0f1a31cd6732c0ab994a2d6ac3c3eec" +} diff --git a/backend/.sqlx/query-10f166464a10fc35df2b6ff1b497b17d6bb71f0ae69cd218e6a3fbc151d19d87.json b/backend/.sqlx/query-10f166464a10fc35df2b6ff1b497b17d6bb71f0ae69cd218e6a3fbc151d19d87.json new file mode 100644 index 0000000000..027f01d181 --- /dev/null +++ b/backend/.sqlx/query-10f166464a10fc35df2b6ff1b497b17d6bb71f0ae69cd218e6a3fbc151d19d87.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (\n SELECT 1 FROM v2_job o, v2_job t,\n LATERAL (SELECT ARRAY_REMOVE(ARRAY[o.id, o.parent_job, o.root_job,\n o.flow_innermost_root_job], NULL) AS run) l\n WHERE o.id = $1 AND t.id = $2 AND t.workspace_id = o.workspace_id\n AND (t.id = ANY(l.run) OR t.parent_job = ANY(l.run)\n OR t.root_job = ANY(l.run) OR t.flow_innermost_root_job = ANY(l.run))\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "10f166464a10fc35df2b6ff1b497b17d6bb71f0ae69cd218e6a3fbc151d19d87" +} diff --git a/backend/.sqlx/query-13f358b7c76cda0b24fcb17dbbe10a00333bbf3b8b43bbccc7a6e1926d82478c.json b/backend/.sqlx/query-13f358b7c76cda0b24fcb17dbbe10a00333bbf3b8b43bbccc7a6e1926d82478c.json new file mode 100644 index 0000000000..25aedfe3be --- /dev/null +++ b/backend/.sqlx/query-13f358b7c76cda0b24fcb17dbbe10a00333bbf3b8b43bbccc7a6e1926d82478c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT job_token_scopes FROM job_perms WHERE job_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "13f358b7c76cda0b24fcb17dbbe10a00333bbf3b8b43bbccc7a6e1926d82478c" +} diff --git a/backend/.sqlx/query-333f7116d660756c36dfc0aa2b8d7ebd10a38cbe5724a554a12291c287b67f6d.json b/backend/.sqlx/query-333f7116d660756c36dfc0aa2b8d7ebd10a38cbe5724a554a12291c287b67f6d.json new file mode 100644 index 0000000000..23b46f5577 --- /dev/null +++ b/backend/.sqlx/query-333f7116d660756c36dfc0aa2b8d7ebd10a38cbe5724a554a12291c287b67f6d.json @@ -0,0 +1,324 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n v2_job_queue.workspace_id,\n v2_job_queue.id,\n v2_job.args as \"args: sqlx::types::Json>>\",\n v2_job.parent_job,\n v2_job.created_by,\n v2_job_queue.started_at,\n v2_job_queue.runnable_settings_handle,\n scheduled_for,\n runnable_path,\n kind as \"kind: JobKind\",\n runnable_id as \"runnable_id: ScriptHash\",\n canceled_reason,\n canceled_by,\n permissioned_as,\n permissioned_as_email,\n flow_status as \"flow_status: sqlx::types::Json>\",\n v2_job.tag,\n script_lang as \"script_lang: ScriptLang\",\n same_worker,\n pre_run_error,\n concurrent_limit,\n concurrency_time_window_s,\n flow_innermost_root_job,\n root_job,\n timeout,\n flow_step_id,\n cache_ttl,\n cache_ignore_s3_path,\n v2_job_queue.priority,\n preprocessed,\n script_entrypoint_override,\n trigger,\n trigger_kind as \"trigger_kind: TriggerKindLabel\",\n visible_to_owner,\n NULL as permissioned_as_end_user_email,\n job_perms.job_token_scopes\n FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id\n LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id WHERE v2_job_queue.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "args: sqlx::types::Json>>", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "started_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "runnable_settings_handle", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "scheduled_for", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "kind: JobKind", + "type_info": { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + } + }, + { + "ordinal": 10, + "name": "runnable_id: ScriptHash", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "canceled_reason", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "canceled_by", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "permissioned_as", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "permissioned_as_email", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "flow_status: sqlx::types::Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "script_lang: ScriptLang", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + } + }, + { + "ordinal": 18, + "name": "same_worker", + "type_info": "Bool" + }, + { + "ordinal": 19, + "name": "pre_run_error", + "type_info": "Text" + }, + { + "ordinal": 20, + "name": "concurrent_limit", + "type_info": "Int4" + }, + { + "ordinal": 21, + "name": "concurrency_time_window_s", + "type_info": "Int4" + }, + { + "ordinal": 22, + "name": "flow_innermost_root_job", + "type_info": "Uuid" + }, + { + "ordinal": 23, + "name": "root_job", + "type_info": "Uuid" + }, + { + "ordinal": 24, + "name": "timeout", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "flow_step_id", + "type_info": "Varchar" + }, + { + "ordinal": 26, + "name": "cache_ttl", + "type_info": "Int4" + }, + { + "ordinal": 27, + "name": "cache_ignore_s3_path", + "type_info": "Bool" + }, + { + "ordinal": 28, + "name": "priority", + "type_info": "Int2" + }, + { + "ordinal": 29, + "name": "preprocessed", + "type_info": "Bool" + }, + { + "ordinal": 30, + "name": "script_entrypoint_override", + "type_info": "Varchar" + }, + { + "ordinal": 31, + "name": "trigger", + "type_info": "Varchar" + }, + { + "ordinal": 32, + "name": "trigger_kind: TriggerKindLabel", + "type_info": { + "Custom": { + "name": "job_trigger_kind", + "kind": { + "Enum": [ + "webhook", + "http", + "websocket", + "kafka", + "email", + "nats", + "schedule", + "app", + "ui", + "postgres", + "sqs", + "gcp", + "mqtt", + "nextcloud", + "google", + "ci_test", + "github", + "azure", + "asset", + "freshness", + "amqp" + ] + } + } + } + }, + { + "ordinal": 33, + "name": "visible_to_owner", + "type_info": "Bool" + }, + { + "ordinal": 34, + "name": "permissioned_as_end_user_email", + "type_info": "Text" + }, + { + "ordinal": 35, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + true, + false, + true, + true, + false, + true, + false, + true, + true, + true, + false, + false, + true, + false, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + null, + true + ] + }, + "hash": "333f7116d660756c36dfc0aa2b8d7ebd10a38cbe5724a554a12291c287b67f6d" +} diff --git a/backend/.sqlx/query-4631200a37f7edec92f3796ab4d4585d9c2aba36fdf33bca9739049366bed107.json b/backend/.sqlx/query-4631200a37f7edec92f3796ab4d4585d9c2aba36fdf33bca9739049366bed107.json new file mode 100644 index 0000000000..a5bde4d661 --- /dev/null +++ b/backend/.sqlx/query-4631200a37f7edec92f3796ab4d4585d9c2aba36fdf33bca9739049366bed107.json @@ -0,0 +1,84 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n flow_version.id AS version,\n flow_version.value->>'early_return' as early_return,\n flow_version.value->>'preprocessor_module' IS NOT NULL as has_preprocessor,\n flow_version.value->>'failure_module' IS NOT NULL as has_failure_module,\n (flow_version.value->>'chat_input_enabled')::boolean as chat_input_enabled,\n flow.tag,\n flow.dedicated_worker,\n flow.on_behalf_of,\n flow.edited_by,\n flow.labels,\n flow.job_token_scopes\n FROM\n flow_version\n INNER JOIN flow\n ON flow.path = flow_version.path AND\n flow.workspace_id = flow_version.workspace_id\n WHERE\n flow_version.workspace_id = $1 AND\n flow_version.path = $2 AND\n flow_version.id = $3\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "early_return", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "has_preprocessor", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "has_failure_module", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "chat_input_enabled", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "dedicated_worker", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "on_behalf_of", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "edited_by", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "labels", + "type_info": "TextArray" + }, + { + "ordinal": 10, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8" + ] + }, + "nullable": [ + false, + null, + null, + null, + null, + true, + true, + true, + false, + true, + true + ] + }, + "hash": "4631200a37f7edec92f3796ab4d4585d9c2aba36fdf33bca9739049366bed107" +} diff --git a/backend/.sqlx/query-4cc81f5e04caefa4f170807c0ce96c69f22d9ea9dc3586099553a8f0e76aad82.json b/backend/.sqlx/query-4cc81f5e04caefa4f170807c0ce96c69f22d9ea9dc3586099553a8f0e76aad82.json new file mode 100644 index 0000000000..7051c33008 --- /dev/null +++ b/backend/.sqlx/query-4cc81f5e04caefa4f170807c0ce96c69f22d9ea9dc3586099553a8f0e76aad82.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, job_token_scopes)\n SELECT unnest($1::uuid[]), $2, $3, $4, $5, $6, $7, $8, $9", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "Varchar", + "Varchar", + "Bool", + "Bool", + "JsonbArray", + "TextArray", + "Varchar", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "4cc81f5e04caefa4f170807c0ce96c69f22d9ea9dc3586099553a8f0e76aad82" +} diff --git a/backend/.sqlx/query-580271c5fe178f5e31f3e211cd7fea97aec4d6d3ec65d3e7402811c7dddbbf64.json b/backend/.sqlx/query-580271c5fe178f5e31f3e211cd7fea97aec4d6d3ec65d3e7402811c7dddbbf64.json new file mode 100644 index 0000000000..e1f4609346 --- /dev/null +++ b/backend/.sqlx/query-580271c5fe178f5e31f3e211cd7fea97aec4d6d3ec65d3e7402811c7dddbbf64.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, job_token_scopes)\n SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, job_token_scopes\n FROM flow\n WHERE path = $2 AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "580271c5fe178f5e31f3e211cd7fea97aec4d6d3ec65d3e7402811c7dddbbf64" +} diff --git a/backend/.sqlx/query-5a975e486a0cdb0fae4dadd344ad3abc2f67827597ef74fef473e309b1951544.json b/backend/.sqlx/query-5a975e486a0cdb0fae4dadd344ad3abc2f67827597ef74fef473e309b1951544.json new file mode 100644 index 0000000000..56f5ddbc84 --- /dev/null +++ b/backend/.sqlx/query-5a975e486a0cdb0fae4dadd344ad3abc2f67827597ef74fef473e309b1951544.json @@ -0,0 +1,253 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n j.id, j.workspace_id, j.runnable_id AS \"runnable_id: ScriptHash\", q.scheduled_for, q.started_at, j.parent_job, j.flow_innermost_root_job, j.runnable_path, j.kind as \"kind!: JobKind\", j.permissioned_as,\n j.created_by, j.script_lang AS \"script_lang: ScriptLang\", j.permissioned_as_email, j.flow_step_id, j.trigger_kind AS \"trigger_kind: TriggerKindLabel\", j.trigger, j.priority, j.concurrent_limit, j.tag, j.cache_ttl, q.cache_ignore_s3_path, q.runnable_settings_handle,\n COALESCE(j.args->'build_binary_only' = 'true'::jsonb, false) AS \"build_binary_only!\",\n p.job_token_scopes AS \"job_token_scopes?\"\n FROM v2_job j LEFT JOIN v2_job_queue q ON j.id = q.id\n LEFT JOIN job_perms p ON p.job_id = j.id\n WHERE j.id = $1 AND j.workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "runnable_id: ScriptHash", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "scheduled_for", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "started_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 6, + "name": "flow_innermost_root_job", + "type_info": "Uuid" + }, + { + "ordinal": 7, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "kind!: JobKind", + "type_info": { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + } + }, + { + "ordinal": 9, + "name": "permissioned_as", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "script_lang: ScriptLang", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + } + }, + { + "ordinal": 12, + "name": "permissioned_as_email", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "flow_step_id", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "trigger_kind: TriggerKindLabel", + "type_info": { + "Custom": { + "name": "job_trigger_kind", + "kind": { + "Enum": [ + "webhook", + "http", + "websocket", + "kafka", + "email", + "nats", + "schedule", + "app", + "ui", + "postgres", + "sqs", + "gcp", + "mqtt", + "nextcloud", + "google", + "ci_test", + "github", + "azure", + "asset", + "freshness", + "amqp" + ] + } + } + } + }, + { + "ordinal": 15, + "name": "trigger", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "priority", + "type_info": "Int2" + }, + { + "ordinal": 17, + "name": "concurrent_limit", + "type_info": "Int4" + }, + { + "ordinal": 18, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 19, + "name": "cache_ttl", + "type_info": "Int4" + }, + { + "ordinal": 20, + "name": "cache_ignore_s3_path", + "type_info": "Bool" + }, + { + "ordinal": 21, + "name": "runnable_settings_handle", + "type_info": "Int8" + }, + { + "ordinal": 22, + "name": "build_binary_only!", + "type_info": "Bool" + }, + { + "ordinal": 23, + "name": "job_token_scopes?", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + true, + true, + true, + false, + false, + false, + true, + false, + true, + true, + true, + true, + true, + false, + true, + true, + true, + null, + true + ] + }, + "hash": "5a975e486a0cdb0fae4dadd344ad3abc2f67827597ef74fef473e309b1951544" +} diff --git a/backend/.sqlx/query-60d53c73e67dd7b29cce910fd76b6c87f2994fa2d5f33e0f3ee9effe28614abe.json b/backend/.sqlx/query-60d53c73e67dd7b29cce910fd76b6c87f2994fa2d5f33e0f3ee9effe28614abe.json new file mode 100644 index 0000000000..f04f994e6a --- /dev/null +++ b/backend/.sqlx/query-60d53c73e67dd7b29cce910fd76b6c87f2994fa2d5f33e0f3ee9effe28614abe.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT job_token_scopes FROM script WHERE hash = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "60d53c73e67dd7b29cce910fd76b6c87f2994fa2d5f33e0f3ee9effe28614abe" +} diff --git a/backend/.sqlx/query-6445041033c95fcf52c79c33f34cf6c41fb443f8bd91f12bce0e53f6e91bfb16.json b/backend/.sqlx/query-6445041033c95fcf52c79c33f34cf6c41fb443f8bd91f12bce0e53f6e91bfb16.json new file mode 100644 index 0000000000..abcdf7baab --- /dev/null +++ b/backend/.sqlx/query-6445041033c95fcf52c79c33f34cf6c41fb443f8bd91f12bce0e53f6e91bfb16.json @@ -0,0 +1,331 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_status SET\n flow_status = JSONB_SET(\n JSONB_SET(v2_job_status.flow_status, $2::TEXT[], $3),\n $4::TEXT[], $5\n ) - $6::TEXT[],\n flow_leaf_jobs = CASE\n WHEN $7::TEXT IS NULL\n OR COALESCE(v2_job.flow_innermost_root_job, v2_job_status.id) <> v2_job_status.id\n THEN v2_job_status.flow_leaf_jobs\n ELSE JSONB_SET(COALESCE(v2_job_status.flow_leaf_jobs, '{}'::JSONB), ARRAY[$7::TEXT], $8) END\n FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id\n LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id\n WHERE v2_job_status.id = $1 AND v2_job_queue.id = $1\n RETURNING\n v2_job_queue.workspace_id,\n v2_job_queue.id,\n v2_job.args as \"args: sqlx::types::Json>>\",\n v2_job.parent_job,\n v2_job.created_by,\n v2_job_queue.started_at,\n v2_job_queue.runnable_settings_handle,\n v2_job_queue.scheduled_for,\n v2_job.runnable_path,\n v2_job.kind as \"kind: JobKind\",\n v2_job.runnable_id as \"runnable_id: ScriptHash\",\n v2_job_queue.canceled_reason,\n v2_job_queue.canceled_by,\n v2_job.permissioned_as,\n v2_job.permissioned_as_email,\n v2_job_status.flow_status as \"flow_status: sqlx::types::Json>\",\n v2_job.tag,\n v2_job.script_lang as \"script_lang: ScriptLang\",\n v2_job.same_worker,\n v2_job.pre_run_error,\n v2_job.concurrent_limit,\n v2_job.concurrency_time_window_s,\n v2_job.flow_innermost_root_job,\n v2_job.root_job,\n v2_job.timeout,\n v2_job.flow_step_id,\n v2_job.cache_ttl,\n v2_job_queue.cache_ignore_s3_path,\n v2_job_queue.priority,\n v2_job.preprocessed,\n v2_job.script_entrypoint_override,\n v2_job.trigger,\n v2_job.trigger_kind as \"trigger_kind: TriggerKindLabel\",\n v2_job.visible_to_owner,\n NULL as permissioned_as_end_user_email,\n job_perms.job_token_scopes", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "args: sqlx::types::Json>>", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "started_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "runnable_settings_handle", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "scheduled_for", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "kind: JobKind", + "type_info": { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + } + }, + { + "ordinal": 10, + "name": "runnable_id: ScriptHash", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "canceled_reason", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "canceled_by", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "permissioned_as", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "permissioned_as_email", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "flow_status: sqlx::types::Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "script_lang: ScriptLang", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + } + }, + { + "ordinal": 18, + "name": "same_worker", + "type_info": "Bool" + }, + { + "ordinal": 19, + "name": "pre_run_error", + "type_info": "Text" + }, + { + "ordinal": 20, + "name": "concurrent_limit", + "type_info": "Int4" + }, + { + "ordinal": 21, + "name": "concurrency_time_window_s", + "type_info": "Int4" + }, + { + "ordinal": 22, + "name": "flow_innermost_root_job", + "type_info": "Uuid" + }, + { + "ordinal": 23, + "name": "root_job", + "type_info": "Uuid" + }, + { + "ordinal": 24, + "name": "timeout", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "flow_step_id", + "type_info": "Varchar" + }, + { + "ordinal": 26, + "name": "cache_ttl", + "type_info": "Int4" + }, + { + "ordinal": 27, + "name": "cache_ignore_s3_path", + "type_info": "Bool" + }, + { + "ordinal": 28, + "name": "priority", + "type_info": "Int2" + }, + { + "ordinal": 29, + "name": "preprocessed", + "type_info": "Bool" + }, + { + "ordinal": 30, + "name": "script_entrypoint_override", + "type_info": "Varchar" + }, + { + "ordinal": 31, + "name": "trigger", + "type_info": "Varchar" + }, + { + "ordinal": 32, + "name": "trigger_kind: TriggerKindLabel", + "type_info": { + "Custom": { + "name": "job_trigger_kind", + "kind": { + "Enum": [ + "webhook", + "http", + "websocket", + "kafka", + "email", + "nats", + "schedule", + "app", + "ui", + "postgres", + "sqs", + "gcp", + "mqtt", + "nextcloud", + "google", + "ci_test", + "github", + "azure", + "asset", + "freshness", + "amqp" + ] + } + } + } + }, + { + "ordinal": 33, + "name": "visible_to_owner", + "type_info": "Bool" + }, + { + "ordinal": 34, + "name": "permissioned_as_end_user_email", + "type_info": "Text" + }, + { + "ordinal": 35, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Uuid", + "TextArray", + "Jsonb", + "TextArray", + "Jsonb", + "TextArray", + "Text", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + true, + true, + false, + true, + true, + false, + true, + false, + true, + true, + true, + false, + false, + true, + false, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + null, + true + ] + }, + "hash": "6445041033c95fcf52c79c33f34cf6c41fb443f8bd91f12bce0e53f6e91bfb16" +} diff --git a/backend/.sqlx/query-67db536d06c57f195641f178ce651cbd7b8896984115d55318bb6f4100d72d9a.json b/backend/.sqlx/query-67db536d06c57f195641f178ce651cbd7b8896984115d55318bb6f4100d72d9a.json new file mode 100644 index 0000000000..cfd88ed5cb --- /dev/null +++ b/backend/.sqlx/query-67db536d06c57f195641f178ce651cbd7b8896984115d55318bb6f4100d72d9a.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT $2 IN (parent_job, root_job, flow_innermost_root_job) FROM v2_job\n WHERE id = $1 AND workspace_id = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "67db536d06c57f195641f178ce651cbd7b8896984115d55318bb6f4100d72d9a" +} diff --git a/backend/.sqlx/query-706f2227e34971d22a577b33f6aaa7f5755da9997419c4a9d07b30d4da318bea.json b/backend/.sqlx/query-706f2227e34971d22a577b33f6aaa7f5755da9997419c4a9d07b30d4da318bea.json new file mode 100644 index 0000000000..7c305d1e7a --- /dev/null +++ b/backend/.sqlx/query-706f2227e34971d22a577b33f6aaa7f5755da9997419c4a9d07b30d4da318bea.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO script (\n workspace_id, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of, on_behalf_of_email, assets, modules, job_token_scopes\n )\n SELECT\n $1, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n -- Same three forms and the same prefix-first rule as permissioned_as_exists,\n -- superadmin fallback included: one acting outside their workspaces has no usr\n -- row but still authenticates.\n CASE WHEN on_behalf_of LIKE 'u/%' THEN\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM usr u WHERE u.workspace_id = $1::varchar\n AND u.username = substring(on_behalf_of from 3)\n UNION ALL\n SELECT 1 FROM password p WHERE p.super_admin\n AND (p.username = substring(on_behalf_of from 3)\n OR p.email = substring(on_behalf_of from 3))))\n WHEN on_behalf_of LIKE 'g/%' THEN\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM group_ g WHERE g.workspace_id = $1::varchar\n AND g.name = substring(on_behalf_of from 3)))\n ELSE\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM usr u WHERE u.workspace_id = $1::varchar\n AND u.username = on_behalf_of\n UNION ALL\n SELECT 1 FROM password p WHERE p.email = on_behalf_of\n AND p.super_admin))\n END, on_behalf_of_email, assets, modules, job_token_scopes\n FROM script\n WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "706f2227e34971d22a577b33f6aaa7f5755da9997419c4a9d07b30d4da318bea" +} diff --git a/backend/.sqlx/query-77e9cb16c25defc2423621a64f020e1e07f7e50df82fb6c33f79e6faa5b77bab.json b/backend/.sqlx/query-77e9cb16c25defc2423621a64f020e1e07f7e50df82fb6c33f79e6faa5b77bab.json new file mode 100644 index 0000000000..57822becd2 --- /dev/null +++ b/backend/.sqlx/query-77e9cb16c25defc2423621a64f020e1e07f7e50df82fb6c33f79e6faa5b77bab.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT coalesce(flow_version_lite.value, flow_version.value) as \"value!: sqlx::types::Json>\", flow.job_token_scopes FROM flow\n LEFT JOIN flow_version\n ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]\n LEFT JOIN flow_version_lite\n ON flow_version_lite.id = flow_version.id\n WHERE flow.path = $1 AND flow.workspace_id = $2 LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value!: sqlx::types::Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 1, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null, + true + ] + }, + "hash": "77e9cb16c25defc2423621a64f020e1e07f7e50df82fb6c33f79e6faa5b77bab" +} diff --git a/backend/.sqlx/query-1debd472c9ffd2fc78877484f93db51f9aabed54f9894eda8ad610053ad76ce6.json b/backend/.sqlx/query-83e9c830ee816d4e4e7193a67823cab7c9a4461d3bfbdc0fe7dbdd28ae2de924.json similarity index 56% rename from backend/.sqlx/query-1debd472c9ffd2fc78877484f93db51f9aabed54f9894eda8ad610053ad76ce6.json rename to backend/.sqlx/query-83e9c830ee816d4e4e7193a67823cab7c9a4461d3bfbdc0fe7dbdd28ae2de924.json index 96147d531e..b6a5d2a2d0 100644 --- a/backend/.sqlx/query-1debd472c9ffd2fc78877484f93db51f9aabed54f9894eda8ad610053ad76ce6.json +++ b/backend/.sqlx/query-83e9c830ee816d4e4e7193a67823cab7c9a4461d3bfbdc0fe7dbdd28ae2de924.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT restart_unless_cancelled, timeout FROM script WHERE hash = $1 AND workspace_id = $2", + "query": "SELECT restart_unless_cancelled, timeout, job_token_scopes FROM script WHERE hash = $1 AND workspace_id = $2", "describe": { "columns": [ { @@ -12,6 +12,11 @@ "ordinal": 1, "name": "timeout", "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "job_token_scopes", + "type_info": "TextArray" } ], "parameters": { @@ -21,9 +26,10 @@ ] }, "nullable": [ + true, true, true ] }, - "hash": "1debd472c9ffd2fc78877484f93db51f9aabed54f9894eda8ad610053ad76ce6" + "hash": "83e9c830ee816d4e4e7193a67823cab7c9a4461d3bfbdc0fe7dbdd28ae2de924" } diff --git a/backend/.sqlx/query-df8586283178b0e684b9a0e359efb16e38fd68a5fe2b01cd77f74a725bbf37a3.json b/backend/.sqlx/query-aed606f8c8d91a924ac457b4eabdf391947d5fc3da90ef98a7b793c2430eb7bc.json similarity index 76% rename from backend/.sqlx/query-df8586283178b0e684b9a0e359efb16e38fd68a5fe2b01cd77f74a725bbf37a3.json rename to backend/.sqlx/query-aed606f8c8d91a924ac457b4eabdf391947d5fc3da90ef98a7b793c2430eb7bc.json index 3383951c13..0bd5d0eca5 100644 --- a/backend/.sqlx/query-df8586283178b0e684b9a0e359efb16e38fd68a5fe2b01cd77f74a725bbf37a3.json +++ b/backend/.sqlx/query-aed606f8c8d91a924ac457b4eabdf391947d5fc3da90ef98a7b793c2430eb7bc.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT q.id AS \"id!\", j.created_by, j.permissioned_as, j.permissioned_as_email, j.trigger, j.trigger_kind AS \"trigger_kind: TriggerKindLabel\", j.preprocessed, j.args AS \"args: sqlx::types::Json>>\" FROM v2_job_queue q JOIN v2_job j USING (id) JOIN script s ON s.workspace_id = j.workspace_id AND s.hash = j.runnable_id WHERE j.workspace_id = $1 AND j.runnable_path = $2 AND j.kind = 'script' AND j.flow_step_id IS NULL AND j.runnable_id != $3 AND q.canceled_by IS NULL AND s.restart_unless_cancelled", + "query": "SELECT q.id AS \"id!\", j.created_by, j.permissioned_as, j.permissioned_as_email, j.trigger, j.trigger_kind AS \"trigger_kind: TriggerKindLabel\", j.preprocessed, j.args AS \"args: sqlx::types::Json>>\", p.job_token_scopes AS \"job_token_scopes?\" FROM v2_job_queue q JOIN v2_job j USING (id) JOIN script s ON s.workspace_id = j.workspace_id AND s.hash = j.runnable_id LEFT JOIN job_perms p ON p.job_id = q.id WHERE j.workspace_id = $1 AND j.runnable_path = $2 AND j.kind = 'script' AND j.flow_step_id IS NULL AND j.runnable_id != $3 AND q.canceled_by IS NULL AND s.restart_unless_cancelled", "describe": { "columns": [ { @@ -71,6 +71,11 @@ "ordinal": 7, "name": "args: sqlx::types::Json>>", "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "job_token_scopes?", + "type_info": "TextArray" } ], "parameters": { @@ -88,8 +93,9 @@ true, true, true, + true, true ] }, - "hash": "df8586283178b0e684b9a0e359efb16e38fd68a5fe2b01cd77f74a725bbf37a3" + "hash": "aed606f8c8d91a924ac457b4eabdf391947d5fc3da90ef98a7b793c2430eb7bc" } diff --git a/backend/.sqlx/query-b40c9e2d637cf0f2f49fd4c720b64eb6af24e8d8aa99b846f21f2ed49c320bdc.json b/backend/.sqlx/query-b40c9e2d637cf0f2f49fd4c720b64eb6af24e8d8aa99b846f21f2ed49c320bdc.json new file mode 100644 index 0000000000..db729b97b1 --- /dev/null +++ b/backend/.sqlx/query-b40c9e2d637cf0f2f49fd4c720b64eb6af24e8d8aa99b846f21f2ed49c320bdc.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, job_token_scopes)\n SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, job_token_scopes\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "b40c9e2d637cf0f2f49fd4c720b64eb6af24e8d8aa99b846f21f2ed49c320bdc" +} diff --git a/backend/.sqlx/query-b48afe9913423a90955011fc6368b8517561f5b55a30e44801d6a9123a762584.json b/backend/.sqlx/query-b48afe9913423a90955011fc6368b8517561f5b55a30e44801d6a9123a762584.json new file mode 100644 index 0000000000..47dcca381c --- /dev/null +++ b/backend/.sqlx/query-b48afe9913423a90955011fc6368b8517561f5b55a30e44801d6a9123a762584.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT job_token_scopes FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false ORDER BY created_at DESC LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "b48afe9913423a90955011fc6368b8517561f5b55a30e44801d6a9123a762584" +} diff --git a/backend/.sqlx/query-bfd01122256e922b00acf0e86593007c6aeccf76bfd07384ffaccf50a24d13d4.json b/backend/.sqlx/query-bfd01122256e922b00acf0e86593007c6aeccf76bfd07384ffaccf50a24d13d4.json new file mode 100644 index 0000000000..b1f969f343 --- /dev/null +++ b/backend/.sqlx/query-bfd01122256e922b00acf0e86593007c6aeccf76bfd07384ffaccf50a24d13d4.json @@ -0,0 +1,102 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels, on_behalf_of, on_behalf_of_email, job_token_scopes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8", + "Varchar", + "Int8Array", + "Text", + "Text", + "Text", + "Varchar", + "Text", + "Bool", + "Jsonb", + "Text", + { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + }, + { + "Custom": { + "name": "script_kind", + "kind": { + "Enum": [ + "script", + "trigger", + "failure", + "command", + "approval", + "preprocessor" + ] + } + } + }, + "Varchar", + "VarcharArray", + "Int4", + "Int4", + "Int4", + "Bool", + "Bool", + "Int2", + "Bool", + "Bool", + "Int4", + "Int4", + "Varchar", + "Bool", + "Varchar", + "Varchar", + "Bool", + "Bool", + "Jsonb", + "Varchar", + "Int4", + "Bool", + "Int8", + "Jsonb", + "TextArray", + "Varchar", + "Text", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "bfd01122256e922b00acf0e86593007c6aeccf76bfd07384ffaccf50a24d13d4" +} diff --git a/backend/.sqlx/query-c4eaa6e3b1d30a72ef1b54b2b7f28d84f1ca3c91de86d520dce06ac12d557bd5.json b/backend/.sqlx/query-c4eaa6e3b1d30a72ef1b54b2b7f28d84f1ca3c91de86d520dce06ac12d557bd5.json new file mode 100644 index 0000000000..151296d652 --- /dev/null +++ b/backend/.sqlx/query-c4eaa6e3b1d30a72ef1b54b2b7f28d84f1ca3c91de86d520dce06ac12d557bd5.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH inserted AS (\n INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs, job_token_scopes)\n SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs, job_token_scopes\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3\n RETURNING 1\n ) SELECT COUNT(*) FROM inserted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c4eaa6e3b1d30a72ef1b54b2b7f28d84f1ca3c91de86d520dce06ac12d557bd5" +} diff --git a/backend/.sqlx/query-c921b1a03e3181bbac351545941affc87853ff2d97f567a8817260cfef43e00e.json b/backend/.sqlx/query-c921b1a03e3181bbac351545941affc87853ff2d97f567a8817260cfef43e00e.json new file mode 100644 index 0000000000..393c88720d --- /dev/null +++ b/backend/.sqlx/query-c921b1a03e3181bbac351545941affc87853ff2d97f567a8817260cfef43e00e.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email, username, is_admin, is_operator, groups, folders, end_user_email,\n job_token_scopes\n FROM job_perms WHERE job_id = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "is_operator", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "groups", + "type_info": "TextArray" + }, + { + "ordinal": 5, + "name": "folders", + "type_info": "JsonbArray" + }, + { + "ordinal": 6, + "name": "end_user_email", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "c921b1a03e3181bbac351545941affc87853ff2d97f567a8817260cfef43e00e" +} diff --git a/backend/.sqlx/query-ce3bccee14cd107631c4ddb4a467099be925f1058c76c6af31e8a620607cf6a1.json b/backend/.sqlx/query-ce3bccee14cd107631c4ddb4a467099be925f1058c76c6af31e8a620607cf6a1.json new file mode 100644 index 0000000000..4ae34431dc --- /dev/null +++ b/backend/.sqlx/query-ce3bccee14cd107631c4ddb4a467099be925f1058c76c6af31e8a620607cf6a1.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE\n flow\n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n ws_error_handler_muted = $7,\n value = $8,\n schema = $9::text::json,\n edited_by = $10,\n edited_at = now(),\n labels = COALESCE($13, labels),\n on_behalf_of = $14,\n on_behalf_of_email = $15,\n job_token_scopes = CASE WHEN $16 THEN $17 ELSE job_token_scopes END\n WHERE\n path = $11 AND workspace_id = $12", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text", + "Varchar", + "Bool", + "Bool", + "Bool", + "Jsonb", + "Text", + "Varchar", + "Text", + "Text", + "TextArray", + "Varchar", + "Text", + "Bool", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "ce3bccee14cd107631c4ddb4a467099be925f1058c76c6af31e8a620607cf6a1" +} diff --git a/backend/.sqlx/query-d51f555e80b27549b884f334087878286dcc2f23101c2e1c8c1d07b6f0deaecd.json b/backend/.sqlx/query-d51f555e80b27549b884f334087878286dcc2f23101c2e1c8c1d07b6f0deaecd.json new file mode 100644 index 0000000000..ae90b163ff --- /dev/null +++ b/backend/.sqlx/query-d51f555e80b27549b884f334087878286dcc2f23101c2e1c8c1d07b6f0deaecd.json @@ -0,0 +1,159 @@ +{ + "db_name": "PostgreSQL", + "query": "select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, cache_ignore_s3_path, runnable_settings_handle, language as \"language: ScriptLang\", dedicated_worker, priority, timeout, on_behalf_of, created_by, labels, job_token_scopes FROM script\n WHERE path = $1 AND workspace_id = $2 AND archived = false AND (lock IS NOT NULL OR $3 = false)\n ORDER BY created_at DESC LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "hash", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "concurrency_key", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "concurrent_limit", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "concurrency_time_window_s", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "debounce_key", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "debounce_delay_s", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "cache_ttl", + "type_info": "Int4" + }, + { + "ordinal": 8, + "name": "cache_ignore_s3_path", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "runnable_settings_handle", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "language: ScriptLang", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + } + }, + { + "ordinal": 11, + "name": "dedicated_worker", + "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "priority", + "type_info": "Int2" + }, + { + "ordinal": 13, + "name": "timeout", + "type_info": "Int4" + }, + { + "ordinal": 14, + "name": "on_behalf_of", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "labels", + "type_info": "TextArray" + }, + { + "ordinal": 17, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Bool" + ] + }, + "nullable": [ + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true, + false, + true, + true + ] + }, + "hash": "d51f555e80b27549b884f334087878286dcc2f23101c2e1c8c1d07b6f0deaecd" +} diff --git a/backend/.sqlx/query-df1fe2e7ef004b8de0e626f40fd737b3b4b662a89148cf6ac70eb3563ad7f0c5.json b/backend/.sqlx/query-df1fe2e7ef004b8de0e626f40fd737b3b4b662a89148cf6ac70eb3563ad7f0c5.json new file mode 100644 index 0000000000..7f160dc856 --- /dev/null +++ b/backend/.sqlx/query-df1fe2e7ef004b8de0e626f40fd737b3b4b662a89148cf6ac70eb3563ad7f0c5.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow (\n workspace_id, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, dependency_job, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, versions, on_behalf_of, on_behalf_of_email, lock_error_logs,\n job_token_scopes\n )\n SELECT $2, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, NULL, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, ARRAY[]::bigint[],\n -- Same predicate as clone_scripts.\n CASE WHEN on_behalf_of LIKE 'u/%' THEN\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM usr u WHERE u.workspace_id = $2::varchar\n AND u.username = substring(on_behalf_of from 3)\n UNION ALL\n SELECT 1 FROM password p WHERE p.super_admin\n AND (p.username = substring(on_behalf_of from 3)\n OR p.email = substring(on_behalf_of from 3))))\n WHEN on_behalf_of LIKE 'g/%' THEN\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM group_ g WHERE g.workspace_id = $2::varchar\n AND g.name = substring(on_behalf_of from 3)))\n ELSE\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM usr u WHERE u.workspace_id = $2::varchar\n AND u.username = on_behalf_of\n UNION ALL\n SELECT 1 FROM password p WHERE p.email = on_behalf_of\n AND p.super_admin))\n END, on_behalf_of_email, lock_error_logs, job_token_scopes\n FROM flow\n WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "df1fe2e7ef004b8de0e626f40fd737b3b4b662a89148cf6ac70eb3563ad7f0c5" +} diff --git a/backend/.sqlx/query-e81eaa501b4af1269a4929ec660d10fc10d9ec4dc2359f43d79fe255d101cad8.json b/backend/.sqlx/query-e81eaa501b4af1269a4929ec660d10fc10d9ec4dc2359f43d79fe255d101cad8.json new file mode 100644 index 0000000000..909565b8c6 --- /dev/null +++ b/backend/.sqlx/query-e81eaa501b4af1269a4929ec660d10fc10d9ec4dc2359f43d79fe255d101cad8.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs, job_token_scopes)\n SELECT $1, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs, job_token_scopes\n FROM flow WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "e81eaa501b4af1269a4929ec660d10fc10d9ec4dc2359f43d79fe255d101cad8" +} diff --git a/backend/.sqlx/query-eefb19c05e6d9650b4dd1e50e10c26f01e117b75ab48e197c7d91d388865da74.json b/backend/.sqlx/query-eefb19c05e6d9650b4dd1e50e10c26f01e117b75ab48e197c7d91d388865da74.json new file mode 100644 index 0000000000..24c87414b1 --- /dev/null +++ b/backend/.sqlx/query-eefb19c05e6d9650b4dd1e50e10c26f01e117b75ab48e197c7d91d388865da74.json @@ -0,0 +1,398 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n v2_job.id,\n v2_job.raw_code,\n v2_job.raw_lock,\n v2_job.raw_flow as \"raw_flow: _\",\n v2_job.tag,\n v2_job.created_at,\n v2_job.created_by,\n v2_job.permissioned_as,\n v2_job.permissioned_as_email,\n v2_job.kind as \"kind: _\",\n v2_job.runnable_id,\n v2_job.runnable_path,\n v2_job.parent_job,\n v2_job.root_job,\n v2_job.script_lang as \"script_lang: _\",\n v2_job.script_entrypoint_override,\n v2_job.flow_step,\n v2_job.flow_step_id,\n v2_job.flow_innermost_root_job,\n v2_job.\"trigger\",\n v2_job.trigger_kind as \"trigger_kind: _\",\n v2_job.same_worker,\n v2_job.visible_to_owner,\n v2_job.concurrent_limit,\n v2_job.concurrency_time_window_s,\n v2_job.cache_ttl,\n v2_job.timeout,\n v2_job.priority,\n v2_job.preprocessed,\n v2_job.args as \"args: _\",\n v2_job.labels,\n job_perms.job_token_scopes as \"job_token_scopes?\",\n v2_job.pre_run_error,\n\n v2_job_queue.started_at,\n v2_job_queue.scheduled_for,\n v2_job_queue.running,\n v2_job_queue.canceled_by,\n v2_job_queue.canceled_reason,\n v2_job_queue.suspend,\n v2_job_queue.suspend_until,\n v2_job_queue.worker,\n v2_job_queue.extras as \"extras: _\",\n\n v2_job_runtime.ping,\n v2_job_runtime.memory_peak,\n\n v2_job_status.flow_status as \"flow_status: _\",\n v2_job_status.flow_leaf_jobs as \"flow_leaf_jobs: _\",\n v2_job_status.workflow_as_code_status as \"workflow_as_code_status: _\",\n\n concurrency_key.key as \"concurrency_key?\"\n FROM v2_job_queue\n INNER JOIN v2_job ON v2_job.id = v2_job_queue.id\n LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id\n LEFT JOIN v2_job_runtime ON v2_job_runtime.id = v2_job_queue.id\n LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id\n LEFT JOIN concurrency_key ON concurrency_key.job_id = v2_job_queue.id\n WHERE v2_job_queue.workspace_id = $1\n AND v2_job_queue.running = false\n AND v2_job.parent_job IS NULL\n AND v2_job.trigger_kind IS DISTINCT FROM 'schedule'\n ORDER BY v2_job.created_at DESC\n LIMIT $2\n OFFSET $3\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "raw_code", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "raw_lock", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "raw_flow: _", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "permissioned_as", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "permissioned_as_email", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "kind: _", + "type_info": { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + } + }, + { + "ordinal": 10, + "name": "runnable_id", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 13, + "name": "root_job", + "type_info": "Uuid" + }, + { + "ordinal": 14, + "name": "script_lang: _", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + } + }, + { + "ordinal": 15, + "name": "script_entrypoint_override", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "flow_step", + "type_info": "Int4" + }, + { + "ordinal": 17, + "name": "flow_step_id", + "type_info": "Varchar" + }, + { + "ordinal": 18, + "name": "flow_innermost_root_job", + "type_info": "Uuid" + }, + { + "ordinal": 19, + "name": "trigger", + "type_info": "Varchar" + }, + { + "ordinal": 20, + "name": "trigger_kind: _", + "type_info": { + "Custom": { + "name": "job_trigger_kind", + "kind": { + "Enum": [ + "webhook", + "http", + "websocket", + "kafka", + "email", + "nats", + "schedule", + "app", + "ui", + "postgres", + "sqs", + "gcp", + "mqtt", + "nextcloud", + "google", + "ci_test", + "github", + "azure", + "asset", + "freshness", + "amqp" + ] + } + } + } + }, + { + "ordinal": 21, + "name": "same_worker", + "type_info": "Bool" + }, + { + "ordinal": 22, + "name": "visible_to_owner", + "type_info": "Bool" + }, + { + "ordinal": 23, + "name": "concurrent_limit", + "type_info": "Int4" + }, + { + "ordinal": 24, + "name": "concurrency_time_window_s", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "cache_ttl", + "type_info": "Int4" + }, + { + "ordinal": 26, + "name": "timeout", + "type_info": "Int4" + }, + { + "ordinal": 27, + "name": "priority", + "type_info": "Int2" + }, + { + "ordinal": 28, + "name": "preprocessed", + "type_info": "Bool" + }, + { + "ordinal": 29, + "name": "args: _", + "type_info": "Jsonb" + }, + { + "ordinal": 30, + "name": "labels", + "type_info": "TextArray" + }, + { + "ordinal": 31, + "name": "job_token_scopes?", + "type_info": "TextArray" + }, + { + "ordinal": 32, + "name": "pre_run_error", + "type_info": "Text" + }, + { + "ordinal": 33, + "name": "started_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 34, + "name": "scheduled_for", + "type_info": "Timestamptz" + }, + { + "ordinal": 35, + "name": "running", + "type_info": "Bool" + }, + { + "ordinal": 36, + "name": "canceled_by", + "type_info": "Varchar" + }, + { + "ordinal": 37, + "name": "canceled_reason", + "type_info": "Text" + }, + { + "ordinal": 38, + "name": "suspend", + "type_info": "Int4" + }, + { + "ordinal": 39, + "name": "suspend_until", + "type_info": "Timestamptz" + }, + { + "ordinal": 40, + "name": "worker", + "type_info": "Varchar" + }, + { + "ordinal": 41, + "name": "extras: _", + "type_info": "Jsonb" + }, + { + "ordinal": 42, + "name": "ping", + "type_info": "Timestamptz" + }, + { + "ordinal": 43, + "name": "memory_peak", + "type_info": "Int4" + }, + { + "ordinal": 44, + "name": "flow_status: _", + "type_info": "Jsonb" + }, + { + "ordinal": 45, + "name": "flow_leaf_jobs: _", + "type_info": "Jsonb" + }, + { + "ordinal": 46, + "name": "workflow_as_code_status: _", + "type_info": "Jsonb" + }, + { + "ordinal": 47, + "name": "concurrency_key?", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + true, + true, + true, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + true, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + false + ] + }, + "hash": "eefb19c05e6d9650b4dd1e50e10c26f01e117b75ab48e197c7d91d388865da74" +} diff --git a/backend/.sqlx/query-f2146082f64fcdb4acdfe92e42f084f90b3ac06139ffca98670215956150563a.json b/backend/.sqlx/query-f2146082f64fcdb4acdfe92e42f084f90b3ac06139ffca98670215956150563a.json new file mode 100644 index 0000000000..ad4991a7cf --- /dev/null +++ b/backend/.sqlx/query-f2146082f64fcdb4acdfe92e42f084f90b3ac06139ffca98670215956150563a.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow (\n workspace_id, path, summary, description,\n dependency_job, lock_error_logs, tag,\n dedicated_worker, visible_to_runner_only,\n ws_error_handler_muted,\n value, schema, edited_by, edited_at, labels,\n on_behalf_of, on_behalf_of_email, job_token_scopes\n ) VALUES (\n $1, $2, $3, $4,\n NULL, '', $5,\n $6, $7,\n $8,\n $9, $10::text::json, $11, now(), $12,\n $13, $14, $15\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Text", + "Text", + "Varchar", + "Bool", + "Bool", + "Bool", + "Jsonb", + "Text", + "Varchar", + "TextArray", + "Varchar", + "Text", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "f2146082f64fcdb4acdfe92e42f084f90b3ac06139ffca98670215956150563a" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 765d297205..3ef967569d 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -fe47a306d3760ac2d5a8e14365f05a3503a3ac35 +259ad3bfeef5285ba80eedc86309b11dca001220 diff --git a/backend/migrations/20261002124918_job_token_scopes.down.sql b/backend/migrations/20261002124918_job_token_scopes.down.sql new file mode 100644 index 0000000000..be2c13b3ff --- /dev/null +++ b/backend/migrations/20261002124918_job_token_scopes.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE job_perms DROP COLUMN IF EXISTS job_token_scopes; +ALTER TABLE flow DROP COLUMN IF EXISTS job_token_scopes; +ALTER TABLE script DROP COLUMN IF EXISTS job_token_scopes; diff --git a/backend/migrations/20261002124918_job_token_scopes.up.sql b/backend/migrations/20261002124918_job_token_scopes.up.sql new file mode 100644 index 0000000000..6b24325378 --- /dev/null +++ b/backend/migrations/20261002124918_job_token_scopes.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE script ADD COLUMN IF NOT EXISTS job_token_scopes text[]; +ALTER TABLE flow ADD COLUMN IF NOT EXISTS job_token_scopes text[]; +ALTER TABLE job_perms ADD COLUMN IF NOT EXISTS job_token_scopes text[]; diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index eafbda45c5..77963b73c6 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -6167,6 +6167,21 @@ async fn handle_zombie_jobs(db: &Pool, base_internal_url: &str, node_n continue; } if let Some(job) = job.unwrap() { + // Read while the job is still queued: a re-run (perpetual, retry) takes its cap from + // here once the `job_perms` row is swept after completion. A failed read leaves the + // job for the next sweep rather than completing it with no cap. + let perms = + match windmill_common::auth::get_job_perms(db, &job.id, &job.workspace_id).await { + Ok(perms) => perms, + Err(e) => { + tracing::error!( + "Could not read the permissions of zombie job {}: {e:#}", + job.id + ); + continue; + } + }; + let job_token_scopes = perms.as_ref().and_then(|p| p.job_token_scopes.clone()); let label = ephemeral_script_token_label(&job.permissioned_as, &job.created_by); let token = create_token_for_owner( &db, @@ -6176,7 +6191,7 @@ async fn handle_zombie_jobs(db: &Pool, base_internal_url: &str, node_n job_token_expiry_secs(&db, &job.workspace_id).await, &job.permissioned_as_email, &job.id, - None, + perms, Some(format!("handle_zombie_jobs")), ) .await @@ -6198,10 +6213,12 @@ async fn handle_zombie_jobs(db: &Pool, base_internal_url: &str, node_n ); let memory_peak = job.memory_peak.unwrap_or(0); let (_, killpill_rx_never_used) = KillpillSender::new(1); + let mut completed = windmill_queue::MiniCompletedJob::from(job); + completed.job_token_scopes = job_token_scopes; let _ = handle_job_error( db, &client, - &windmill_queue::MiniCompletedJob::from(job), + &completed, memory_peak, None, error::Error::ExecutionErr(error_message.clone()), diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 169eb81681..2a518a7cc8 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -97,7 +97,7 @@ draft: workspace_id(char), path(char), typ(draft_type), value(json), created_at( email_to_igroup: email(char), igroup(char) email_trigger: path(char), local_part(char), workspaced_local_part(bool), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), labels(text[]) favorite: usr(char), workspace_id(char), path(char), favorite_kind(favorite_kind) -flow: workspace_id(char), path(char), summary(text), description(text), value(jsonb), edited_by(char), edited_at(ts), archived(bool), schema(json), extra_perms(jsonb), dependency_job(uuid), draft_only(bool), tag(char), ws_error_handler_muted(bool), dedicated_worker(bool), timeout(int), visible_to_runner_only(bool), concurrency_key(char), versions(bigint[]), on_behalf_of(varchar), on_behalf_of_email(text), lock_error_logs(text), labels(text[]) +flow: workspace_id(char), path(char), summary(text), description(text), value(jsonb), edited_by(char), edited_at(ts), archived(bool), schema(json), extra_perms(jsonb), dependency_job(uuid), draft_only(bool), tag(char), ws_error_handler_muted(bool), dedicated_worker(bool), timeout(int), visible_to_runner_only(bool), concurrency_key(char), versions(bigint[]), on_behalf_of(varchar), on_behalf_of_email(text), lock_error_logs(text), labels(text[]), job_token_scopes(text[]) FK: (workspace_id) -> workspace(id) flow_conversation: id(uuid), workspace_id(char), flow_path(char), title(char), created_at(ts), updated_at(ts), created_by(char), is_test(bool) FK: (workspace_id) -> workspace(id) @@ -132,7 +132,7 @@ input: id(uuid), workspace_id(char), runnable_id(char), runnable_type(runnable_t FK: (workspace_id) -> workspace(id) instance_group: name(char), summary(char), id(char), scim_display_name(char), external_id(char) job_logs: job_id(uuid), workspace_id(char), created_at(ts), logs(text), log_offset(int), log_file_index(text[]) -job_perms: job_id(uuid), email(char), username(char), is_admin(bool), is_operator(bool), created_at(ts), workspace_id(char), groups(text[]), folders(jsonb[]), end_user_email(char) +job_perms: job_id(uuid), email(char), username(char), is_admin(bool), is_operator(bool), created_at(ts), workspace_id(char), groups(text[]), folders(jsonb[]), end_user_email(char), job_token_scopes(text[]) job_resolution: job_id(uuid), workspace_id(char), resolved_at(ts), resolved_by(char), note(text), automatic(bool) job_result_stream: job_id(uuid), workspace_id(text), stream(text) job_result_stream_v2: job_id(uuid), workspace_id(text), stream(text), idx(int) @@ -183,7 +183,7 @@ resume_job: id(uuid), job(uuid), flow(uuid), created_at(ts), value(jsonb), appro runnable_settings: hash(bigint), debouncing_settings(bigint), concurrency_settings(bigint) schedule: workspace_id(char), path(char), edited_by(char), edited_at(ts), schedule(char), enabled(bool), script_path(char), args(jsonb), extra_perms(jsonb), is_flow(bool), email(char), error(text), timezone(char), on_failure(char), on_recovery(char), on_failure_times(int), on_failure_exact(bool), on_failure_extra_args(jsonb), on_recovery_times(int), on_recovery_extra_args(jsonb), ws_error_handler_muted(bool), retry(jsonb), summary(char), no_flow_overlap(bool), tag(char), paused_until(ts), on_success(char), on_success_extra_args(jsonb), cron_version(text), description(text), dynamic_skip(char), labels(text[]) FK: (workspace_id) -> workspace(id) -script: workspace_id(char), hash(bigint), path(char), parent_hashes(bigint[]), summary(text), description(text), content(text), created_by(char), created_at(ts), archived(bool), schema(json), deleted(bool), is_template(bool), extra_perms(jsonb), lock(text), lock_error_logs(text), language(script_lang), kind(script_kind), tag(char), draft_only(bool), envs(char), concurrent_limit(int), concurrency_time_window_s(int), cache_ttl(int), dedicated_worker(bool), ws_error_handler_muted(bool), priority(smallint), timeout(int), delete_after_use(bool), restart_unless_cancelled(bool), concurrency_key(char), visible_to_runner_only(bool), auto_kind(varchar), codebase(char), has_preprocessor(bool), schema_validation(bool), assets(jsonb), debounce_key(char), debounce_delay_s(int), cache_ignore_s3_path(bool), runnable_settings_handle(bigint), labels(text[]), on_behalf_of(varchar), on_behalf_of_email(text) +script: workspace_id(char), hash(bigint), path(char), parent_hashes(bigint[]), summary(text), description(text), content(text), created_by(char), created_at(ts), archived(bool), schema(json), deleted(bool), is_template(bool), extra_perms(jsonb), lock(text), lock_error_logs(text), language(script_lang), kind(script_kind), tag(char), draft_only(bool), envs(char), concurrent_limit(int), concurrency_time_window_s(int), cache_ttl(int), dedicated_worker(bool), ws_error_handler_muted(bool), priority(smallint), timeout(int), delete_after_use(bool), restart_unless_cancelled(bool), concurrency_key(char), visible_to_runner_only(bool), auto_kind(varchar), codebase(char), has_preprocessor(bool), schema_validation(bool), assets(jsonb), debounce_key(char), debounce_delay_s(int), cache_ignore_s3_path(bool), runnable_settings_handle(bigint), labels(text[]), on_behalf_of(varchar), on_behalf_of_email(text), job_token_scopes(text[]) FK: (workspace_id) -> workspace(id) skip_workspace_diff_tally: workspace_id(char), added_at(ts) sqs_trigger: path(char), queue_url(char), aws_resource_path(char), message_attributes(text[]), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error(text), server_id(char), last_server_ping(ts), aws_auth_resource_type(aws_auth_resource_type), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), labels(text[]) diff --git a/backend/tests/asset_trigger_dispatch.rs b/backend/tests/asset_trigger_dispatch.rs index c165958a33..08d755f5d1 100644 --- a/backend/tests/asset_trigger_dispatch.rs +++ b/backend/tests/asset_trigger_dispatch.rs @@ -245,6 +245,7 @@ fn make_mini(id: Uuid, runnable_path: &str) -> MiniCompletedJob { cache_ignore_s3_path: None, runnable_settings_handle: None, build_binary_only: false, + job_token_scopes: None, } } @@ -330,6 +331,7 @@ async fn end_to_end_asset_dispatch(db: Pool) -> anyhow::Result<()> { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }; let completed = RunJob::from(job).run_until_complete(&db, false, port).await; assert!( @@ -493,6 +495,7 @@ async fn partition_dynamic_resolved_persisted_and_propagated( concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }; let completed = RunJob::from(job) .arg("tenant_id", json!("acme")) diff --git a/backend/tests/batch_rerun.rs b/backend/tests/batch_rerun.rs index 51a6afa565..58cf27db87 100644 --- a/backend/tests/batch_rerun.rs +++ b/backend/tests/batch_rerun.rs @@ -53,6 +53,7 @@ fn script_payload() -> JobPayload { concurrency_settings: ConcurrencySettings::default().into(), debouncing_settings: DebouncingSettings::default(), labels: None, + job_token_scopes: None, } } @@ -63,6 +64,7 @@ fn flow_payload() -> JobPayload { apply_preprocessor: false, version: FLOW_VERSION, labels: None, + job_token_scopes: None, } } diff --git a/backend/tests/bun_jobs.rs b/backend/tests/bun_jobs.rs index 14f8c422a4..50fad28a04 100644 --- a/backend/tests/bun_jobs.rs +++ b/backend/tests/bun_jobs.rs @@ -854,6 +854,7 @@ fn run_main_script_job(hash: i64) -> RunJob { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) } diff --git a/backend/tests/caller_modules_strip.rs b/backend/tests/caller_modules_strip.rs index e6f96c04ec..a65ca22d1b 100644 --- a/backend/tests/caller_modules_strip.rs +++ b/backend/tests/caller_modules_strip.rs @@ -54,6 +54,7 @@ async fn stored_modules(db: &Pool, payload: JobPayload) -> Option) { concurrency_settings: ConcurrencySettings::default(), debouncing_settings: DebouncingSettings::default(), labels: None, + job_token_scopes: None, }; assert_eq!(stored_modules(&db, deployed).await, None); diff --git a/backend/tests/dedicated_workers.rs b/backend/tests/dedicated_workers.rs index 13fb043454..5e9c371f90 100644 --- a/backend/tests/dedicated_workers.rs +++ b/backend/tests/dedicated_workers.rs @@ -85,6 +85,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000001, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -122,6 +123,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000002, + job_token_scopes: None, }) .arg("x", json!(7)) .push(&db) @@ -161,6 +163,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000003, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -205,6 +208,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(21)) .push(&db) @@ -247,6 +251,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -265,6 +270,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -327,6 +333,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000004, + job_token_scopes: None, }) .run_until_complete(&db, false, port) .await @@ -356,6 +363,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000005, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -391,6 +399,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000006, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -427,6 +436,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000007, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -465,6 +475,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000008, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -500,6 +511,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000009, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -511,6 +523,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000010, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -564,6 +577,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -622,6 +636,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(7)) .push(&db) @@ -634,6 +649,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000011, + job_token_scopes: None, }) .arg("x", json!(7)) .push(&db) @@ -683,6 +699,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000012, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -742,6 +759,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000013, + job_token_scopes: None, }) .run_until_complete(&db, false, port) .await @@ -777,6 +795,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -795,6 +814,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -848,6 +868,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -867,6 +888,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -926,6 +948,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000014, + job_token_scopes: None, }) .arg("x", json!(20)) .push(&db) @@ -938,6 +961,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000014, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -988,6 +1012,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -1038,6 +1063,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -1089,6 +1115,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -1142,6 +1169,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -1160,6 +1188,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -1222,6 +1251,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(7)) .push(&db) @@ -1240,6 +1270,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .workspace("test-workspace-2") .arg("x", json!(7)) @@ -1294,6 +1325,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -1338,6 +1370,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -1375,6 +1408,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000015, + job_token_scopes: None, }) .push(&db) .await; @@ -1412,6 +1446,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000019, + job_token_scopes: None, }) .push(&db) .await; @@ -1451,6 +1486,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000016, + job_token_scopes: None, }) .arg("x", json!(1)) .push(&db) @@ -1490,6 +1526,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000017, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -1529,6 +1566,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000018, + job_token_scopes: None, }) .push(&db) .await; diff --git a/backend/tests/dependency_job_tag.rs b/backend/tests/dependency_job_tag.rs index e1b5dc4628..58b320d2c1 100644 --- a/backend/tests/dependency_job_tag.rs +++ b/backend/tests/dependency_job_tag.rs @@ -115,6 +115,7 @@ async fn push_binary_prebuild(db: &Pool, tag: Option<&str>) -> anyhow: None, None, None, + None, ) .await?; tx.commit().await?; diff --git a/backend/tests/dependency_map.rs b/backend/tests/dependency_map.rs index 59895b7a3a..dad6c86aa3 100644 --- a/backend/tests/dependency_map.rs +++ b/backend/tests/dependency_map.rs @@ -452,6 +452,7 @@ def main(): ws_error_handler_muted: None, labels: None, skip_draft_deletion: None, + job_token_scopes: None, }) .send() .await diff --git a/backend/tests/error_handler.rs b/backend/tests/error_handler.rs index 5d5b7a828b..4f58685221 100644 --- a/backend/tests/error_handler.rs +++ b/backend/tests/error_handler.rs @@ -161,6 +161,7 @@ export async function main(path: string, email: string, job_id: string, is_flow: concurrency_settings: ConcurrencySettings::default(), debouncing_settings: DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, server.addr.port()) .await; @@ -285,6 +286,7 @@ async fn test_error_handler_muted_on_script(db: Pool) -> anyhow::Resul concurrency_settings: ConcurrencySettings::default(), debouncing_settings: DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, server.addr.port()) .await; @@ -381,6 +383,7 @@ async fn test_error_handler_not_triggered_on_success(db: Pool) -> anyh concurrency_settings: ConcurrencySettings::default(), debouncing_settings: DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, server.addr.port()) .await; diff --git a/backend/tests/flow_engine_parity.rs b/backend/tests/flow_engine_parity.rs index 7ea221c2e8..3806bb2886 100644 --- a/backend/tests/flow_engine_parity.rs +++ b/backend/tests/flow_engine_parity.rs @@ -50,6 +50,7 @@ fn flow_module(id: &str, value: FlowModuleValue) -> FlowModule { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, } } @@ -2974,6 +2975,7 @@ async fn test_flow_env_marks_sub_flows_only_without_ancestor_env( apply_preprocessor: false, version, labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, port) .await; diff --git a/backend/tests/job_payload.rs b/backend/tests/job_payload.rs index 1a973f6fe6..0038effc78 100644 --- a/backend/tests/job_payload.rs +++ b/backend/tests/job_payload.rs @@ -57,6 +57,7 @@ mod job_payload { language: ScriptLang::Deno, priority: None, apply_preprocessor: false, + job_token_scopes: None, }) .arg("world", json!("foo")) .run_until_complete(&db, false, port) @@ -92,6 +93,7 @@ mod job_payload { debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .run_until_complete_with(db, false, port, |id| async move { let job = sqlx::query!("SELECT preprocessed FROM v2_job WHERE id = $1", id) @@ -435,6 +437,7 @@ mod job_payload { apply_preprocessor: false, version: 1443253234253454, labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, port) .await @@ -485,6 +488,7 @@ mod job_payload { apply_preprocessor: true, version: 1443253234253456, labels: None, + job_token_scopes: None, }) .run_until_complete_with(db, false, port, |id| async move { let job = sqlx::query!("SELECT preprocessed FROM v2_job WHERE id = $1", id) @@ -556,6 +560,7 @@ mod job_payload { apply_preprocessor: true, version: 1443253234253454, labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, port) .await @@ -1150,6 +1155,7 @@ mod job_payload { apply_preprocessor: true, version: 1443253234253454, labels: None, + job_token_scopes: None, }) .run_until_complete(db, false, port) .await; @@ -1625,6 +1631,7 @@ mod job_payload { debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .arg("foo", json!("hello")) .arg("bar", json!("world")) @@ -1676,6 +1683,7 @@ mod job_payload { debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .arg("foo", json!("hello")) .arg("bar", json!("world")) @@ -1727,6 +1735,7 @@ mod job_payload { debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .arg("foo", json!("hello")) .arg("bar", json!("world")) @@ -1778,6 +1787,7 @@ mod job_payload { debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .arg("foo", json!("hello")) .arg("bar", json!("world")) @@ -1831,6 +1841,7 @@ mod job_payload { debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .arg("foo", json!("hello")) .arg("bar", json!("world")) diff --git a/backend/tests/job_token_scopes.rs b/backend/tests/job_token_scopes.rs new file mode 100644 index 0000000000..8129fe2b3e --- /dev/null +++ b/backend/tests/job_token_scopes.rs @@ -0,0 +1,354 @@ +//! A job token restricted by `job_token_scopes` reaches only what its scopes allow, plus the +//! runtime routes about its own job, and no job it creates holds a wider token than it. + +use reqwest::StatusCode; +use serde_json::json; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use windmill_common::jobs::JobPayload; +use windmill_test_utils::*; + +const OIDC_JOB: &str = "b0000000-0000-0000-0000-000000000001"; +const RUN_JOB: &str = "b0000000-0000-0000-0000-000000000002"; +const FLOW_JOB: &str = "b0000000-0000-0000-0000-000000000003"; + +async fn insert_job( + db: &Pool, + id: &str, + parent: Option<&str>, + scopes: &[&str], +) -> anyhow::Result<()> { + let id = Uuid::parse_str(id)?; + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag, parent_job) + VALUES ($1, 'test-workspace', 'test-user-3', 'u/test-user-3', 'test3@windmill.dev', + 'script', 'deno', 'u/test-user-3/agent', 'deno', $2)", + ) + .bind(id) + .bind(parent.map(Uuid::parse_str).transpose()?) + .execute(db) + .await?; + sqlx::query( + "INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, + workspace_id, job_token_scopes) + VALUES ($1, 'test3@windmill.dev', 'test-user-3', false, false, '{}', '{}', + 'test-workspace', $2)", + ) + .bind(id) + .bind(scopes) + .execute(db) + .await?; + Ok(()) +} + +async fn job_token(db: &Pool, id: &str) -> anyhow::Result { + Ok(windmill_common::auth::create_token_for_owner( + db, + "test-workspace", + "u/test-user-3", + "ephemeral-script", + 300, + "test3@windmill.dev", + &Uuid::parse_str(id)?, + None, + None, + ) + .await?) +} + +async fn insert_script( + db: &Pool, + path: &str, + hash: i64, + scopes: Option<&[&str]>, +) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO script (workspace_id, created_by, content, schema, summary, description, + path, hash, language, lock, kind, job_token_scopes) + VALUES ('test-workspace', 'test-user-3', 'export function main() {}', '{}', '', '', + $1, $2, 'deno', '', 'script', $3)", + ) + .bind(path) + .bind(hash) + .bind(scopes) + .execute(db) + .await?; + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_restricted_job_token_is_confined(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + insert_script(&db, "u/test-user-3/open", 515151, None).await?; + insert_script( + &db, + "u/test-user-3/oidc_only", + 525252, + Some(&["oidc:write"]), + ) + .await?; + insert_job(&db, FLOW_JOB, None, &["oidc:write"]).await?; + insert_job(&db, OIDC_JOB, Some(FLOW_JOB), &["oidc:write"]).await?; + insert_job(&db, RUN_JOB, None, &["jobs:run"]).await?; + + let server = ApiServer::start(db.clone()).await?; + set_jwt_secret().await; + let base = format!( + "http://localhost:{}/api/w/test-workspace", + server.addr.port() + ); + let client = reqwest::Client::new(); + let oidc_token = job_token(&db, OIDC_JOB).await?; + + let refused = [ + client.get(format!("{base}/variables/get_value/u/test-user-3/secret")), + client + .post(format!("{base}/scripts/create")) + .json(&json!({})), + client + .post(format!("{base}/schedules/create")) + .json(&json!({})), + client + .post(format!("{base}/jobs/run/p/u/test-user-3/open")) + .json(&json!({})), + ]; + for request in refused { + let resp = request.bearer_auth(&oidc_token).send().await?; + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "{}", + resp.text().await? + ); + } + let own = client + .get(format!("{base}/jobs_u/get_root_job_id/{OIDC_JOB}")) + .bearer_auth(&oidc_token) + .send() + .await?; + assert_eq!(own.status(), StatusCode::OK, "{}", own.text().await?); + // The orchestrator reads a flow's step results with the token of the step that just ran. + for (job, refused) in [(FLOW_JOB, false), (RUN_JOB, true)] { + let resp = client + .get(format!("{base}/jobs/result_by_id/{job}/a")) + .bearer_auth(&oidc_token) + .send() + .await?; + assert_eq!(resp.status() == StatusCode::FORBIDDEN, refused, "{job}"); + } + // Its progress reaches only the flow it runs in. + for (flow, refused) in [(FLOW_JOB, false), (RUN_JOB, true)] { + let resp = client + .post(format!("{base}/job_metrics/set_progress/{OIDC_JOB}")) + .bearer_auth(&oidc_token) + .json(&json!({ "percent": 50, "flow_job_id": flow })) + .send() + .await?; + assert_eq!(resp.status() == StatusCode::FORBIDDEN, refused, "{flow}"); + } + + // Imported queue rows bypass push, so a restricted token cannot import, even an admin's. + let admin_job = Uuid::parse_str("b0000000-0000-0000-0000-000000000004")?; + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag) + VALUES ($1, 'test-workspace', 'test-user', 'u/test-user', 'test@windmill.dev', + 'script', 'deno', 'u/test-user/agent', 'deno')", + ) + .bind(admin_job) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, + workspace_id, job_token_scopes) + VALUES ($1, 'test@windmill.dev', 'test-user', true, false, '{}', '{}', 'test-workspace', + '{jobs:run}')", + ) + .bind(admin_job) + .execute(&db) + .await?; + let admin_token = windmill_common::auth::create_token_for_owner( + &db, + "test-workspace", + "u/test-user", + "ephemeral-script", + 300, + "test@windmill.dev", + &admin_job, + None, + None, + ) + .await?; + let resp = client + .post(format!("{base}/jobs/queue/import")) + .bearer_auth(&admin_token) + .json(&json!([])) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::FORBIDDEN, "{}", resp.text().await?); + // Nor can it, even an admin's, place a child in an unrelated run: the flow-run routes + // trust that lineage. + let resp = client + .post(format!( + "{base}/jobs/run/p/u/test-user-3/open?root_job={RUN_JOB}" + )) + .bearer_auth(&admin_token) + .json(&json!({})) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::CREATED, "{}", resp.text().await?); + let child = Uuid::parse_str(&resp.text().await?)?; + let claimed: bool = sqlx::query_scalar( + "SELECT $2 IN (parent_job, root_job, flow_innermost_root_job) IS TRUE FROM v2_job + WHERE id = $1", + ) + .bind(child) + .bind(Uuid::parse_str(RUN_JOB)?) + .fetch_one(&db) + .await?; + assert!(!claimed); + + // A job it starts is capped at its own scopes, intersected with the target's setting. + let run_token = job_token(&db, RUN_JOB).await?; + for (path, expected) in [ + ("open", vec!["jobs:run".to_string()]), + ("oidc_only", vec![]), + ] { + let resp = client + .post(format!("{base}/jobs/run/p/u/test-user-3/{path}")) + .bearer_auth(&run_token) + .json(&json!({})) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::CREATED); + let child = Uuid::parse_str(&resp.text().await?)?; + let scopes: Option> = + sqlx::query_scalar("SELECT job_token_scopes FROM job_perms WHERE job_id = $1") + .bind(child) + .fetch_one(&db) + .await?; + assert_eq!(scopes, Some(expected), "child of {path}"); + } + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_flow_steps_inherit_the_flow_restriction(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let value = json!({ "modules": [ + { "id": "a", "value": { "type": "identity" }, + "job_token_scopes": ["oidc:write", "variables:read"] }, + { "id": "b", "value": { "type": "identity" } }, + ] }); + sqlx::query( + "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, + schema, extra_perms, versions) + VALUES ('test-workspace', 'u/test-user/agent_flow', '', '', $1, 'test-user', now(), '{}', + '{}', '{7171}')", + ) + .bind(&value) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO flow_version (id, workspace_id, path, value, schema, created_by) + VALUES (7171, 'test-workspace', 'u/test-user/agent_flow', $1, '{}', 'test-user')", + ) + .bind(&value) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let scopes = |v: &[&str]| Some(v.iter().map(|s| s.to_string()).collect::>()); + // A step's own setting narrows the flow's restriction and never widens it; in an + // unrestricted flow it restricts that step alone. + for (flow_scopes, expected_a, expected_b) in [ + (scopes(&["oidc:write"]), scopes(&["oidc:write"]), scopes(&["oidc:write"])), + (None, scopes(&["oidc:write", "variables:read"]), None), + ] { + let flow = RunJob::from(JobPayload::Flow { + path: "u/test-user/agent_flow".to_string(), + dedicated_worker: None, + apply_preprocessor: false, + version: 7171, + labels: None, + job_token_scopes: flow_scopes.clone(), + }) + .run_until_complete(&db, false, server.addr.port()) + .await; + + let steps: Vec<(String, Option>)> = sqlx::query_as( + "SELECT j.flow_step_id, p.job_token_scopes FROM v2_job j + JOIN job_perms p ON p.job_id = j.id + WHERE j.parent_job = $1 ORDER BY j.flow_step_id", + ) + .bind(flow.id) + .fetch_all(&db) + .await?; + assert_eq!( + steps, + vec![("a".to_string(), expected_a), ("b".to_string(), expected_b)], + "flow scopes {flow_scopes:?}" + ); + } + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_deploy_without_the_field_keeps_the_restriction( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let base = format!("http://localhost:{}/api/w/test-workspace", server.addr.port()); + let client = reqwest::Client::new(); + let script = |path: &str, scopes: Option| { + let mut body = json!({ + "path": path, "summary": "", "description": "", "content": "echo 42", "language": "bash", + }); + if let Some(scopes) = scopes { + body["job_token_scopes"] = scopes; + } + body + }; + let stored = |path: &'static str| { + let db = db.clone(); + async move { + sqlx::query_scalar::<_, Option>>( + "SELECT job_token_scopes FROM script WHERE path = $1 ORDER BY created_at DESC LIMIT 1", + ) + .bind(path) + .fetch_one(&db) + .await + } + }; + + let resp = client + .post(format!("{base}/scripts/create")) + .bearer_auth("SECRET_TOKEN") + .json(&script("u/test-user/agent", Some(json!(["oidc:write"])))) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::CREATED, "{}", resp.text().await?); + + // A rename by a client that does not know the field carries the restriction along. + let resp = client + .post(format!("{base}/scripts/update/u/test-user/agent")) + .bearer_auth("SECRET_TOKEN") + .json(&script("u/test-user/renamed", None)) + .send() + .await?; + assert!(resp.status().is_success(), "{}", resp.text().await?); + assert_eq!(stored("u/test-user/renamed").await?, Some(vec!["oidc:write".to_string()])); + + // An explicit null clears it. + let resp = client + .post(format!("{base}/scripts/update/u/test-user/renamed")) + .bearer_auth("SECRET_TOKEN") + .json(&script("u/test-user/renamed", Some(serde_json::Value::Null))) + .send() + .await?; + assert!(resp.status().is_success(), "{}", resp.text().await?); + assert_eq!(stored("u/test-user/renamed").await?, None); + Ok(()) +} diff --git a/backend/tests/list_jobs.rs b/backend/tests/list_jobs.rs index 29a49c1610..d33618d048 100644 --- a/backend/tests/list_jobs.rs +++ b/backend/tests/list_jobs.rs @@ -957,6 +957,7 @@ async fn test_job_labels_propagated_at_push_time(db: Pool) -> anyhow:: .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: Some(vec!["prod".to_string(), "deploy".to_string()]), + job_token_scopes: None, }) .push(&db) .await; @@ -1060,6 +1061,7 @@ async fn test_job_label_filter(db: Pool) -> anyhow::Result<()> { .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: Some(vec!["prod".to_string()]), + job_token_scopes: None, }) .push(&db) .await; @@ -1077,6 +1079,7 @@ async fn test_job_label_filter(db: Pool) -> anyhow::Result<()> { .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: Some(vec!["staging".to_string()]), + job_token_scopes: None, }) .push(&db) .await; diff --git a/backend/tests/nativets_stress.rs b/backend/tests/nativets_stress.rs index 21d3911778..d1e371ef81 100644 --- a/backend/tests/nativets_stress.rs +++ b/backend/tests/nativets_stress.rs @@ -191,6 +191,7 @@ async fn push_job(db: &Pool, content: &str, args: &serde_json::Value) None, None, None, + None, ) .await .expect("push must succeed"); diff --git a/backend/tests/preview_native_tag.rs b/backend/tests/preview_native_tag.rs index eba686396a..39f985cefe 100644 --- a/backend/tests/preview_native_tag.rs +++ b/backend/tests/preview_native_tag.rs @@ -76,6 +76,7 @@ async fn push_preview_and_get_row( None, None, None, + None, ) .await .expect("push must succeed"); diff --git a/backend/tests/python_jobs.rs b/backend/tests/python_jobs.rs index 9616df267a..33961e465f 100644 --- a/backend/tests/python_jobs.rs +++ b/backend/tests/python_jobs.rs @@ -1278,6 +1278,7 @@ async fn test_python_wac_v2_with_preprocessor(db: Pool) -> anyhow::Res debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .arg("who", json!("alice")) .arg("count", json!(7)) diff --git a/backend/tests/restart_at_step_api.rs b/backend/tests/restart_at_step_api.rs index c5ab703f46..2b57de2c64 100644 --- a/backend/tests/restart_at_step_api.rs +++ b/backend/tests/restart_at_step_api.rs @@ -41,6 +41,7 @@ async fn test_api_restart_at_step_nested_happy(db: Pool) -> anyhow::Re apply_preprocessor: true, version: 1443253234253454, labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, port) .await; @@ -85,6 +86,7 @@ async fn test_api_restart_at_step_top_level_happy(db: Pool) -> anyhow: apply_preprocessor: true, version: 1443253234253454, labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, port) .await; @@ -118,6 +120,7 @@ async fn test_api_restart_at_step_rejects_unknown_step(db: Pool) -> an apply_preprocessor: true, version: 1443253234253454, labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, port) .await; @@ -161,6 +164,7 @@ async fn test_api_restart_at_step_rejects_out_of_range_iteration( apply_preprocessor: true, version: 1443253234253454, labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, port) .await; diff --git a/backend/tests/restart_flow_version_scope.rs b/backend/tests/restart_flow_version_scope.rs index 82e8f8e4e7..551e8c3619 100644 --- a/backend/tests/restart_flow_version_scope.rs +++ b/backend/tests/restart_flow_version_scope.rs @@ -53,6 +53,7 @@ async fn push_restart( None, None, None, + None, ) .await?; tx.commit().await?; diff --git a/backend/tests/success_handler.rs b/backend/tests/success_handler.rs index fe6127c27e..8fc4182d9e 100644 --- a/backend/tests/success_handler.rs +++ b/backend/tests/success_handler.rs @@ -181,6 +181,7 @@ export async function main(path: string, email: string, job_id: string, is_flow: concurrency_settings: ConcurrencySettings::default(), debouncing_settings: DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, server.addr.port()) .await; diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index dec5abfb23..938763f21e 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -212,6 +212,7 @@ async fn test_deno_flow(db: Pool) -> anyhow::Result<()> { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }, FlowModule { id: "b".to_string(), @@ -260,6 +261,7 @@ async fn test_deno_flow(db: Pool) -> anyhow::Result<()> { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }], modules_node: None, } @@ -282,6 +284,7 @@ async fn test_deno_flow(db: Pool) -> anyhow::Result<()> { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }, ], same_worker: false, @@ -398,6 +401,7 @@ async fn test_deno_flow_same_worker(db: Pool) -> anyhow::Result<()> { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }, FlowModule { id: "b".to_string(), @@ -455,6 +459,7 @@ async fn test_deno_flow_same_worker(db: Pool) -> anyhow::Result<()> { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }, FlowModule { id: "e".to_string(), @@ -498,6 +503,7 @@ async fn test_deno_flow_same_worker(db: Pool) -> anyhow::Result<()> { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }, ], modules_node: None, @@ -520,6 +526,7 @@ async fn test_deno_flow_same_worker(db: Pool) -> anyhow::Result<()> { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }, FlowModule { id: "c".to_string(), @@ -569,6 +576,7 @@ async fn test_deno_flow_same_worker(db: Pool) -> anyhow::Result<()> { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }, ], same_worker: true, @@ -5791,6 +5799,7 @@ async fn test_flow_tag_judged_as_written_before_preprocessor( apply_preprocessor: true, version: 1443253234253456, labels: None, + job_token_scopes: None, }) .as_user("test-user-2", "test2@windmill.dev") .run_until_complete(&db, false, port) diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index 869f40e14c..ced8385ef5 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -104,6 +104,32 @@ pub struct ExpiringAuthCache { pub job_id: Option, } +/// Whether `target` belongs to the flow run of the job `job_id`: the job itself, one of its +/// ancestors, or a job whose parent or root is one of them (a sibling step, a loop or branch +/// iteration, any job pushed as a child of one of them). A lookup that fails reads as no. +async fn job_in_same_flow_run(db: &DB, job_id: uuid::Uuid, target: uuid::Uuid) -> bool { + if job_id == target { + return true; + } + sqlx::query_scalar!( + "SELECT EXISTS ( + SELECT 1 FROM v2_job o, v2_job t, + LATERAL (SELECT ARRAY_REMOVE(ARRAY[o.id, o.parent_job, o.root_job, + o.flow_innermost_root_job], NULL) AS run) l + WHERE o.id = $1 AND t.id = $2 AND t.workspace_id = o.workspace_id + AND (t.id = ANY(l.run) OR t.parent_job = ANY(l.run) + OR t.root_job = ANY(l.run) OR t.flow_innermost_root_job = ANY(l.run)) + )", + job_id, + target + ) + .fetch_one(db) + .await + .ok() + .flatten() + .unwrap_or(false) +} + pub struct AuthCache { db: DB, superadmin_secret: Option, @@ -1131,8 +1157,20 @@ pub async fn resolve_opt_job_authed( return Err((err, parts)); } } + let own_job_runtime_route = match opt_job_authed.job_id { + Some(job_id) if opt_job_authed.authed.scopes.is_some() => { + crate::scopes::is_own_job_runtime_route(path, method, job_id) + || match crate::scopes::flow_run_read_route_job(path, method) { + Some(target) => { + job_in_same_flow_run(&cache.db, job_id, target).await + } + None => false, + } + } + _ => false, + }; let authed = &mut opt_job_authed.authed; - if authed.scopes.is_some() { + if authed.scopes.is_some() && !own_job_runtime_route { transform_old_scope_to_new_scope(authed.scopes.as_mut()); if let Err(err) = crate::scopes::check_scopes_for_route( diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index 5b6a618e5f..30c3bb65af 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -35,6 +35,7 @@ use windmill_common::{ }; use scopes::ScopeDefinition; +use windmill_common::scopes::scope_contains; // Re-export key auth types and functions pub use auth::{ @@ -475,11 +476,11 @@ pub fn is_effectively_unscoped(scopes: Option<&[String]>) -> bool { /// refused. An admin check is not a substitute — it answers for the user behind the /// token, not for the token's own scopes. /// -/// This bounds the token making the request, not every route to the key. A job token -/// is minted unscoped from its owner's privileges, so a `jobs:run` token still reaches -/// the key indirectly by running a job as a workspace admin — the same property that -/// lets git-sync export it. Confining that means not inheriting unscoped privilege -/// into job tokens, which is a far wider change than this guard. +/// This bounds the token making the request, not every route to the key. The jobs a +/// scoped user token runs get a token with their owner's privileges, capped only by the +/// runnable's own `job_token_scopes` (see [`caller_scope_ceiling`]), so a `jobs:run` +/// token still reaches the key indirectly by running an unrestricted job as a workspace +/// admin — the same property that lets git-sync export it. pub fn forbid_scoped_token_workspace_key(authed: &ApiAuthed) -> error::Result<()> { if is_effectively_unscoped(authed.scopes.as_deref()) { return Ok(()); @@ -492,6 +493,50 @@ pub fn forbid_scoped_token_workspace_key(authed: &ApiAuthed) -> error::Result<() )) } +/// For a route that authenticates its token itself rather than through the route layer +/// (which checks scopes): a job token restricted by `job_token_scopes` must still hold +/// `required` there. Other credentials keep the access those routes always gave them. +pub fn check_job_token_scope(authed: &ApiAuthed, required: F) -> error::Result<()> +where + F: FnOnce() -> String, +{ + if authed.job_id.is_some() { + check_scopes(authed, required) + } else { + Ok(()) + } +} + +/// The cap on the token of a job pushed on `authed`'s request, to pass to `push` as its +/// scope ceiling. A job token caps every job it starts at its own job's scopes, so a +/// restricted job cannot widen its reach by running another one; the job row is the +/// source rather than the token's claims, which the MCP proxy narrows per route. Any +/// other credential caps nothing: the jobs it runs act as their owner, as they always did. +pub async fn caller_scope_ceiling( + db: &windmill_common::DB, + authed: &ApiAuthed, +) -> error::Result>> { + let Some(job_id) = authed.job_id else { + return Ok(None); + }; + // A job token is minted with scopes exactly when its job is restricted, so an unscoped one + // has nothing to read. + if authed.scopes.is_none() { + return Ok(None); + } + let row = sqlx::query_scalar!( + "SELECT job_token_scopes FROM job_perms WHERE job_id = $1", + job_id + ) + .fetch_optional(db) + .await?; + Ok(match row { + Some(scopes) => scopes, + // The job left the queue and its row was swept: only the token's own claims are left. + None => authed.scopes.clone(), + }) +} + /// Enforce monotonic privilege when a token lifecycle endpoint mints or rescopes /// a credential on behalf of `authed`: the resulting credential must never be /// more privileged than the caller's own token. @@ -604,84 +649,6 @@ fn first_filter_tags(scopes: Option<&[String]>) -> Option> { }) } -/// Whether `caller` grants at least everything `requested` grants (directional -/// containment). -/// -/// This is intentionally NOT `ScopeDefinition::includes`: that method answers -/// "does this scope grant access to a required action" using OR semantics over -/// resources (any overlap counts, and a `*` on either side matches), which is -/// correct for access checks but unsafe for subset checks — it would let a -/// token scoped to `scripts:read:f/team/a` mint `scripts:read:*` or -/// `scripts:read:f/team/a,f/other/b`. Subset containment instead requires that -/// EVERY requested resource is covered by SOME caller resource. -fn scope_contains(caller: &ScopeDefinition, requested: &ScopeDefinition) -> bool { - if caller.domain != requested.domain { - return false; - } - - // write subsumes read; otherwise the action must match exactly. - match (caller.action.as_str(), requested.action.as_str()) { - (c, r) if c == r || (c == "write" && r == "read") => {} - // Apps only: `write` covers `run` (see `ScopeDefinition::includes`), so an - // app-editor token can mint the narrower run-only credential. - ("write", "run") if caller.domain == "apps" => {} - ("write", "cancel") if caller.domain == "jobs" => {} - _ => return false, - } - - if caller.domain == "jobs" && caller.action == "run" { - match (&caller.kind, &requested.kind) { - (Some(caller_kind), Some(requested_kind)) if caller_kind != requested_kind => { - return false - } - // Caller pinned to a kind, but the request covers any kind. - (Some(_), None) => return false, - _ => {} - } - } - - match (&caller.resource, &requested.resource) { - // Caller is unrestricted on resources: covers everything. - (None, _) => true, - // Caller is resource-restricted but the request is not: broader, unless the - // caller lists `*` and so already spans every path. Kept in step with - // `ScopeDefinition::includes`, which accepts that same grant for a - // whole-collection read: what a token may exercise, it may also delegate. - (Some(caller_resources), None) => caller_resources.iter().any(|r| r == "*"), - (Some(caller_resources), Some(requested_resources)) => { - resource_set_contains(caller_resources, requested_resources) - } - } -} - -/// Every resource in `requested` must be covered by some resource in `caller`. -fn resource_set_contains(caller: &[String], requested: &[String]) -> bool { - if caller.iter().any(|r| r == "*") { - return true; - } - requested - .iter() - .all(|req| req != "*" && caller.iter().any(|c| resource_covers(c, req))) -} - -/// Directional: does the single caller resource pattern cover `requested`? -/// `caller` may be an exact path or a `/*` subtree wildcard; `requested` -/// may itself be a subtree wildcard, in which case the whole requested subtree -/// must fall within the caller's subtree. -fn resource_covers(caller: &str, requested: &str) -> bool { - if caller == requested { - return true; - } - let Some(prefix) = caller.strip_suffix("/*") else { - // An exact caller resource only covers itself (handled above). - return false; - }; - let requested_base = requested.strip_suffix("/*").unwrap_or(requested); - requested_base == prefix - || (requested_base.starts_with(prefix) - && requested_base.as_bytes().get(prefix.len()) == Some(&b'/')) -} - /// Returns a predicate that checks whether `path` is within the token's /// scope for `{domain}:{action}:{path}`. For tokens without scope /// restrictions (no scopes at all, or only `if_jobs:filter_tags:*` scopes), diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index f2a6612e42..4fa85dfa3a 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -6,469 +6,9 @@ * LICENSE-AGPL for a copy of the license. */ -use itertools::Itertools; -use serde::{Deserialize, Serialize}; -use std::collections::HashSet; use windmill_common::error::{Error, Result}; -/// Comprehensive scope system for JWT token authorization -/// -/// Scopes follow the format: {domain}:{action}[:{resource}] -/// Examples: -/// - "jobs:read" - Read access to jobs -/// - "scripts:write:f/folder/*" - Write access to scripts in a folder -/// - "*" - Full access (superuser) - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ScopeDefinition { - pub domain: String, - pub action: String, - pub kind: Option, // For jobs:run:kind (optional) - pub resource: Option>, -} - -impl ScopeDefinition { - pub fn new( - domain: &str, - action: &str, - kind: Option<&str>, - resource: Option>, - ) -> Self { - Self { - domain: domain.to_string(), - action: action.to_string(), - kind: kind.map(|s| s.to_string()), - resource: resource, - } - } - - pub fn from_scope_string(scope: &str) -> Result { - let parts: Vec<&str> = scope.split(':').collect(); - - let into_owned_vec = |resources: &str| -> Vec { - let resources = resources - .split(",") - .collect_vec() - .into_iter() - .map(ToOwned::to_owned) - .collect_vec(); - - resources - }; - - match parts.len() { - 2 => Ok(Self::new(parts[0], parts[1], None, None)), // domain:action - 3 => { - if parts[0] == "jobs" && parts[1] == "run" { - Ok(Self::new(parts[0], parts[1], Some(parts[2]), None)) - } else { - Ok(Self::new( - parts[0], - parts[1], - None, - Some(into_owned_vec(parts[2])), - )) - } - } - 4 => { - if parts[0] == "jobs" && parts[1] == "run" { - Ok(Self::new( - parts[0], - parts[1], - Some(parts[2]), - Some(into_owned_vec(parts[3])), - )) - } else { - Err(Error::BadRequest(format!( - "Invalid 4-part scope: {}", - scope - ))) - } - } - _ => Err(Error::BadRequest(format!( - "Invalid scope format: {}", - scope - ))), - } - } - - pub fn as_string(&self) -> String { - match (&self.kind, &self.resource) { - (Some(kind), Some(resource)) => { - format!( - "{}:{}:{}:{}", - self.domain, - self.action, - kind, - resource.join(",") - ) - } - (Some(kind), None) => { - format!("{}:{}:{}", self.domain, self.action, kind) - } - (None, Some(resource)) => { - format!("{}:{}:{}", self.domain, self.action, resource.join(",")) - } - (None, None) => format!("{}:{}", self.domain, self.action), - } - } - - pub fn includes(&self, other: &ScopeDefinition) -> bool { - if self.domain != other.domain { - return false; - } - - match (self.action.as_str(), other.action.as_str()) { - (a, b) if (a == "write" && b == "read") || (a == b) => {} - // Apps only: `write` can rewrite the app and its policy, so it also covers - // running its components. Not general — `jobs:write` must not grant - // `jobs:run`. The resource check below still confines it to the same app. - ("write", "run") if self.domain == "apps" => {} - ("write", "cancel") if self.domain == "jobs" => {} - _ => return false, - } - - if self.domain == "jobs" && self.action == "run" { - match (&self.kind, &other.kind) { - (Some(self_kind), Some(other_kind)) => { - if self_kind != other_kind { - return false; - } - } - (Some(_), None) => { - return false; - } - (None, _) => { - return true; - } - } - } - - match (&self.resource, &other.resource) { - (Some(self_resources), Some(other_resources)) => { - resources_match(self_resources, other_resources) - } - // A requirement naming no path is the whole domain, so only a grant that - // itself spans every path satisfies it. `*` is that grant — the scope UI - // accepts it as a resource path and `resources_match` already reads it as - // everything — while any listed path leaves the collection unauthorized. - (Some(self_resources), None) => self_resources.iter().any(|r| r == "*"), - (None, _) => true, - } - } -} - -fn resources_match(scope_resources: &[String], accepted_resources: &[String]) -> bool { - if scope_resources.contains(&"*".to_string()) || accepted_resources.contains(&"*".to_string()) { - return true; - } - - if scope_resources.len() <= 4 && accepted_resources.len() <= 4 { - return resources_match_small(scope_resources, accepted_resources); - } - - resources_match_large(scope_resources, accepted_resources) -} - -fn resources_match_small(scope_resources: &[String], accepted_resources: &[String]) -> bool { - for required in accepted_resources { - for scope_resource in scope_resources { - if resource_matches_pattern(scope_resource, required) { - return true; - } - } - } - false -} - -fn resources_match_large(scope_resources: &[String], accepted_resources: &[String]) -> bool { - let mut exact_matches = HashSet::new(); - let mut patterns = Vec::new(); - - for scope_resource in scope_resources { - if scope_resource.contains('*') { - patterns.push(scope_resource); - } else { - exact_matches.insert(scope_resource); - } - } - - for accepted_resource in accepted_resources { - if exact_matches.contains(accepted_resource) { - return true; - } - - for pattern in &patterns { - if resource_matches_pattern(pattern, accepted_resource) { - return true; - } - } - } - - false -} - -fn resource_matches_pattern(scope_resource: &str, accepted_resource: &str) -> bool { - if scope_resource == accepted_resource { - return true; - } - - let matches_wildcard = |pattern: &str, resource: &str| -> bool { - if !pattern.ends_with("/*") { - return false; - } - - let prefix = &pattern[..pattern.len() - 2]; - - if !resource.starts_with(prefix) { - return false; - } - - // If the resource is exactly the prefix, it matches - if resource.len() == prefix.len() { - return true; - } - - // If the resource is longer, the next character must be '/' for a valid match - // This prevents "u/user" from matching "u/use/*" - resource.chars().nth(prefix.len()) == Some('/') - }; - - // Check if either resource is a wildcard pattern and matches the other - matches_wildcard(scope_resource, accepted_resource) - || matches_wildcard(accepted_resource, scope_resource) -} - -// ───────────────────────────────────────────────────────────────── -// Route-level scope checking -// ───────────────────────────────────────────────────────────────── - -/// Available scope domains (top-level API categories) -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ScopeDomain { - // Core resource domains - Jobs, - Scripts, - /// The `/data_metrics` catalog. Its own domain, NOT an alias of `Scripts`: a - /// `data_metrics:read` token must reach only this route, never the broader - /// `/scripts` routes (some of which do no further scope check). - DataMetrics, - Flows, - FlowConversations, - Apps, - Variables, - Resources, - Schedules, - Folders, - Users, - Groups, - Workspaces, - - // Trigger domains - HttpTriggers, - WebsocketTriggers, - KafkaTriggers, - NatsTriggers, - MqttTriggers, - AmqpTriggers, - SqsTriggers, - GcpTriggers, - AzureTriggers, - PostgresTriggers, - EmailTriggers, - - // Native trigger domains - NativeTriggers, - TriggersHistory, - - // System domains - Audit, - Settings, - Workers, - ServiceLogs, - Configs, - OAuth, - AI, - AiEvals, // AI agent eval datasets - - Indexer, - Teams, // Microsoft Teams integration - GitSync, // Git synchronization - - // Special domains - Capture, // Webhook capture - Drafts, // Draft resources - Favorites, // User favorites - Inputs, // Input templates - JobHelpers, // Job helper functions - ConcurrencyGroups, // Concurrency groups - Oidc, // OpenID Connect - Openapi, // OpenAPI generation - - // Additional domains - Acls, // Granular access control lists - RawApps, // Raw application data - AgentWorkers, // Agent workers management - Mcp, // MCP - Docs, // Self-hosted documentation search (read-only) -} - -impl ScopeDomain { - pub fn as_str(&self) -> &'static str { - match self { - Self::Jobs => "jobs", - Self::Scripts => "scripts", - Self::DataMetrics => "data_metrics", - Self::Flows => "flows", - Self::FlowConversations => "flow_conversations", - Self::Apps => "apps", - Self::Variables => "variables", - Self::Resources => "resources", - Self::Schedules => "schedules", - Self::Folders => "folders", - Self::Users => "users", - Self::Groups => "groups", - Self::Workspaces => "workspaces", - Self::HttpTriggers => "http_triggers", - Self::WebsocketTriggers => "websocket_triggers", - Self::KafkaTriggers => "kafka_triggers", - Self::NatsTriggers => "nats_triggers", - Self::MqttTriggers => "mqtt_triggers", - Self::AmqpTriggers => "amqp_triggers", - Self::SqsTriggers => "sqs_triggers", - Self::GcpTriggers => "gcp_triggers", - Self::AzureTriggers => "azure_triggers", - Self::PostgresTriggers => "postgres_triggers", - Self::EmailTriggers => "email_triggers", - Self::NativeTriggers => "native_triggers", - Self::TriggersHistory => "triggers_history", - Self::Audit => "audit", - Self::Settings => "settings", - Self::Workers => "workers", - Self::ServiceLogs => "service_logs", - Self::Configs => "configs", - Self::OAuth => "oauth", - Self::AI => "ai", - Self::AiEvals => "ai_evals", - Self::Capture => "capture", - Self::Drafts => "drafts", - Self::Favorites => "favorites", - Self::Inputs => "inputs", - Self::JobHelpers => "job_helpers", - Self::ConcurrencyGroups => "concurrency_groups", - Self::Oidc => "oidc", - Self::Openapi => "openapi", - Self::Acls => "acls", - Self::RawApps => "raw_apps", - Self::AgentWorkers => "agent_workers", - Self::Indexer => "indexer", - Self::Teams => "teams", - Self::GitSync => "git_sync", - Self::Mcp => "mcp", - Self::Docs => "docs", - } - } - - pub fn from_str(s: &str) -> Option { - match s { - "jobs" | "jobs_u" => Some(Self::Jobs), - "scripts" => Some(Self::Scripts), - // A distinct domain, not an alias of `scripts` (see the enum variant): - // a `data_metrics:read` token must not reach the broader /scripts routes. - "data_metrics" => Some(Self::DataMetrics), - "flows" => Some(Self::Flows), - "flow_conversations" => Some(Self::FlowConversations), - "apps" | "apps_u" => Some(Self::Apps), - "variables" => Some(Self::Variables), - "resources" => Some(Self::Resources), - "schedules" => Some(Self::Schedules), - "folders" => Some(Self::Folders), - "users" => Some(Self::Users), - "groups" => Some(Self::Groups), - "workspaces" => Some(Self::Workspaces), - "http_triggers" => Some(Self::HttpTriggers), - "websocket_triggers" => Some(Self::WebsocketTriggers), - "kafka_triggers" => Some(Self::KafkaTriggers), - "nats_triggers" => Some(Self::NatsTriggers), - "mqtt_triggers" => Some(Self::MqttTriggers), - "amqp_triggers" => Some(Self::AmqpTriggers), - "sqs_triggers" => Some(Self::SqsTriggers), - "gcp_triggers" => Some(Self::GcpTriggers), - "azure_triggers" => Some(Self::AzureTriggers), - "postgres_triggers" => Some(Self::PostgresTriggers), - "email_triggers" => Some(Self::EmailTriggers), - "audit" => Some(Self::Audit), - "settings" => Some(Self::Settings), - "workers" => Some(Self::Workers), - "service_logs" => Some(Self::ServiceLogs), - "configs" => Some(Self::Configs), - "oauth" => Some(Self::OAuth), - "ai" => Some(Self::AI), - "ai_evals" => Some(Self::AiEvals), - "indexer" | "srch" => Some(Self::Indexer), - "teams" => Some(Self::Teams), - "native_triggers" => Some(Self::NativeTriggers), - "triggers_history" => Some(Self::TriggersHistory), - "git_sync" | "github_app" => Some(Self::GitSync), - "capture" => Some(Self::Capture), - "drafts" => Some(Self::Drafts), - "favorites" => Some(Self::Favorites), - "inputs" => Some(Self::Inputs), - "job_helpers" => Some(Self::JobHelpers), - "concurrency_groups" => Some(Self::ConcurrencyGroups), - "oidc" => Some(Self::Oidc), - "openapi" => Some(Self::Openapi), - "acls" => Some(Self::Acls), - "raw_apps" => Some(Self::RawApps), - "agent_workers" => Some(Self::AgentWorkers), - "mcp" => Some(Self::Mcp), - "docs" => Some(Self::Docs), - _ => None, - } - } -} - -/// Available scope actions -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ScopeAction { - Read, // GET operations, list, view - Write, // POST, PUT, PATCH, DELETE operations, create, update, delete - Run, // Special action for running (scripts, flows, etc.) - Cancel, // Cancelling jobs (`CANCEL_PATH_ACTIONS`); covered by `jobs:write` -} - -impl ScopeAction { - pub fn as_str(&self) -> &'static str { - match self { - Self::Read => "read", - Self::Write => "write", - Self::Run => "run", - Self::Cancel => "cancel", - } - } - - pub fn from_str(s: &str) -> Option { - match s { - "read" => Some(Self::Read), - "write" => Some(Self::Write), - "delete" => Some(Self::Write), - "run" => Some(Self::Run), - "cancel" => Some(Self::Cancel), - _ => None, - } - } - - /// Check if this action includes another action - /// Write includes Read - pub fn includes(&self, other: &ScopeAction) -> bool { - match (self, other) { - (ScopeAction::Write, ScopeAction::Read) => true, - (ScopeAction::Run, ScopeAction::Read) => true, - (ScopeAction::Write, ScopeAction::Cancel) => true, - (a, b) => a == b, - } - } -} +pub use windmill_common::scopes::{ScopeAction, ScopeDefinition, ScopeDomain}; pub fn check_route_access( token_scopes: &[String], @@ -1293,6 +833,60 @@ pub fn scope_for_route(method: &str, path: &str) -> Option { }) } +/// Routes the runtime of a job calls about that job alone: progress, its root id, its +/// resume and approval urls, a workflow-as-code checkpoint or task. A job token restricted +/// by `job_token_scopes` keeps these whatever its scopes, or the job could not run at all, +/// but only for its own job: the job id in the path must be the token's. +pub fn is_own_job_runtime_route(route_path: &str, http_method: &str, job_id: uuid::Uuid) -> bool { + let Some(rest) = route_path.strip_prefix("/api/w/") else { + return false; + }; + let Some((_workspace, rest)) = rest.split_once('/') else { + return false; + }; + let segments: Vec<&str> = rest.split('/').collect(); + let (method_ok, id_index) = match segments.as_slice() { + ["jobs_u", "get" | "get_root_job_id", ..] => (http_method == "GET", 2), + ["job_metrics", "set_progress", ..] => (http_method == "POST", 2), + ["job_metrics", "get_progress", ..] => (http_method == "GET", 2), + ["jobs" | "jobs_u", "resume_urls" | "wac_approval_urls", ..] => (http_method == "GET", 2), + ["jobs", "wac", "inline_checkpoint", ..] => (http_method == "POST", 3), + ["jobs", "run", "workflow_as_code", ..] => (http_method == "POST", 3), + _ => return false, + }; + method_ok + && segments + .get(id_index) + .and_then(|id| uuid::Uuid::parse_str(id).ok()) + .is_some_and(|id| id == job_id) +} + +/// Routes reading the state of a flow run: step results (`results.x` in input transforms, +/// loop and branch results) and the run's user state. The flow orchestrator evaluates a +/// step's inputs with the token of the step that just finished, and the SDK reads user state +/// at the root job, so a restricted job token keeps these for every job of its own run. +/// Returns the job id the path names; the caller checks it against the token's lineage. +pub fn flow_run_read_route_job(route_path: &str, http_method: &str) -> Option { + let rest = route_path.strip_prefix("/api/w/")?; + let (_workspace, rest) = rest.split_once('/')?; + let segments: Vec<&str> = rest.split('/').collect(); + let id = match segments.as_slice() { + ["jobs" | "jobs_u", "result_by_id", id, ..] if http_method == "GET" => id, + ["jobs_u", "completed", "get_result" | "get_result_maybe", id, ..] + if http_method == "GET" => + { + id + } + ["jobs" | "jobs_u", "flow", "user_states", id, ..] + if http_method == "GET" || http_method == "POST" => + { + id + } + _ => return None, + }; + uuid::Uuid::parse_str(id).ok() +} + /// Helper function to check if scopes allow access to a route pub fn check_scopes_for_route( token_scopes: Option<&[String]>, @@ -1312,6 +906,63 @@ pub fn check_scopes_for_route( mod tests { use super::*; + #[test] + fn own_job_runtime_routes_admit_only_the_tokens_job() { + let own = uuid::Uuid::new_v4(); + let other = uuid::Uuid::new_v4(); + let ok = |path: &str, method: &str| is_own_job_runtime_route(path, method, own); + assert!(ok( + &format!("/api/w/ws/job_metrics/set_progress/{own}"), + "POST" + )); + assert!(ok( + &format!("/api/w/ws/jobs_u/get_root_job_id/{own}"), + "GET" + )); + assert!(ok(&format!("/api/w/ws/jobs/resume_urls/{own}/0"), "GET")); + assert!(ok( + &format!("/api/w/ws/jobs/wac/inline_checkpoint/{own}"), + "POST" + )); + assert!(ok( + &format!("/api/w/ws/jobs/run/workflow_as_code/{own}/main"), + "POST" + )); + assert!(!ok( + &format!("/api/w/ws/job_metrics/set_progress/{other}"), + "POST" + )); + assert!(!ok(&format!("/api/w/ws/jobs_u/get/{own}"), "POST")); + assert!(!ok(&format!("/api/w/ws/jobs/run/p/{own}"), "POST")); + assert!(!ok("/api/w/ws/variables/get_value/u/admin/secret", "GET")); + let run = |path: &str, method: &str| flow_run_read_route_job(path, method); + assert_eq!( + run(&format!("/api/w/ws/jobs/result_by_id/{other}/b"), "GET"), + Some(other) + ); + assert_eq!( + run( + &format!("/api/w/ws/jobs_u/completed/get_result/{other}"), + "GET" + ), + Some(other) + ); + assert_eq!( + run( + &format!("/api/w/ws/jobs/flow/user_states/{other}/k"), + "POST" + ), + Some(other) + ); + assert_eq!( + run( + &format!("/api/w/ws/jobs_u/completed/delete/{other}"), + "POST" + ), + None + ); + } + #[test] fn test_scope_definition_parsing() { let scope = ScopeDefinition::from_scope_string("jobs:read").unwrap(); diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 518ad4e259..1007cfd5b1 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -804,6 +804,24 @@ async fn create_flow( check_schedule_conflict(&mut tx, &w_id, &nf.path).await?; let schema_str = nf.schema.and_then(|x| serde_json::to_string(&x.0).ok()); + let job_token_scopes = nf + .job_token_scopes + .as_ref() + .and_then(|scopes| scopes.as_deref()) + .map(windmill_common::scopes::validate_job_token_scopes) + .transpose()?; + let restricts_steps = windmill_common::scopes::validate_flow_step_job_token_scopes( + &serde_json::from_str::(nf.value.get()) + .map_err(|e| windmill_common::error::Error::BadRequest(e.to_string()))?, + )?; + if job_token_scopes.is_some() || restricts_steps { + windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES + .assert() + .await?; + } + if restricts_steps { + windmill_common::feature_usage::log_feature_usage("job_token_scopes", "deploy", "step:set"); + } let resolved_on_behalf_of = windmill_common::resolve_on_behalf_of( nf.on_behalf_of_email.as_deref(), nf.on_behalf_of.as_deref(), @@ -824,14 +842,14 @@ async fn create_flow( dedicated_worker, visible_to_runner_only, ws_error_handler_muted, value, schema, edited_by, edited_at, labels, - on_behalf_of, on_behalf_of_email + on_behalf_of, on_behalf_of_email, job_token_scopes ) VALUES ( $1, $2, $3, $4, NULL, '', $5, $6, $7, $8, $9, $10::text::json, $11, now(), $12, - $13, $14 + $13, $14, $15 )"#, w_id, nf.path, @@ -847,9 +865,11 @@ async fn create_flow( nf.labels.as_deref() as Option<&[String]>, resolved_on_behalf_of, legacy_on_behalf_of_email, + job_token_scopes.as_deref() as Option<&[String]>, ) .execute(&mut *tx) .await?; + windmill_common::scopes::log_job_token_scopes_deploy("flow", job_token_scopes.as_deref()); let version = sqlx::query_scalar!( "INSERT INTO flow_version (workspace_id, path, value, schema, created_by) @@ -974,6 +994,7 @@ async fn create_flow( None, None, None, + None, ) .await?; @@ -1153,7 +1174,7 @@ async fn get_flow_version( let mut tx = user_db.begin(&authed).await?; let flow = sqlx::query_as::<_, Flow>( - "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by + "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, flow.job_token_scopes, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by FROM flow LEFT JOIN flow_version ON flow_version.path = flow.path AND flow_version.workspace_id = flow.workspace_id WHERE flow.path = $1 AND flow.workspace_id = $2 AND flow_version.id = $3", @@ -1213,6 +1234,7 @@ async fn get_flow_version_by_id( flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, + flow.job_token_scopes, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, @@ -1368,6 +1390,27 @@ async fn update_flow( let old_dep_job = not_found_if_none(old_dep_job, "Flow", flow_path)?; let is_new_path = nf.path != flow_path; let schema_str = schema.and_then(|x| serde_json::to_string(&x).ok()); + // Absent keeps the deployed value: a client unaware of the setting must not drop a + // restriction by saving the flow. + let set_job_token_scopes = nf.job_token_scopes.is_some(); + let job_token_scopes = nf + .job_token_scopes + .as_ref() + .and_then(|scopes| scopes.as_deref()) + .map(windmill_common::scopes::validate_job_token_scopes) + .transpose()?; + let restricts_steps = windmill_common::scopes::validate_flow_step_job_token_scopes( + &serde_json::from_str::(nf.value.get()) + .map_err(|e| windmill_common::error::Error::BadRequest(e.to_string()))?, + )?; + if job_token_scopes.is_some() || restricts_steps { + windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES + .assert() + .await?; + } + if restricts_steps { + windmill_common::feature_usage::log_feature_usage("job_token_scopes", "deploy", "step:set"); + } let resolved_on_behalf_of = windmill_common::resolve_on_behalf_of( nf.on_behalf_of_email.as_deref(), nf.on_behalf_of.as_deref(), @@ -1402,7 +1445,8 @@ async fn update_flow( edited_at = now(), labels = COALESCE($13, labels), on_behalf_of = $14, - on_behalf_of_email = $15 + on_behalf_of_email = $15, + job_token_scopes = CASE WHEN $16 THEN $17 ELSE job_token_scopes END WHERE path = $11 AND workspace_id = $12", if is_new_path { flow_path } else { &nf.path }, @@ -1420,19 +1464,24 @@ async fn update_flow( nf.labels.as_deref() as Option<&[String]>, resolved_on_behalf_of, legacy_on_behalf_of_email, + set_job_token_scopes, + job_token_scopes.as_deref() as Option<&[String]>, ) .execute(&mut *tx) .await .map_err(|e| { error::Error::internal_err(format!("Error updating flow due to flow update: {e:#}")) })?; + if set_job_token_scopes { + windmill_common::scopes::log_job_token_scopes_deploy("flow", job_token_scopes.as_deref()); + } if is_new_path { // if new path, must clone flow to new path and delete old flow for flow_version foreign key constraint sqlx::query!( "INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels) - SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, job_token_scopes) + SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, job_token_scopes FROM flow WHERE path = $2 AND workspace_id = $3", nf.path, @@ -1696,6 +1745,7 @@ async fn update_flow( None, None, None, + None, ) .await?; @@ -1871,6 +1921,7 @@ async fn get_flow_by_path( flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, + flow.job_token_scopes, folder_labels(flow.workspace_id, flow.path) AS inherited_labels, flow_version.id AS version_id, flow_version.schema, @@ -1912,6 +1963,7 @@ async fn get_flow_by_path( flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, + flow.job_token_scopes, folder_labels(flow.workspace_id, flow.path) AS inherited_labels, flow_version.id AS version_id, flow_version.schema, @@ -2336,6 +2388,7 @@ mod tests { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }, FlowModule { id: "b".to_string(), @@ -2371,6 +2424,7 @@ mod tests { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }, FlowModule { id: "c".to_string(), @@ -2406,6 +2460,7 @@ mod tests { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }, ], failure_module: Some(Box::new(FlowModule { @@ -2440,6 +2495,7 @@ mod tests { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, })), preprocessor_module: None, same_worker: false, diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index 1a750c38ba..8c13483e41 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -124,7 +124,9 @@ pub async fn drop_unclaimable_run_lineage( /// The jobs of `referenced` that `authed` cannot claim as its own run lineage: anything but the /// token's own job and that job's `parent_job`, `root_job` and `flow_innermost_root_job`, or for -/// a workspace admin anything outside the workspace. +/// a workspace admin anything outside the workspace. A restricted job token never gets the admin +/// latitude: its flow-run routes trust this lineage (`job_in_same_flow_run`), so a claimed +/// unrelated run would escape its scopes. pub async fn unclaimable_run_lineage( db: &DB, w_id: &str, @@ -157,7 +159,8 @@ pub async fn unclaimable_run_lineage( if referenced.is_empty() { return Ok(referenced); } - let in_workspace = if authed.is_admin { + let restricted_job_token = authed.job_id.is_some() && authed.scopes.is_some(); + let in_workspace = if authed.is_admin && !restricted_job_token { sqlx::query_scalar!( "SELECT id FROM v2_job WHERE id = ANY($1) AND workspace_id = $2", &referenced, @@ -865,6 +868,7 @@ pub async fn run_flow<'c>( chat_input_enabled, early_return, labels, + job_token_scopes, .. } = flow_version_info; @@ -914,6 +918,7 @@ pub async fn run_flow<'c>( ) }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?; let (uuid, mut tx) = push( &db, tx, @@ -924,6 +929,7 @@ pub async fn run_flow<'c>( version, apply_preprocessor, labels, + job_token_scopes, }, push_args, authed.display_username(), @@ -950,6 +956,7 @@ pub async fn run_flow<'c>( None, authed.trigger_or_fallback(trigger), run_query.suspended_mode, + scope_ceiling.as_deref(), ) .await?; @@ -1141,6 +1148,7 @@ pub async fn push_script_job_by_path_into_queue<'c>( ) }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -1176,6 +1184,7 @@ pub async fn push_script_job_by_path_into_queue<'c>( None, authed.trigger_or_fallback(trigger), run_query.suspended_mode, + scope_ceiling.as_deref(), ) .await?; diff --git a/backend/windmill-api-jobs/src/job_metrics.rs b/backend/windmill-api-jobs/src/job_metrics.rs index abb95693f5..ddbe5a047a 100644 --- a/backend/windmill-api-jobs/src/job_metrics.rs +++ b/backend/windmill-api-jobs/src/job_metrics.rs @@ -173,10 +173,33 @@ struct JobProgressSetRequest { } async fn set_job_progress( + authed: windmill_api_auth::ApiAuthed, Extension(db): Extension, Path((w_id, job_id)): Path<(String, Uuid)>, Json(JobProgressSetRequest { percent, flow_job_id }): Json, ) -> error::JsonResult<()> { + // A restricted job token reaches this route without a scope only for its own job + // (`is_own_job_runtime_route`), so the flow it reports progress to must be its own too. + if let (Some(flow_job_id), Some(token_job), Some(_)) = + (flow_job_id, authed.job_id, &authed.scopes) + { + let own_flow = sqlx::query_scalar!( + "SELECT $2 IN (parent_job, root_job, flow_innermost_root_job) FROM v2_job + WHERE id = $1 AND workspace_id = $3", + token_job, + flow_job_id, + &w_id + ) + .fetch_optional(&db) + .await? + .flatten() + .unwrap_or(false); + if !own_flow { + return Err(error::Error::PermissionDenied(format!( + "flow job {flow_job_id} is not this job's flow" + ))); + } + } // If flow_job_id exists, than we should modify flow_status of corresponding module // Individual jobs and flows are handled differently if let Some(flow_job_id) = flow_job_id { diff --git a/backend/windmill-api-jobs/src/jobs_export.rs b/backend/windmill-api-jobs/src/jobs_export.rs index 6389134031..d60edc3a3f 100644 --- a/backend/windmill-api-jobs/src/jobs_export.rs +++ b/backend/windmill-api-jobs/src/jobs_export.rs @@ -112,6 +112,8 @@ pub struct ExportableQueuedJob { pub preprocessed: Option, pub args: Option>>, pub labels: Option>, + #[serde(default)] + pub job_token_scopes: Option>, pub pre_run_error: Option, // v2_job_queue columns (excluding workspace_id and id/created_at/tag/priority) @@ -276,6 +278,7 @@ pub async fn export_queued_jobs( v2_job.preprocessed, v2_job.args as "args: _", v2_job.labels, + job_perms.job_token_scopes as "job_token_scopes?", v2_job.pre_run_error, v2_job_queue.started_at, @@ -298,6 +301,7 @@ pub async fn export_queued_jobs( concurrency_key.key as "concurrency_key?" FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id + LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id LEFT JOIN v2_job_runtime ON v2_job_runtime.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id LEFT JOIN concurrency_key ON concurrency_key.job_id = v2_job_queue.id @@ -445,6 +449,23 @@ pub async fn import_queued_jobs( )); } + // Imported rows bypass `push`, so nothing would cap their tokens: a restricted job token + // could otherwise queue arbitrary jobs that run with its owner's full permissions. + if authed.job_id.is_some() && authed.scopes.is_some() { + return Err(error::Error::PermissionDenied( + "A restricted job token cannot import queued jobs".to_string(), + )); + } + // An imported job gets no `job_perms` row, so its token would be minted without the + // restriction it was queued with. + if let Some(job) = jobs.iter().find(|job| job.job_token_scopes.is_some()) { + return Err(error::Error::BadRequest(format!( + "Queued job {} restricts its job token and cannot be imported; run it again on \ + this instance instead", + job.id + ))); + } + let mut tx = user_db.begin(&authed).await?; for job in jobs { diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 6edc6e2e59..f622c4bb91 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -899,6 +899,7 @@ async fn is_noop_deploy_against_parent( ns: &NewScript, parent: &Script, resolved_on_behalf_of: Option<&str>, + resolved_job_token_scopes: Option<&[String]>, db: &DB, ) -> Result { if parent.archived || parent.deleted { @@ -952,6 +953,8 @@ async fn is_noop_deploy_against_parent( // caller-intent flag (auto-resolve parent), not script state auto_parent: _, labels, + // resolved against the deployed value into `resolved_job_token_scopes`, compared below + job_token_scopes: _, // caller-intent flag (preserve user drafts on CLI/git-sync deploys); // transient, never persisted, does not change what the script *is* skip_draft_deletion: _, @@ -1025,6 +1028,9 @@ async fn is_noop_deploy_against_parent( if resolved_on_behalf_of != parent.on_behalf_of.as_deref() { return Ok(false); } + if resolved_job_token_scopes != parent.job_token_scopes.as_deref() { + return Ok(false); + } // Both of a dbt script's derived fields are compared as they WOULD BE STORED, // not as they arrived: the schema comes from the descriptor and the clients // cannot derive it (`windmill-parser-wasm` has no dbt arm), so they send the @@ -1492,6 +1498,37 @@ async fn create_script_internal<'c>( parent_adopted_from_retired_path = ns.parent_hash.is_some(); } + // Absent keeps the previous version's value, read once the parent is settled (a rename + // adopts its source head above), so a client unaware of the setting cannot drop a + // restriction by redeploying or renaming. + let resolved_job_token_scopes: Option> = match (&ns.job_token_scopes, &ns.parent_hash) { + (Some(Some(scopes)), _) => { + windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES + .assert() + .await?; + Some(windmill_common::scopes::validate_job_token_scopes(scopes)?) + } + (Some(None), _) => None, + (None, Some(parent_hash)) => sqlx::query_scalar!( + "SELECT job_token_scopes FROM script WHERE hash = $1 AND workspace_id = $2", + parent_hash.0, + &w_id + ) + .fetch_optional(&db) + .await? + .flatten(), + (None, None) => sqlx::query_scalar!( + "SELECT job_token_scopes FROM script WHERE path = $1 AND workspace_id = $2 \ + AND deleted = false ORDER BY created_at DESC LIMIT 1", + &ns.path, + &w_id + ) + .fetch_optional(&db) + .await? + .flatten(), + }; + ns.job_token_scopes = Some(resolved_job_token_scopes.clone()); + // Before hashing, so the hash and the no-op check see the schema that gets stored. // `{}` counts as absent: an agent filling every tool argument sends it for "none". let schema_absent = ns.schema.as_ref().is_none_or(|s| { @@ -1620,8 +1657,14 @@ async fn create_script_internal<'c>( // CLI pushes must not produce phantom commits on the downstream // git repository. if skip_if_noop - && is_noop_deploy_against_parent(&ns, &ps, resolved_on_behalf_of.as_deref(), &db) - .await? + && is_noop_deploy_against_parent( + &ns, + &ps, + resolved_on_behalf_of.as_deref(), + resolved_job_token_scopes.as_deref(), + &db, + ) + .await? { tracing::info!( workspace_id = %w_id, @@ -2197,8 +2240,8 @@ async fn create_script_internal<'c>( content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, \ envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \ dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ - delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels, on_behalf_of, on_behalf_of_email) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41)", + delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels, on_behalf_of, on_behalf_of_email, job_token_scopes) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42)", &w_id, &hash.0, ns.path, @@ -2242,9 +2285,14 @@ async fn create_script_internal<'c>( ns.labels.as_deref() as Option<&[String]>, resolved_on_behalf_of, legacy_on_behalf_of_email, + resolved_job_token_scopes.as_deref() as Option<&[String]>, ) .execute(&mut *tx) .await?; + windmill_common::scopes::log_job_token_scopes_deploy( + "script", + resolved_job_token_scopes.as_deref(), + ); // A lock that is not left to a dependency job queues none, so this is the only place its hash // can be recorded. `try_skip_relock` treats a missing hash for an imported script as changed, @@ -2954,6 +3002,7 @@ async fn create_script_internal<'c>( None, None, None, + None, ) .await?; @@ -3077,6 +3126,7 @@ async fn create_script_internal<'c>( None, None, None, + None, ) .await?; tracing::info!("pushed auto-build binary job {job_id} for {script_path}"); diff --git a/backend/windmill-api-workspaces/src/datatable_migrations.rs b/backend/windmill-api-workspaces/src/datatable_migrations.rs index f762fda175..988c430b20 100644 --- a/backend/windmill-api-workspaces/src/datatable_migrations.rs +++ b/backend/windmill-api-workspaces/src/datatable_migrations.rs @@ -235,6 +235,7 @@ async fn run_datatable_migration_job( args.insert("database".to_string(), database_arg.clone()); let push_args = PushArgs { extra: None, args: &args }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?; let (uuid, mut tx) = push( db, PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()), @@ -278,6 +279,7 @@ async fn run_datatable_migration_job( None, None, None, + scope_ceiling.as_deref(), ) .await?; diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 81fead981b..ac838d772b 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -7637,7 +7637,7 @@ async fn clone_scripts( dedicated_worker, ws_error_handler_muted, priority, timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, - on_behalf_of, on_behalf_of_email, assets, modules + on_behalf_of, on_behalf_of_email, assets, modules, job_token_scopes ) SELECT $1, hash, path, parent_hashes, summary, description, content, @@ -7669,7 +7669,7 @@ async fn clone_scripts( UNION ALL SELECT 1 FROM password p WHERE p.email = on_behalf_of AND p.super_admin)) - END, on_behalf_of_email, assets, modules + END, on_behalf_of_email, assets, modules, job_token_scopes FROM script WHERE workspace_id = $2"#, target_workspace_id, @@ -8027,7 +8027,8 @@ async fn clone_flows( workspace_id, path, summary, description, value, edited_by, edited_at, archived, schema, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, - concurrency_key, versions, on_behalf_of, on_behalf_of_email, lock_error_logs + concurrency_key, versions, on_behalf_of, on_behalf_of_email, lock_error_logs, + job_token_scopes ) SELECT $2, path, summary, description, value, edited_by, edited_at, archived, schema, extra_perms, NULL, tag, @@ -8053,7 +8054,7 @@ async fn clone_flows( UNION ALL SELECT 1 FROM password p WHERE p.email = on_behalf_of AND p.super_admin)) - END, on_behalf_of_email, lock_error_logs + END, on_behalf_of_email, lock_error_logs, job_token_scopes FROM flow WHERE workspace_id = $1", source_workspace_id, diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 8ca98f66d1..7e2958bb58 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -408,8 +408,8 @@ pub(crate) async fn change_workspace_id( info!("Duplicating flow table rows"); sqlx::query!( "INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs) - SELECT $1, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs, job_token_scopes) + SELECT $1, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs, job_token_scopes FROM flow WHERE workspace_id = $2", &rw.new_id, &old_id diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d9aeb63707..3b915f0b55 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -30150,6 +30150,12 @@ components: items: type: string default: [] + job_token_scopes: + type: array + nullable: true + items: + type: string + description: "Scopes the token of every job of this runnable is restricted to (domain:action[:resource], e.g. oidc:write). Jobs it starts, flow steps and AI agent tools inherit the restriction. Unset, the job token carries the full permissions of the identity the job runs as; an empty list leaves it no API access beyond its own job." inherited_labels: type: array items: @@ -30307,6 +30313,12 @@ components: type: array items: type: string + job_token_scopes: + type: array + nullable: true + items: + type: string + description: "Scopes the token of every job of this runnable is restricted to (domain:action[:resource], e.g. oidc:write). Jobs it starts, flow steps and AI agent tools inherit the restriction. Unset, the job token carries the full permissions of the identity the job runs as; an empty list leaves it no API access beyond its own job. Omitting the field keeps the deployed value; null clears it." skip_draft_deletion: type: boolean description: "When true (set by the CLI / git sync), deploying this script does not delete an existing user draft at the same path." @@ -36165,6 +36177,12 @@ components: items: type: string default: [] + job_token_scopes: + type: array + nullable: true + items: + type: string + description: "Scopes the token of every job of this runnable is restricted to (domain:action[:resource], e.g. oidc:write). Jobs it starts, flow steps and AI agent tools inherit the restriction. Unset, the job token carries the full permissions of the identity the job runs as; an empty list leaves it no API access beyond its own job." inherited_labels: type: array items: @@ -36211,6 +36229,12 @@ components: type: array items: type: string + job_token_scopes: + type: array + nullable: true + items: + type: string + description: "Scopes the token of every job of this runnable is restricted to (domain:action[:resource], e.g. oidc:write). Jobs it starts, flow steps and AI agent tools inherit the restriction. Unset, the job token carries the full permissions of the identity the job runs as; an empty list leaves it no API access beyond its own job. Omitting the field keeps the deployed value; null clears it." required: - path # Like OpenFlowWPath but `path` is optional: on update the flow is identified by @@ -36247,6 +36271,12 @@ components: type: array items: type: string + job_token_scopes: + type: array + nullable: true + items: + type: string + description: "Scopes the token of every job of this runnable is restricted to (domain:action[:resource], e.g. oidc:write). Jobs it starts, flow steps and AI agent tools inherit the restriction. Unset, the job token carries the full permissions of the identity the job runs as; an empty list leaves it no API access beyond its own job. Omitting the field keeps the deployed value; null clears it." FlowPreview: type: object diff --git a/backend/windmill-api/src/agent_runs.rs b/backend/windmill-api/src/agent_runs.rs index d2e76dcca0..b844e8c2bf 100644 --- a/backend/windmill-api/src/agent_runs.rs +++ b/backend/windmill-api/src/agent_runs.rs @@ -84,6 +84,7 @@ pub(crate) async fn run_agent( )?; let push_authed = authed.clone().into(); + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, mut tx) = push( &db, PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()), @@ -118,6 +119,7 @@ pub(crate) async fn run_agent( None, authed.trigger_or_fallback(None), None, + scope_ceiling.as_deref(), ) .await?; diff --git a/backend/windmill-api/src/ai_evals/run.rs b/backend/windmill-api/src/ai_evals/run.rs index 42c2fcb2f2..57e75b3f9f 100644 --- a/backend/windmill-api/src/ai_evals/run.rs +++ b/backend/windmill-api/src/ai_evals/run.rs @@ -760,6 +760,7 @@ async fn push_run_flow( let path = subject.path.clone(); let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?; let (uuid, tx) = push( db, tx, @@ -790,6 +791,7 @@ async fn push_run_flow( None, authed.trigger_or_fallback(None), None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 69349a4134..f276e6d143 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -2756,6 +2756,7 @@ async fn create_app_internal<'a>( None, None, None, + None, ) .await?; tracing::info!("Pushed app dependency job {}", dependency_job_uuid); @@ -3897,6 +3898,7 @@ async fn update_app_internal<'a>( None, None, None, + None, ) .await?; tracing::info!("Pushed app dependency job {}", dependency_job_uuid); @@ -4491,6 +4493,10 @@ async fn execute_component( let app_trigger = (!is_preview).then(|| TriggerMetadata::new(Some(path.to_string()), JobTriggerKind::App)); + let scope_ceiling = match opt_authed.as_ref() { + Some(authed) => windmill_api_auth::caller_scope_ceiling(&db, authed).await?, + None => None, + }; let (uuid, mut tx) = push( &db, tx, @@ -4526,6 +4532,7 @@ async fn execute_component( end_user_email, app_trigger, None, + scope_ceiling.as_deref(), ) .await?; diff --git a/backend/windmill-api/src/apps_raw_bundle.rs b/backend/windmill-api/src/apps_raw_bundle.rs index f50f94f8eb..61dc4b861d 100644 --- a/backend/windmill-api/src/apps_raw_bundle.rs +++ b/backend/windmill-api/src/apps_raw_bundle.rs @@ -148,6 +148,7 @@ pub(crate) async fn bundle_raw_app_sources( args.insert("runnables".to_string(), runnables.to_owned()); let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?; let (uuid, tx) = push( db, tx, @@ -191,6 +192,7 @@ pub(crate) async fn bundle_raw_app_sources( None, authed.trigger_or_fallback(None), None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; diff --git a/backend/windmill-api/src/concurrency_groups.rs b/backend/windmill-api/src/concurrency_groups.rs index c94608ac7e..aab73c81d9 100644 --- a/backend/windmill-api/src/concurrency_groups.rs +++ b/backend/windmill-api/src/concurrency_groups.rs @@ -56,6 +56,7 @@ async fn get_concurrency_key( .get_authed(Some(job.workspace_id.clone()), &token) .await .ok_or_else(not_found)?; + windmill_api_auth::check_job_token_scope(&authed_in_workspace, || "jobs:read".to_string())?; require_job_read_access( &db, diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 00139215c4..b3a5e86b46 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -7353,6 +7353,7 @@ pub async fn restart_flow( let tx = PushIsolationLevel::Isolated(user_db, authed.clone().into()); + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -7390,6 +7391,7 @@ pub async fn restart_flow( None, authed.trigger_or_fallback(None), run_query.suspended_mode, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -7527,7 +7529,11 @@ pub async fn run_workflow_as_code( extra.insert(ENTRYPOINT_OVERRIDE.to_string(), to_raw_value(&entrypoint)); let args = PushArgs { args: &task.args.unwrap_or_else(HashMap::new), extra: Some(extra) }; check_tag_available_for_workspace(&db, &w_id, &run_query.tag, &args, &authed).await?; - check_scopes(&authed, || format!("jobs:run"))?; + // A job running its own task is its runtime, not a new run: a restricted job token keeps + // it (`is_own_job_runtime_route`), and the task inherits that job's restriction at push. + if authed.job_id != Some(job_id) { + check_scopes(&authed, || format!("jobs:run"))?; + } // The task becomes a child of `job_id`, runs its code and writes into its flow status, so // only that job itself (the SDK's `task` wrapper, on its `WM_TOKEN`) or an admin may push it. if authed.job_id != Some(job_id) && !authed.is_admin { @@ -7672,6 +7678,7 @@ pub async fn run_workflow_as_code( ) }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, mut tx) = push( &db, tx, @@ -7702,6 +7709,7 @@ pub async fn run_workflow_as_code( None, None, None, + scope_ceiling.as_deref(), ) .await?; @@ -7985,6 +7993,7 @@ pub async fn run_wait_result_job_by_path_get( ) }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -8015,6 +8024,7 @@ pub async fn run_wait_result_job_by_path_get( None, authed.trigger_or_fallback(None), run_query.suspended_mode, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -8133,6 +8143,7 @@ pub async fn run_wait_result_script_by_path_internal( ) }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -8163,6 +8174,7 @@ pub async fn run_wait_result_script_by_path_internal( None, authed.trigger_or_fallback(None), run_query.suspended_mode, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -8215,6 +8227,7 @@ pub async fn run_wait_result_script_by_hash( timeout, has_preprocessor, labels, + job_token_scopes, runnable_settings: ScriptRunnableSettingsInline { concurrency_settings, debouncing_settings }, .. @@ -8248,6 +8261,7 @@ pub async fn run_wait_result_script_by_hash( ) }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -8265,6 +8279,7 @@ pub async fn run_wait_result_script_by_hash( apply_preprocessor: !run_query.skip_preprocessor.unwrap_or(false) && has_preprocessor.unwrap_or(false), labels, + job_token_scopes, }, push_args, authed.display_username(), @@ -8291,6 +8306,7 @@ pub async fn run_wait_result_script_by_hash( None, authed.trigger_or_fallback(None), run_query.suspended_mode, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -8810,6 +8826,7 @@ async fn run_preview_script( let push_args = PushArgs { extra, args: &preview_args }; check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -8860,6 +8877,7 @@ async fn run_preview_script( None, authed.trigger_or_fallback(None), None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -8944,7 +8962,8 @@ async fn run_inline_script_by_path( token, db, w_id, - InlineScriptTarget::Path(script_path.to_path().to_string()), + // Resolved to a version by `run_inline_script_inner`. + InlineScriptTarget::Path { path: script_path.to_path().to_string(), hash: 0 }, body.args, Some(user_db), ) @@ -9007,6 +9026,42 @@ async fn run_inline_script_inner( args: Option>>, user_db: Option, ) -> error::Result { + // An inline run executes with the caller's own token, so it cannot honour a script's + // `job_token_scopes`: such a script only runs as a job. A path is resolved here, once, and + // the version checked is the version run. + let (target, restricted) = match target { + InlineScriptTarget::Path { path, .. } => { + let authed_ref = authed.to_authed_ref(); + let info = get_latest_deployed_hash_for_path( + user_db + .as_ref() + .map(|db| UserDbWithAuthed { db: db.clone(), authed: &authed_ref }), + db.clone(), + &w_id, + &path, + ) + .await?; + let restricted = info.job_token_scopes.is_some(); + ( + InlineScriptTarget::Path { path, hash: info.hash }, + restricted, + ) + } + InlineScriptTarget::Hash(hash) => { + let restricted = windmill_common::get_script_info_for_hash(None, &db, &w_id, hash) + .await? + .job_token_scopes + .is_some(); + (InlineScriptTarget::Hash(hash), restricted) + } + }; + if restricted { + return Err(Error::BadRequest( + "This script restricts its job token (job_token_scopes) and cannot be run inline; \ + run it as a job instead" + .to_string(), + )); + } let utils = get_worker_internal_server_inline_utils()?; let authed_owned: windmill_common::db::Authed = authed.clone().into(); let result = utils.run_inline_script.as_ref()(RunInlineScriptFnParams { @@ -9193,6 +9248,7 @@ async fn run_bundle_preview_script( // tracing::info!("is_tar 1: {is_tar}"); // hmap.insert("") + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, ntx) = push( &db, ltx, @@ -9239,6 +9295,7 @@ async fn run_bundle_preview_script( None, authed.trigger_or_fallback(None), None, + scope_ceiling.as_deref(), ) .await?; job_id = Some(uuid); @@ -9358,6 +9415,7 @@ async fn push_dependencies_job( req.temp_script_refs .map(|v| hm.insert("temp_script_refs".to_owned(), to_raw_value(&v))); + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?; let (uuid, tx) = push( db, PushIsolationLevel::IsolatedRoot(db.clone()), @@ -9392,6 +9450,7 @@ async fn push_dependencies_job( None, None, None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -9490,6 +9549,7 @@ async fn push_flow_dependencies_job( req.temp_script_refs .map(|v| args_map.insert("temp_script_refs".to_string(), to_raw_value(&v))); + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?; let (uuid, tx) = push( db, PushIsolationLevel::IsolatedRoot(db.clone()), @@ -9520,6 +9580,7 @@ async fn push_flow_dependencies_job( None, None, None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -9574,6 +9635,7 @@ async fn add_batch_jobs( ) -> error::JsonResult> { require_super_admin(&db, &authed).await?; + let mut job_token_scopes: Option> = None; let ( hash, path, @@ -9605,11 +9667,13 @@ async fn add_batch_jobs( dedicated_worker, timeout, runnable_settings, + job_token_scopes: script_job_token_scopes, .. // TODO: consider on_behalf_of_email and created_by for batch jobs } = get_latest_deployed_hash_for_path(Some(db_authed), db.clone(), &w_id, &path) .await? .prefetch_cached(&db) .await?; + job_token_scopes = script_job_token_scopes; ( Some(script_hash), Some(path), @@ -9656,7 +9720,7 @@ async fn add_batch_jobs( } else if let Some(path) = batch_info.path { let mut tx = user_db.clone().begin(&authed).await?; let value_json = sqlx::query!( - "SELECT coalesce(flow_version_lite.value, flow_version.value) as \"value!: sqlx::types::Json>\" FROM flow + "SELECT coalesce(flow_version_lite.value, flow_version.value) as \"value!: sqlx::types::Json>\", flow.job_token_scopes FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] LEFT JOIN flow_version_lite @@ -9667,6 +9731,7 @@ async fn add_batch_jobs( .fetch_optional(&mut *tx) .await? .ok_or_else(|| Error::internal_err(format!("not found flow at path {:?}", path)))?; + job_token_scopes = value_json.job_token_scopes; let value = serde_json::from_str::(value_json.value.get()).map_err(|err| { Error::internal_err(format!( @@ -9679,6 +9744,11 @@ async fn add_batch_jobs( "Path is required if no value is not provided" ))? }; + if windmill_common::scopes::validate_flow_step_job_token_scopes(&value)? { + windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES + .assert() + .await?; + } add_virtual_items_if_necessary(&mut value.modules); let flow_status = FlowStatus::new(&value); ( @@ -9722,7 +9792,8 @@ async fn add_batch_jobs( let language = language.unwrap_or(ScriptLang::Deno); let tag = if let Some(dedicated_worker) = dedicated_worker { - if dedicated_worker && path.is_some() { + // Same rule as `push`: a dedicated worker would run it with its own unscoped token. + if dedicated_worker && path.is_some() && job_token_scopes.is_none() { windmill_common::worker::dedicated_worker_tag(&w_id, &path.clone().unwrap()) } else { format!("{}", language.as_str()) @@ -9790,8 +9861,8 @@ async fn add_batch_jobs( .await?; sqlx::query!( - "INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id) - SELECT unnest($1::uuid[]), $2, $3, $4, $5, $6, $7, $8", + "INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, job_token_scopes) + SELECT unnest($1::uuid[]), $2, $3, $4, $5, $6, $7, $8, $9", &uuids, authed.email, authed.username, @@ -9800,6 +9871,7 @@ async fn add_batch_jobs( &[], &[], w_id, + job_token_scopes.as_deref() as Option<&[String]>, ) .execute(&mut *tx) .await?; @@ -9850,6 +9922,13 @@ async fn run_preview_flow_job( // jobs:run scope so a narrowly-scoped token cannot escape its scope. See run_preview_script. check_scopes(&authed, || format!("jobs:run"))?; require_path_read_access_for_preview(&authed, &raw_flow.path)?; + // Step restrictions apply to a preview as they do to a deployed run, so they are checked + // the same way: an invalid entry or an older worker would leave a step unrestricted. + if windmill_common::scopes::validate_flow_step_job_token_scopes(&raw_flow.value)? { + windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES + .assert() + .await?; + } // A builder must be able to test what it composes, but the submitted value is not the stored // one: without this the preview is a way to run inline code the write path refuses. if authed.is_operator { @@ -9892,6 +9971,7 @@ async fn run_preview_flow_job( check_tag_available_for_workspace(&db, &w_id, &tag, &PushArgs::from(&flow_args), &authed) .await?; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, mut tx) = push( &db, tx, @@ -9926,6 +10006,7 @@ async fn run_preview_flow_job( None, authed.trigger_or_fallback(None), None, + scope_ceiling.as_deref(), ) .await?; @@ -10149,6 +10230,7 @@ async fn run_dynamic_select( let scheduled_for = run_query.get_scheduled_for(&db).await?; let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -10195,6 +10277,7 @@ async fn run_dynamic_select( None, authed.trigger_or_fallback(None), None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -10270,6 +10353,7 @@ pub async fn run_job_by_hash_inner( delete_after_use, delete_after_secs, labels, + job_token_scopes, .. } = script_info; @@ -10302,6 +10386,7 @@ pub async fn run_job_by_hash_inner( ) }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -10319,6 +10404,7 @@ pub async fn run_job_by_hash_inner( apply_preprocessor: !run_query.skip_preprocessor.unwrap_or(false) && has_preprocessor.unwrap_or(false), labels, + job_token_scopes, }, push_args, authed.display_username(), @@ -10345,6 +10431,7 @@ pub async fn run_job_by_hash_inner( None, authed.trigger_or_fallback(trigger), run_query.suspended_mode, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index 472c7bbec9..8f3962da13 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -372,7 +372,8 @@ impl McpBackend for WindmillBackend { token: &str, workspace_id: &str, ) -> BackendResult { - self.auth_cache + let authed = self + .auth_cache .get_authed(Some(workspace_id.to_string()), token) .await .ok_or_else(|| { @@ -383,7 +384,11 @@ impl McpBackend for WindmillBackend { ), None, ) - }) + })?; + // Job token scopes never include MCP scopes, so a restricted job token gets none. + windmill_api_auth::check_job_token_scope(&authed, || "mcp:all".to_string()) + .map_err(|e| ErrorData::invalid_params(e.to_string(), None))?; + Ok(authed) } async fn runnable_list_fingerprint(&self, workspace_id: &str) -> BackendResult { diff --git a/backend/windmill-api/src/offboarding.rs b/backend/windmill-api/src/offboarding.rs index 26eb06c8e8..b7f83dfd96 100644 --- a/backend/windmill-api/src/offboarding.rs +++ b/backend/windmill-api/src/offboarding.rs @@ -891,8 +891,8 @@ async fn offboard_user_from_workspace<'c>( let flows_reassigned = sqlx::query_scalar!( r#"WITH inserted AS ( INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs) - SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs, job_token_scopes) + SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs, job_token_scopes FROM flow WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3 RETURNING 1 diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index a7f9aeda89..39a213c860 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -665,8 +665,8 @@ async fn update_username_in_workpsace<'c>( // ---- flows ---- sqlx::query!( r#"INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at) - SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, job_token_scopes) + SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, job_token_scopes FROM flow WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, new_username, diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index ea79aaa572..3c3e965248 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -119,6 +119,8 @@ struct ScriptMetadata { pub debouncing_settings: DebouncingSettings, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub job_token_scopes: Option>, #[serde(skip_serializing_if = "is_empty_extra_perms")] pub extra_perms: serde_json::Value, } @@ -906,6 +908,7 @@ pub(crate) async fn tarball_workspace( .then_some(true), modules: script.modules, labels: script.labels, + job_token_scopes: script.job_token_scopes, // Same opt-in contract as flow/app: the tarball only surfaces // ACLs when `?preserve_extra_perms=true`. Passing `Null` lets the // `is_empty_extra_perms` skip-serializer drop the field entirely. @@ -966,7 +969,7 @@ pub(crate) async fn tarball_workspace( { let flows = sqlx::query_as::<_, Flow>( - "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by + "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, flow.job_token_scopes, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.workspace_id = $1 AND flow.archived = false", diff --git a/backend/windmill-common/src/auth.rs b/backend/windmill-common/src/auth.rs index 86b7f173ac..17310bdbbc 100644 --- a/backend/windmill-common/src/auth.rs +++ b/backend/windmill-common/src/auth.rs @@ -362,6 +362,10 @@ pub struct JobPerms { pub groups: Vec, pub folders: Vec, pub end_user_email: Option, + /// The job's effective scopes, stored on its `job_perms` row at push and minted into its + /// token. + #[serde(default)] + pub job_token_scopes: Option>, } impl From for Authed { @@ -654,7 +658,9 @@ pub async fn get_job_perms<'a, E: sqlx::PgExecutor<'a>>( ) -> sqlx::Result> { sqlx::query_as!( JobPerms, - "SELECT email, username, is_admin, is_operator, groups, folders, end_user_email FROM job_perms WHERE job_id = $1 AND workspace_id = $2", + "SELECT email, username, is_admin, is_operator, groups, folders, end_user_email, + job_token_scopes + FROM job_perms WHERE job_id = $1 AND workspace_id = $2", job_id, w_id ) @@ -745,17 +751,29 @@ pub async fn create_token_for_owner( } else { get_job_perms(db, job_id, w_id).await }; - let job_authed = match job_perms { - Ok(Some(jp)) => jp.into(), - _ => { + let (job_authed, job_token_scopes) = match job_perms { + Ok(Some(mut jp)) => { + let scopes = jp.job_token_scopes.take(); + (jp.into(), scopes) + } + // A failed read must not mint as if the job had no row: that would drop its restriction. + Err(e) => { + return Err(Error::internal_err(format!( + "Could not read permissions for job {job_id}: {e:#}" + ))) + } + // Push writes a job's `job_perms` row and its scopes in one statement, so a job with no + // row was never restricted. + Ok(None) => { tracing::warn!("Could not get permissions for job {job_id} from job_perms table, getting permissions directly..."); - fetch_authed_from_permissioned_as(owner, email, w_id, db) + let authed = fetch_authed_from_permissioned_as(owner, email, w_id, db) .await .map_err(|e| { Error::internal_err(format!( "Could not get permissions directly for job {job_id}: {e:#}" )) - })? + })?; + (authed, None) } }; @@ -766,7 +784,7 @@ pub async fn create_token_for_owner( Some(*job_id), Some(label.to_string()), audit_span, - None, + crate::scopes::job_token_jwt_scopes(job_token_scopes), ) .await } diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index 796d1c999a..d437881b7b 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -136,6 +136,7 @@ pub async fn script_path_to_payload<'e>( timeout, has_preprocessor, labels, + job_token_scopes, .. } = script_info; @@ -153,6 +154,7 @@ pub async fn script_path_to_payload<'e>( debouncing_settings, concurrency_settings, labels, + job_token_scopes, }, tag, delete_after_use, @@ -203,7 +205,7 @@ pub async fn get_payload_tag_from_prefixed_path( None, ) } else { - let FlowVersionInfo { dedicated_worker, tag, version, labels, .. } = + let FlowVersionInfo { dedicated_worker, tag, version, labels, job_token_scopes, .. } = get_latest_flow_version_info_for_path(None, &db, w_id, &path, true).await?; ( JobPayload::Flow { @@ -212,6 +214,7 @@ pub async fn get_payload_tag_from_prefixed_path( apply_preprocessor: false, version, labels, + job_token_scopes, }, tag, None, @@ -481,7 +484,8 @@ pub struct RunInlinePreviewScriptFnParams { } pub enum InlineScriptTarget { - Path(String), + /// A script addressed by path, pinned to the version the caller resolved and checked. + Path { path: String, hash: i64 }, Hash(i64), } diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 1013baa2e0..704948dd87 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -127,6 +127,7 @@ pub mod runnable_settings; pub mod runnables; pub mod schedule; pub mod schema; +pub mod scopes; pub mod scripts; pub mod secret_backend; pub mod sensitive_log_masks; @@ -2247,6 +2248,7 @@ pub struct ScriptHashInfo { pub on_behalf_of: Option, pub created_by: String, pub labels: Option>, + pub job_token_scopes: Option>, #[sqlx(flatten)] pub runnable_settings: SR, } @@ -2340,6 +2342,7 @@ impl ScriptHashInfo { on_behalf_of: self.on_behalf_of, created_by: self.created_by, labels: self.labels, + job_token_scopes: self.job_token_scopes, runnable_settings: ScriptRunnableSettingsInline { concurrency_settings: concurrency_settings.maybe_fallback( self.runnable_settings.concurrency_key, @@ -2704,6 +2707,7 @@ async fn get_script_info_for_hash_inner<'e, E: sqlx::PgExecutor<'e>>( on_behalf_of, created_by, labels, + job_token_scopes, path FROM script WHERE hash = $1 AND workspace_id = $2", ) @@ -2725,6 +2729,7 @@ pub struct FlowVersionInfo { pub edited_by: String, pub dedicated_worker: Option, pub labels: Option>, + pub job_token_scopes: Option>, } impl FlowVersionInfo { @@ -2868,7 +2873,8 @@ pub fn get_flow_version_info_from_version< flow.dedicated_worker, flow.on_behalf_of, flow.edited_by, - flow.labels + flow.labels, + flow.job_token_scopes FROM flow_version INNER JOIN flow @@ -3003,9 +3009,10 @@ pub async fn get_latest_hash_for_path<'c, E: sqlx::PgExecutor<'c>>( Option, Option, Option>, + Option>, )> { let r_o = sqlx::query!( - "select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, cache_ignore_s3_path, runnable_settings_handle, language as \"language: ScriptLang\", dedicated_worker, priority, timeout, on_behalf_of, created_by, labels FROM script + "select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, cache_ignore_s3_path, runnable_settings_handle, language as \"language: ScriptLang\", dedicated_worker, priority, timeout, on_behalf_of, created_by, labels, job_token_scopes FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false AND (lock IS NOT NULL OR $3 = false) ORDER BY created_at DESC LIMIT 1", script_path, @@ -3037,6 +3044,7 @@ pub async fn get_latest_hash_for_path<'c, E: sqlx::PgExecutor<'c>>( on_behalf_of, script.runnable_settings_handle, script.labels, + script.job_token_scopes, )) } diff --git a/backend/windmill-common/src/min_version.rs b/backend/windmill-common/src/min_version.rs index 2ae666c14e..930b718b7e 100644 --- a/backend/windmill-common/src/min_version.rs +++ b/backend/windmill-common/src/min_version.rs @@ -19,6 +19,10 @@ pub const MIN_VERSION_SUPPORTS_BINARY_PREBUILD: VC = vc(1, 789, 0, "Auto-build b // dependency job asks bun for a v1 lockfile, and refuses to store one bun raised anyway. // Must name the release this ships in. pub const MIN_VERSION_SUPPORTS_BUN_LOCKFILE_V2: VC = vc(1, 794, 0, "Bun v2 lockfiles"); +// A worker that predates `job_token_scopes` mints the token of every job it pulls without +// them, so a restriction set while one is live is silently ignored on the jobs it runs. Must +// name the release this ships in. +pub const MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES: VC = vc(1, 822, 0, "Restricted job tokens"); pub const MIN_VERSION_SUPPORTS_NODE_DEBOUNCING: VC = vc(1, 658, 0, "Flow node debouncing"); pub const MIN_VERSION_SUPPORTS_TOKEN_HASH: VC = vc(1, 659, 0, "Token hash storage"); pub const MIN_VERSION_SUPPORTS_SYNC_JOBS_DEBOUNCING: VC = vc(1, 602, 0, "Sync jobs debouncing"); diff --git a/backend/windmill-common/src/scopes.rs b/backend/windmill-common/src/scopes.rs new file mode 100644 index 0000000000..35f4497fac --- /dev/null +++ b/backend/windmill-common/src/scopes.rs @@ -0,0 +1,748 @@ +/* + * Author: Windmill Labs, Inc + * Copyright: Windmill Labs, Inc 2024 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +use crate::error::{Error, Result}; + +/// Comprehensive scope system for JWT token authorization +/// +/// Scopes follow the format: {domain}:{action}[:{resource}] +/// Examples: +/// - "jobs:read" - Read access to jobs +/// - "scripts:write:f/folder/*" - Write access to scripts in a folder +/// - "*" - Full access (superuser) + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScopeDefinition { + pub domain: String, + pub action: String, + pub kind: Option, // For jobs:run:kind (optional) + pub resource: Option>, +} + +impl ScopeDefinition { + pub fn new( + domain: &str, + action: &str, + kind: Option<&str>, + resource: Option>, + ) -> Self { + Self { + domain: domain.to_string(), + action: action.to_string(), + kind: kind.map(|s| s.to_string()), + resource: resource, + } + } + + pub fn from_scope_string(scope: &str) -> Result { + let parts: Vec<&str> = scope.split(':').collect(); + + let into_owned_vec = |resources: &str| -> Vec { + let resources = resources + .split(",") + .collect_vec() + .into_iter() + .map(ToOwned::to_owned) + .collect_vec(); + + resources + }; + + match parts.len() { + 2 => Ok(Self::new(parts[0], parts[1], None, None)), // domain:action + 3 => { + if parts[0] == "jobs" && parts[1] == "run" { + Ok(Self::new(parts[0], parts[1], Some(parts[2]), None)) + } else { + Ok(Self::new( + parts[0], + parts[1], + None, + Some(into_owned_vec(parts[2])), + )) + } + } + 4 => { + if parts[0] == "jobs" && parts[1] == "run" { + Ok(Self::new( + parts[0], + parts[1], + Some(parts[2]), + Some(into_owned_vec(parts[3])), + )) + } else { + Err(Error::BadRequest(format!( + "Invalid 4-part scope: {}", + scope + ))) + } + } + _ => Err(Error::BadRequest(format!( + "Invalid scope format: {}", + scope + ))), + } + } + + pub fn as_string(&self) -> String { + match (&self.kind, &self.resource) { + (Some(kind), Some(resource)) => { + format!( + "{}:{}:{}:{}", + self.domain, + self.action, + kind, + resource.join(",") + ) + } + (Some(kind), None) => { + format!("{}:{}:{}", self.domain, self.action, kind) + } + (None, Some(resource)) => { + format!("{}:{}:{}", self.domain, self.action, resource.join(",")) + } + (None, None) => format!("{}:{}", self.domain, self.action), + } + } + + pub fn includes(&self, other: &ScopeDefinition) -> bool { + if self.domain != other.domain { + return false; + } + + match (self.action.as_str(), other.action.as_str()) { + (a, b) if (a == "write" && b == "read") || (a == b) => {} + // Apps only: `write` can rewrite the app and its policy, so it also covers + // running its components. Not general — `jobs:write` must not grant + // `jobs:run`. The resource check below still confines it to the same app. + ("write", "run") if self.domain == "apps" => {} + ("write", "cancel") if self.domain == "jobs" => {} + _ => return false, + } + + if self.domain == "jobs" && self.action == "run" { + match (&self.kind, &other.kind) { + (Some(self_kind), Some(other_kind)) => { + if self_kind != other_kind { + return false; + } + } + (Some(_), None) => { + return false; + } + (None, _) => { + return true; + } + } + } + + match (&self.resource, &other.resource) { + (Some(self_resources), Some(other_resources)) => { + resources_match(self_resources, other_resources) + } + // A requirement naming no path is the whole domain, so only a grant that + // itself spans every path satisfies it. `*` is that grant — the scope UI + // accepts it as a resource path and `resources_match` already reads it as + // everything — while any listed path leaves the collection unauthorized. + (Some(self_resources), None) => self_resources.iter().any(|r| r == "*"), + (None, _) => true, + } + } +} + +fn resources_match(scope_resources: &[String], accepted_resources: &[String]) -> bool { + if scope_resources.contains(&"*".to_string()) || accepted_resources.contains(&"*".to_string()) { + return true; + } + + if scope_resources.len() <= 4 && accepted_resources.len() <= 4 { + return resources_match_small(scope_resources, accepted_resources); + } + + resources_match_large(scope_resources, accepted_resources) +} + +fn resources_match_small(scope_resources: &[String], accepted_resources: &[String]) -> bool { + for required in accepted_resources { + for scope_resource in scope_resources { + if resource_matches_pattern(scope_resource, required) { + return true; + } + } + } + false +} + +fn resources_match_large(scope_resources: &[String], accepted_resources: &[String]) -> bool { + let mut exact_matches = HashSet::new(); + let mut patterns = Vec::new(); + + for scope_resource in scope_resources { + if scope_resource.contains('*') { + patterns.push(scope_resource); + } else { + exact_matches.insert(scope_resource); + } + } + + for accepted_resource in accepted_resources { + if exact_matches.contains(accepted_resource) { + return true; + } + + for pattern in &patterns { + if resource_matches_pattern(pattern, accepted_resource) { + return true; + } + } + } + + false +} + +fn resource_matches_pattern(scope_resource: &str, accepted_resource: &str) -> bool { + if scope_resource == accepted_resource { + return true; + } + + let matches_wildcard = |pattern: &str, resource: &str| -> bool { + if !pattern.ends_with("/*") { + return false; + } + + let prefix = &pattern[..pattern.len() - 2]; + + if !resource.starts_with(prefix) { + return false; + } + + // If the resource is exactly the prefix, it matches + if resource.len() == prefix.len() { + return true; + } + + // If the resource is longer, the next character must be '/' for a valid match + // This prevents "u/user" from matching "u/use/*" + resource.chars().nth(prefix.len()) == Some('/') + }; + + // Check if either resource is a wildcard pattern and matches the other + matches_wildcard(scope_resource, accepted_resource) + || matches_wildcard(accepted_resource, scope_resource) +} + +// ───────────────────────────────────────────────────────────────── +// Route-level scope checking +// ───────────────────────────────────────────────────────────────── + +/// Available scope domains (top-level API categories) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ScopeDomain { + // Core resource domains + Jobs, + Scripts, + /// The `/data_metrics` catalog. Its own domain, NOT an alias of `Scripts`: a + /// `data_metrics:read` token must reach only this route, never the broader + /// `/scripts` routes (some of which do no further scope check). + DataMetrics, + Flows, + FlowConversations, + Apps, + Variables, + Resources, + Schedules, + Folders, + Users, + Groups, + Workspaces, + + // Trigger domains + HttpTriggers, + WebsocketTriggers, + KafkaTriggers, + NatsTriggers, + MqttTriggers, + AmqpTriggers, + SqsTriggers, + GcpTriggers, + AzureTriggers, + PostgresTriggers, + EmailTriggers, + + // Native trigger domains + NativeTriggers, + TriggersHistory, + + // System domains + Audit, + Settings, + Workers, + ServiceLogs, + Configs, + OAuth, + AI, + AiEvals, // AI agent eval datasets + + Indexer, + Teams, // Microsoft Teams integration + GitSync, // Git synchronization + + // Special domains + Capture, // Webhook capture + Drafts, // Draft resources + Favorites, // User favorites + Inputs, // Input templates + JobHelpers, // Job helper functions + ConcurrencyGroups, // Concurrency groups + Oidc, // OpenID Connect + Openapi, // OpenAPI generation + + // Additional domains + Acls, // Granular access control lists + RawApps, // Raw application data + AgentWorkers, // Agent workers management + Mcp, // MCP + Docs, // Self-hosted documentation search (read-only) +} + +impl ScopeDomain { + pub fn as_str(&self) -> &'static str { + match self { + Self::Jobs => "jobs", + Self::Scripts => "scripts", + Self::DataMetrics => "data_metrics", + Self::Flows => "flows", + Self::FlowConversations => "flow_conversations", + Self::Apps => "apps", + Self::Variables => "variables", + Self::Resources => "resources", + Self::Schedules => "schedules", + Self::Folders => "folders", + Self::Users => "users", + Self::Groups => "groups", + Self::Workspaces => "workspaces", + Self::HttpTriggers => "http_triggers", + Self::WebsocketTriggers => "websocket_triggers", + Self::KafkaTriggers => "kafka_triggers", + Self::NatsTriggers => "nats_triggers", + Self::MqttTriggers => "mqtt_triggers", + Self::AmqpTriggers => "amqp_triggers", + Self::SqsTriggers => "sqs_triggers", + Self::GcpTriggers => "gcp_triggers", + Self::AzureTriggers => "azure_triggers", + Self::PostgresTriggers => "postgres_triggers", + Self::EmailTriggers => "email_triggers", + Self::NativeTriggers => "native_triggers", + Self::TriggersHistory => "triggers_history", + Self::Audit => "audit", + Self::Settings => "settings", + Self::Workers => "workers", + Self::ServiceLogs => "service_logs", + Self::Configs => "configs", + Self::OAuth => "oauth", + Self::AI => "ai", + Self::AiEvals => "ai_evals", + Self::Capture => "capture", + Self::Drafts => "drafts", + Self::Favorites => "favorites", + Self::Inputs => "inputs", + Self::JobHelpers => "job_helpers", + Self::ConcurrencyGroups => "concurrency_groups", + Self::Oidc => "oidc", + Self::Openapi => "openapi", + Self::Acls => "acls", + Self::RawApps => "raw_apps", + Self::AgentWorkers => "agent_workers", + Self::Indexer => "indexer", + Self::Teams => "teams", + Self::GitSync => "git_sync", + Self::Mcp => "mcp", + Self::Docs => "docs", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "jobs" | "jobs_u" => Some(Self::Jobs), + "scripts" => Some(Self::Scripts), + // A distinct domain, not an alias of `scripts` (see the enum variant): + // a `data_metrics:read` token must not reach the broader /scripts routes. + "data_metrics" => Some(Self::DataMetrics), + "flows" => Some(Self::Flows), + "flow_conversations" => Some(Self::FlowConversations), + "apps" | "apps_u" => Some(Self::Apps), + "variables" => Some(Self::Variables), + "resources" => Some(Self::Resources), + "schedules" => Some(Self::Schedules), + "folders" => Some(Self::Folders), + "users" => Some(Self::Users), + "groups" => Some(Self::Groups), + "workspaces" => Some(Self::Workspaces), + "http_triggers" => Some(Self::HttpTriggers), + "websocket_triggers" => Some(Self::WebsocketTriggers), + "kafka_triggers" => Some(Self::KafkaTriggers), + "nats_triggers" => Some(Self::NatsTriggers), + "mqtt_triggers" => Some(Self::MqttTriggers), + "amqp_triggers" => Some(Self::AmqpTriggers), + "sqs_triggers" => Some(Self::SqsTriggers), + "gcp_triggers" => Some(Self::GcpTriggers), + "azure_triggers" => Some(Self::AzureTriggers), + "postgres_triggers" => Some(Self::PostgresTriggers), + "email_triggers" => Some(Self::EmailTriggers), + "audit" => Some(Self::Audit), + "settings" => Some(Self::Settings), + "workers" => Some(Self::Workers), + "service_logs" => Some(Self::ServiceLogs), + "configs" => Some(Self::Configs), + "oauth" => Some(Self::OAuth), + "ai" => Some(Self::AI), + "ai_evals" => Some(Self::AiEvals), + "indexer" | "srch" => Some(Self::Indexer), + "teams" => Some(Self::Teams), + "native_triggers" => Some(Self::NativeTriggers), + "triggers_history" => Some(Self::TriggersHistory), + "git_sync" | "github_app" => Some(Self::GitSync), + "capture" => Some(Self::Capture), + "drafts" => Some(Self::Drafts), + "favorites" => Some(Self::Favorites), + "inputs" => Some(Self::Inputs), + "job_helpers" => Some(Self::JobHelpers), + "concurrency_groups" => Some(Self::ConcurrencyGroups), + "oidc" => Some(Self::Oidc), + "openapi" => Some(Self::Openapi), + "acls" => Some(Self::Acls), + "raw_apps" => Some(Self::RawApps), + "agent_workers" => Some(Self::AgentWorkers), + "mcp" => Some(Self::Mcp), + "docs" => Some(Self::Docs), + _ => None, + } + } +} + +/// Available scope actions +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ScopeAction { + Read, // GET operations, list, view + Write, // POST, PUT, PATCH, DELETE operations, create, update, delete + Run, // Special action for running (scripts, flows, etc.) + Cancel, // Cancelling jobs (`CANCEL_PATH_ACTIONS`); covered by `jobs:write` +} + +impl ScopeAction { + pub fn as_str(&self) -> &'static str { + match self { + Self::Read => "read", + Self::Write => "write", + Self::Run => "run", + Self::Cancel => "cancel", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "read" => Some(Self::Read), + "write" => Some(Self::Write), + "delete" => Some(Self::Write), + "run" => Some(Self::Run), + "cancel" => Some(Self::Cancel), + _ => None, + } + } + + /// Check if this action includes another action + /// Write includes Read + pub fn includes(&self, other: &ScopeAction) -> bool { + match (self, other) { + (ScopeAction::Write, ScopeAction::Read) => true, + (ScopeAction::Run, ScopeAction::Read) => true, + (ScopeAction::Write, ScopeAction::Cancel) => true, + (a, b) => a == b, + } + } +} + +/// Whether `caller` grants at least everything `requested` grants (directional +/// containment). +/// +/// This is intentionally NOT `ScopeDefinition::includes`: that method answers +/// "does this scope grant access to a required action" using OR semantics over +/// resources (any overlap counts, and a `*` on either side matches), which is +/// correct for access checks but unsafe for subset checks — it would let a +/// token scoped to `scripts:read:f/team/a` mint `scripts:read:*` or +/// `scripts:read:f/team/a,f/other/b`. Subset containment instead requires that +/// EVERY requested resource is covered by SOME caller resource. +pub fn scope_contains(caller: &ScopeDefinition, requested: &ScopeDefinition) -> bool { + if caller.domain != requested.domain { + return false; + } + + // write subsumes read; otherwise the action must match exactly. + match (caller.action.as_str(), requested.action.as_str()) { + (c, r) if c == r || (c == "write" && r == "read") => {} + // Apps only: `write` covers `run` (see `ScopeDefinition::includes`), so an + // app-editor token can mint the narrower run-only credential. + ("write", "run") if caller.domain == "apps" => {} + ("write", "cancel") if caller.domain == "jobs" => {} + _ => return false, + } + + if caller.domain == "jobs" && caller.action == "run" { + match (&caller.kind, &requested.kind) { + (Some(caller_kind), Some(requested_kind)) if caller_kind != requested_kind => { + return false + } + // Caller pinned to a kind, but the request covers any kind. + (Some(_), None) => return false, + _ => {} + } + } + + match (&caller.resource, &requested.resource) { + // Caller is unrestricted on resources: covers everything. + (None, _) => true, + // Caller is resource-restricted but the request is not: broader, unless the + // caller lists `*` and so already spans every path. Kept in step with + // `ScopeDefinition::includes`, which accepts that same grant for a + // whole-collection read: what a token may exercise, it may also delegate. + (Some(caller_resources), None) => caller_resources.iter().any(|r| r == "*"), + (Some(caller_resources), Some(requested_resources)) => { + resource_set_contains(caller_resources, requested_resources) + } + } +} + +/// Every resource in `requested` must be covered by some resource in `caller`. +fn resource_set_contains(caller: &[String], requested: &[String]) -> bool { + if caller.iter().any(|r| r == "*") { + return true; + } + requested + .iter() + .all(|req| req != "*" && caller.iter().any(|c| resource_covers(c, req))) +} + +/// Directional: does the single caller resource pattern cover `requested`? +/// `caller` may be an exact path or a `/*` subtree wildcard; `requested` +/// may itself be a subtree wildcard, in which case the whole requested subtree +/// must fall within the caller's subtree. +fn resource_covers(caller: &str, requested: &str) -> bool { + if caller == requested { + return true; + } + let Some(prefix) = caller.strip_suffix("/*") else { + // An exact caller resource only covers itself (handled above). + return false; + }; + let requested_base = requested.strip_suffix("/*").unwrap_or(requested); + requested_base == prefix + || (requested_base.starts_with(prefix) + && requested_base.as_bytes().get(prefix.len()) == Some(&b'/')) +} + +/// Stands in for an empty set of job token scopes. A token with `scopes: Some([])` is +/// unrestricted (an empty list is read as "no scopes defined"), so a job whose effective +/// scopes are empty carries this entry instead: it does not parse, and an unparseable +/// entry marks a token as scoped while granting nothing. +pub const NO_API_ACCESS_SCOPE: &str = "no_api_access"; + +/// Validates and normalizes a script or flow `job_token_scopes` setting. `[]` is valid and +/// leaves the job only the runtime routes about itself. +pub fn validate_job_token_scopes(scopes: &[String]) -> Result> { + let mut normalized: Vec = Vec::with_capacity(scopes.len()); + for scope in scopes { + let scope = scope.trim(); + // MCP scopes follow the MCP runtime's own grammar, and a list of only + // `if_jobs:filter_tags` entries reads as unscoped: neither can cap a job token. + if scope.starts_with("mcp:") || scope.starts_with("if_jobs:") { + return Err(Error::BadRequest(format!( + "Job token scopes cannot include '{scope}'" + ))); + } + let parsed = ScopeDefinition::from_scope_string(scope)?; + if ScopeDomain::from_str(&parsed.domain).is_none() + || ScopeAction::from_str(&parsed.action).is_none() + { + return Err(Error::BadRequest(format!( + "Unknown job token scope '{scope}'" + ))); + } + if !normalized.iter().any(|s| s == scope) { + normalized.push(scope.to_string()); + } + } + Ok(normalized) +} + +/// The scopes a pushed job's token is restricted to: what `ceiling` (the scopes of the job +/// or token the push acts for) and `own` (the target's `job_token_scopes` setting) both +/// grant. `None` on both sides means unrestricted. +/// +/// Keeps every entry of either side contained by some entry of the other. Overlaps that +/// neither side contains whole (two different globs) are dropped, which can only narrow. +pub fn intersect_job_token_scopes( + ceiling: Option<&[String]>, + own: Option<&[String]>, +) -> Option> { + match (ceiling, own) { + (None, None) => None, + (Some(c), None) => Some(c.to_vec()), + (None, Some(o)) => Some(o.to_vec()), + (Some(c), Some(o)) => { + let parse = |scopes: &[String]| -> Vec> { + scopes + .iter() + .map(|s| ScopeDefinition::from_scope_string(s).ok()) + .collect() + }; + let (pc, po) = (parse(c), parse(o)); + let mut out: Vec = vec![]; + let mut keep_covered = + |raw: &[String], parsed: &[Option], by: &[Option]| { + for (s, p) in raw.iter().zip(parsed) { + let Some(p) = p else { continue }; + if by.iter().flatten().any(|b| scope_contains(b, p)) + && !out.iter().any(|x| x == s) + { + out.push(s.clone()); + } + } + }; + keep_covered(o, &po, &pc); + keep_covered(c, &pc, &po); + Some(out) + } + } +} + +/// Validates the `job_token_scopes` of every step and agent tool of a flow, returning whether +/// any of them sets one. +pub fn validate_flow_step_job_token_scopes(value: &crate::flows::FlowValue) -> Result { + let mut any = false; + let mut check = |module: &crate::flows::FlowModule| -> anyhow::Result<()> { + if let Some(scopes) = &module.job_token_scopes { + any = true; + validate_job_token_scopes(scopes) + .map_err(|e| anyhow::anyhow!("step {}: {e}", module.id))?; + } + Ok(()) + }; + let extra: Vec = value + .failure_module + .iter() + .chain(value.preprocessor_module.iter()) + .map(|m| (**m).clone()) + .collect(); + crate::flows::FlowModule::traverse_modules(&value.modules, &mut check) + .and_then(|()| crate::flows::FlowModule::traverse_modules(&extra, &mut check)) + .map_err(|e| Error::BadRequest(e.to_string()))?; + Ok(any) +} + +/// Counts a deploy of a script or flow that restricts its job token, keyed by which shape +/// of restriction it picked, so take-up of the presets can be told from custom lists. +pub fn log_job_token_scopes_deploy(kind: &'static str, scopes: Option<&[String]>) { + let Some(scopes) = scopes else { return }; + let shape = match scopes { + [] => "no_api", + [only] if only == "oidc:write" => "oidc_only", + _ => "custom", + }; + crate::feature_usage::log_feature_usage( + "job_token_scopes", + "deploy", + &format!("{kind}:{shape}"), + ); +} + +/// The cap a flow step or agent tool's own `job_token_scopes` puts on its jobs. A definition +/// can reach the worker without the deploy-time validation (a raw flow), so a setting that +/// does not validate restricts the step to no API access rather than being trusted as is. +pub fn step_job_token_scopes(scopes: Option<&[String]>) -> Option> { + scopes.map(|s| validate_job_token_scopes(s).unwrap_or_default()) +} + +/// The `scopes` claim of a job token minted from the job's effective scopes. +pub fn job_token_jwt_scopes(effective: Option>) -> Option> { + match effective { + Some(s) if s.is_empty() => Some(vec![NO_API_ACCESS_SCOPE.to_string()]), + s => s, + } +} + +#[cfg(test)] +mod job_token_scopes_tests { + use super::*; + + fn v(s: &[&str]) -> Vec { + s.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn intersect_never_widens() { + assert_eq!(intersect_job_token_scopes(None, None), None); + let oidc = v(&["oidc:write"]); + assert_eq!(intersect_job_token_scopes(Some(&oidc), None), Some(oidc.clone())); + assert_eq!(intersect_job_token_scopes(None, Some(&oidc)), Some(oidc.clone())); + // A child setting asking for more than its parent holds gets only the common part. + assert_eq!( + intersect_job_token_scopes( + Some(&oidc), + Some(&v(&["oidc:write", "variables:read"])) + ), + Some(oidc.clone()) + ); + // Narrower entries win from either side. + assert_eq!( + intersect_job_token_scopes( + Some(&v(&["variables:write:f/a/*"])), + Some(&v(&["variables:read:f/a/b", "scripts:read"])) + ), + Some(v(&["variables:read:f/a/b"])) + ); + assert_eq!( + intersect_job_token_scopes(Some(&v(&["jobs:run"])), Some(&v(&["oidc:write"]))), + Some(vec![]) + ); + // The empty-set marker grants nothing to intersect with. + assert_eq!( + intersect_job_token_scopes(Some(&v(&[NO_API_ACCESS_SCOPE])), Some(&oidc)), + Some(vec![]) + ); + } + + #[test] + fn empty_effective_scopes_mint_a_scoped_token() { + assert_eq!( + job_token_jwt_scopes(Some(vec![])), + Some(v(&[NO_API_ACCESS_SCOPE])) + ); + assert_eq!(job_token_jwt_scopes(None), None); + } + + #[test] + fn validate_rejects_unusable_scopes() { + assert!(validate_job_token_scopes(&v(&["mcp:all"])).is_err()); + assert!(validate_job_token_scopes(&v(&["if_jobs:filter_tags:a"])).is_err()); + assert!(validate_job_token_scopes(&v(&["nope:write"])).is_err()); + assert!(validate_job_token_scopes(&v(&["*"])).is_err()); + assert_eq!( + validate_job_token_scopes(&v(&["oidc:write", " oidc:write"])).unwrap(), + v(&["oidc:write"]) + ); + // A step setting that skipped deploy validation restricts rather than widens. + assert_eq!( + step_job_token_scopes(Some(&v(&["if_jobs:filter_tags:bun"]))), + Some(vec![]) + ); + } +} diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index 49d82db787..87148f3f4c 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -164,6 +164,7 @@ async fn prefetch_cached_script_inner( assets: script.assets, modules: script.modules, labels: script.labels, + job_token_scopes: script.job_token_scopes, inherited_labels: script.inherited_labels, runnable_settings: ScriptRunnableSettingsInline { concurrency_settings: concurrency_settings.maybe_fallback( @@ -463,6 +464,7 @@ pub async fn deploy_relocked_version( modules, auto_parent: None, labels: s.labels, + job_token_scopes: Some(s.job_token_scopes.clone()), skip_draft_deletion: None, }; @@ -483,7 +485,7 @@ pub async fn deploy_relocked_version( dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \ codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels, \ - lock_error_logs, created_at) + job_token_scopes, lock_error_logs, created_at) SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, \ content, created_by, schema, is_template, extra_perms, $4::text, language, kind, tag, \ @@ -491,7 +493,7 @@ pub async fn deploy_relocked_version( dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \ codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, COALESCE($5::jsonb, modules), labels, \ - $6::text, clock_timestamp() + job_token_scopes, $6::text, clock_timestamp() FROM script WHERE hash = $2 AND workspace_id = $3; ", new_hash, s.hash.0, w_id, lock, modules_json, lock_error_logs).execute(&mut **tx).await?; diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index 98a26aac25..e68b81e4de 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -811,7 +811,7 @@ fn format_pull_query(peek: String) -> String { j.same_worker, j.pre_run_error, j.visible_to_owner, j.tag, j.concurrent_limit, j.concurrency_time_window_s, j.flow_innermost_root_job, j.root_job, j.timeout, j.flow_step_id, j.cache_ttl, q.cache_ignore_s3_path, q.runnable_settings_handle, j.priority, j.raw_code, j.raw_lock, j.raw_flow, - j.script_entrypoint_override, j.preprocessed, COALESCE(pj.runnable_path, j.args->>'_FLOW_PATH') as parent_runnable_path, + j.script_entrypoint_override, j.preprocessed, p.job_token_scopes, COALESCE(pj.runnable_path, j.args->>'_FLOW_PATH') as parent_runnable_path, COALESCE(p.email, j.permissioned_as_email) as permissioned_as_email, p.username as permissioned_as_username, p.is_admin as permissioned_as_is_admin, p.is_operator as permissioned_as_is_operator, p.groups as permissioned_as_groups, p.folders as permissioned_as_folders, p.end_user_email as permissioned_as_end_user_email FROM q, j diff --git a/backend/windmill-dep-map/src/trigger_dependents.rs b/backend/windmill-dep-map/src/trigger_dependents.rs index d5cfd8e5a4..48e151736a 100644 --- a/backend/windmill-dep-map/src/trigger_dependents.rs +++ b/backend/windmill-dep-map/src/trigger_dependents.rs @@ -210,6 +210,7 @@ pub async fn trigger_dependents_to_recompute_dependencies( None, None, None, + None, ) .await?; diff --git a/backend/windmill-queue/src/asset_dispatch.rs b/backend/windmill-queue/src/asset_dispatch.rs index a3f7154cda..b44541dc9d 100644 --- a/backend/windmill-queue/src/asset_dispatch.rs +++ b/backend/windmill-queue/src/asset_dispatch.rs @@ -822,6 +822,7 @@ async fn push_subscriber( debouncing_settings, concurrency_settings, labels: script.labels, + job_token_scopes: script.job_token_scopes, } }; @@ -904,6 +905,7 @@ async fn push_subscriber( JobTriggerKind::Asset, )), None, + None, ) .await .map_err(|e| error::Error::internal_err(format!("push asset-triggered job: {e:#}")))?; diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index a06c98b902..e48ff19713 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -74,7 +74,7 @@ use windmill_common::{ add_virtual_items_if_necessary, FlowModule, FlowModuleValue, FlowValue, InputTransform, Retry, StopAfterIf, }, - jobs::{get_payload_tag_from_prefixed_path, JobKind, JobPayload, QueuedJob, RawCode}, + jobs::{get_payload_tag_from_prefixed_path, JobKind, JobPayload, RawCode}, min_version::{MIN_VERSION_IS_AT_LEAST_1_432, MIN_VERSION_IS_AT_LEAST_1_440}, schedule::Schedule, scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang}, @@ -503,6 +503,7 @@ pub async fn push_init_job<'c>( None, None, None, + None, ) .await?; inner_tx.commit().await?; @@ -563,6 +564,7 @@ pub async fn push_periodic_bash_job<'c>( None, None, None, + None, ) .await?; inner_tx.commit().await?; @@ -725,9 +727,11 @@ async fn restart_perpetual_runs_at_path( PerpetualRunToRestart, "SELECT q.id AS \"id!\", j.created_by, j.permissioned_as, j.permissioned_as_email, \ j.trigger, j.trigger_kind AS \"trigger_kind: TriggerKindLabel\", j.preprocessed, \ - j.args AS \"args: sqlx::types::Json>>\" \ + j.args AS \"args: sqlx::types::Json>>\", \ + p.job_token_scopes AS \"job_token_scopes?\" \ FROM v2_job_queue q JOIN v2_job j USING (id) \ JOIN script s ON s.workspace_id = j.workspace_id AND s.hash = j.runnable_id \ + LEFT JOIN job_perms p ON p.job_id = q.id \ WHERE j.workspace_id = $1 AND j.runnable_path = $2 AND j.kind = 'script' \ AND j.flow_step_id IS NULL AND j.runnable_id != $3 AND q.canceled_by IS NULL \ AND s.restart_unless_cancelled", @@ -765,6 +769,20 @@ async fn restart_perpetual_runs_at_path( Ok(true) } +/// A worker older than `job_token_scopes` ignores a step's or agent tool's own restriction, so +/// a flow that sets one is refused while such a worker is live, whichever way the flow arrived +/// (a deploy, a restart, a preview, a standalone agent, an eval). Free when every worker is +/// current. +async fn refuse_step_scopes_on_outdated_workers(value: &FlowValue) -> Result<(), Error> { + let gate = &windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES; + if !gate.met().await + && windmill_common::scopes::validate_flow_step_job_token_scopes(value).unwrap_or(true) + { + gate.assert().await?; + } + Ok(()) +} + /// A run of an earlier version at the path, and what its replacement inherits from it. struct PerpetualRunToRestart { id: Uuid, @@ -777,6 +795,8 @@ struct PerpetualRunToRestart { /// completion swaps in what a preprocessor returned. preprocessed: Option, args: Option>>>, + /// Its effective token scopes: the replacement never holds a wider token. + job_token_scopes: Option>, } /// What every run at the path moves to. @@ -857,6 +877,7 @@ async fn restart_perpetual_run( // an earlier version the next pass picks up. return Ok(()); } + let scope_ceiling = run.job_token_scopes; let (_, tx) = push( db, PushIsolationLevel::Transaction(tx), @@ -887,6 +908,7 @@ async fn restart_perpetual_run( None, None, None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -1251,7 +1273,7 @@ lazy_static::lazy_static! { pub static ref MAX_RESULT_SIZE_MB: usize = std::env::var("MAX_RESULT_SIZE_MB").unwrap_or("500".to_string()).parse().unwrap_or(500); // Cache for perpetual-restart settings (restart_unless_cancelled, timeout) - keyed by (hash, workspace_id) - static ref RESTART_UNLESS_CANCELLED_CACHE: Cache<(i64, String), (bool, Option)> = Cache::new(10000); + static ref RESTART_UNLESS_CANCELLED_CACHE: Cache<(i64, String), (bool, Option, Option>)> = Cache::new(10000); // Cache for workspace error handler settings with 60s TTL // Key: workspace_id, Value: (error_handler, error_handler_extra_args, error_handler_muted_on_cancel, error_handler_muted_on_user_path, report_to_instance_alerts, expiry_timestamp) @@ -2037,13 +2059,13 @@ async fn restart_job_if_perpetual_inner( ) -> Result<(), Error> { let cache_key = (hash.0, queued_job.workspace_id.clone()); - let (restart, script_timeout) = if let Some(cached) = + let (restart, script_timeout, script_job_token_scopes) = if let Some(cached) = RESTART_UNLESS_CANCELLED_CACHE.get(&cache_key) { cached } else { let row = sqlx::query!( - "SELECT restart_unless_cancelled, timeout FROM script WHERE hash = $1 AND workspace_id = $2", + "SELECT restart_unless_cancelled, timeout, job_token_scopes FROM script WHERE hash = $1 AND workspace_id = $2", hash.0, &queued_job.workspace_id ) @@ -2054,10 +2076,12 @@ async fn restart_job_if_perpetual_inner( .as_ref() .and_then(|r| r.restart_unless_cancelled) .unwrap_or(false); - let script_timeout = row.and_then(|r| r.timeout); + let script_timeout = row.as_ref().and_then(|r| r.timeout); + let script_job_token_scopes = row.and_then(|r| r.job_token_scopes); - RESTART_UNLESS_CANCELLED_CACHE.insert(cache_key, (restart, script_timeout)); - (restart, script_timeout) + let cached = (restart, script_timeout, script_job_token_scopes); + RESTART_UNLESS_CANCELLED_CACHE.insert(cache_key, cached.clone()); + cached }; if restart { @@ -2103,6 +2127,8 @@ async fn restart_job_if_perpetual_inner( .await? .flatten() .unwrap_or_default(); + // The replacement never holds a wider token than the run it replaces. + let scope_ceiling = queued_job.job_token_scopes.clone(); let (_uuid, tx) = push( db, tx, @@ -2127,6 +2153,7 @@ async fn restart_job_if_perpetual_inner( // TODO(debouncing): handle properly debouncing_settings: DebouncingSettings::default(), labels: None, // labels already set on original job + job_token_scopes: script_job_token_scopes, }, PushArgs::from(&args.0), &queued_job.created_by, @@ -2153,6 +2180,7 @@ async fn restart_job_if_perpetual_inner( None, None, None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -2411,6 +2439,8 @@ pub async fn maybe_enqueue_native_script_retry( ) .await?; let tx = PushIsolationLevel::IsolatedRoot(db.clone()); + // The retry never holds a wider token than the attempt it replaces. + let scope_ceiling = job.job_token_scopes.clone(); let (new_id, mut tx) = match push( db, tx, @@ -2459,6 +2489,7 @@ pub async fn maybe_enqueue_native_script_retry( None, trigger, None, + scope_ceiling.as_deref(), ) .await { @@ -3424,6 +3455,7 @@ pub async fn push_error_handler<'a, 'c, T: Serialize + Send + Sync>( None, None, None, + None, ) .await?; tx.commit().await?; @@ -3513,6 +3545,7 @@ pub async fn push_success_handler<'a, 'c, T: Serialize + Send + Sync>( None, None, None, + None, ) .await?; tx.commit().await?; @@ -3571,6 +3604,11 @@ pub struct MiniPulledJob { pub visible_to_owner: bool, pub permissioned_as_end_user_email: Option, pub runnable_settings_handle: Option, + /// The job's effective token scopes (`job_perms.job_token_scopes`), minted into its token. + /// No `sqlx(default)`: a query that forgets the column must fail rather than mint an + /// unrestricted token. + #[serde(default)] + pub job_token_scopes: Option>, } impl MiniPulledJob { @@ -3622,6 +3660,7 @@ impl MiniPulledJob { runnable_settings_handle: None, concurrent_limit: None, concurrency_time_window_s: None, + job_token_scopes: None, } } } @@ -3659,6 +3698,10 @@ pub struct MiniCompletedJob { /// the server would reject every completion it sends, not just build jobs. #[serde(default)] pub build_binary_only: bool, + /// The job's effective token scopes, carried from the pull: a re-run (retry, perpetual + /// restart) caps itself with them after the job's `job_perms` row may have been swept. + #[serde(default)] + pub job_token_scopes: Option>, } impl From for MiniCompletedJob { @@ -3686,9 +3729,11 @@ impl From for MiniCompletedJob { cache_ttl: job.cache_ttl, cache_ignore_s3_path: job.cache_ignore_s3_path, runnable_settings_handle: job.runnable_settings_handle, - // `QueuedJobV2` carries no args, and nothing reaches the restart gate - // through this conversion — the worker completes jobs from the pulled job. + // `QueuedJobV2` carries no args, which is what marks a binary-build job. build_binary_only: false, + // Nor scopes: a caller whose completion can re-run the job (the monitor's zombie + // recovery) fills them in from `job_perms` while the job is still queued. + job_token_scopes: None, } } } @@ -3719,6 +3764,7 @@ impl From for MiniCompletedJob { cache_ttl: job.cache_ttl, cache_ignore_s3_path: job.cache_ignore_s3_path, runnable_settings_handle: job.runnable_settings_handle, + job_token_scopes: job.job_token_scopes.clone(), build_binary_only: crate::binary_prebuild::is_build_binary_job( job.args.as_ref().map(|x| &x.0), ), @@ -3751,6 +3797,7 @@ impl From> for MiniCompletedJob { cache_ttl: job.cache_ttl, cache_ignore_s3_path: job.cache_ignore_s3_path, runnable_settings_handle: job.runnable_settings_handle, + job_token_scopes: job.job_token_scopes.clone(), build_binary_only: crate::binary_prebuild::is_build_binary_job( job.args.as_ref().map(|x| &x.0), ), @@ -3825,48 +3872,6 @@ impl MiniPulledJob { .and_then(|f| f.chat_input_enabled) } - pub fn from(job: &QueuedJob) -> MiniPulledJob { - MiniPulledJob { - workspace_id: job.workspace_id.clone(), - id: job.id, - args: job.args.clone(), - parent_job: job.parent_job.clone(), - created_by: job.created_by.clone(), - started_at: job.started_at.clone(), - scheduled_for: job.scheduled_for, - runnable_path: job.script_path.clone(), - kind: job.job_kind, - runnable_id: job.script_hash.clone(), - canceled_reason: job.canceled_reason.clone(), - canceled_by: job.canceled_by.clone(), - permissioned_as: job.permissioned_as.clone(), - permissioned_as_email: job.email.clone(), - flow_status: job.flow_status.clone(), - tag: job.tag.clone(), - script_lang: job.language.clone(), - same_worker: job.same_worker, - pre_run_error: job.pre_run_error.clone(), - concurrent_limit: job.concurrent_limit.clone(), - concurrency_time_window_s: job.concurrency_time_window_s.clone(), - runnable_settings_handle: job.runnable_settings_handle, - flow_innermost_root_job: job.root_job.clone(), // QueuedJob is taken from v2_as_queue, where root_job corresponds to flow_innermost_root_job in v2_job - root_job: None, - timeout: job.timeout.clone(), - flow_step_id: job.flow_step_id.clone(), - cache_ttl: job.cache_ttl.clone(), - cache_ignore_s3_path: job.cache_ignore_s3_path.clone(), - priority: job.priority.clone(), - preprocessed: job.preprocessed.clone(), - script_entrypoint_override: job.script_entrypoint_override.clone(), - trigger: job.schedule_path.clone(), - trigger_kind: job - .schedule_path - .is_some() - .then(|| JobTriggerKind::Schedule.into()), - visible_to_owner: job.visible_to_owner.clone(), - permissioned_as_end_user_email: None, - } - } pub fn is_flow(&self) -> bool { self.kind.is_flow() } @@ -3968,6 +3973,7 @@ impl PulledJob { groups, folders, end_user_email: self.job.permissioned_as_end_user_email.clone(), + job_token_scopes: self.job.job_token_scopes.clone(), }), _ => None, }; @@ -4074,8 +4080,10 @@ pub async fn get_mini_pulled_job<'c>( trigger, trigger_kind as \"trigger_kind: TriggerKindLabel\", visible_to_owner, - NULL as permissioned_as_end_user_email - FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id WHERE v2_job_queue.id = $1", + NULL as permissioned_as_end_user_email, + job_perms.job_token_scopes + FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id + LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id WHERE v2_job_queue.id = $1", job_id, ) .fetch_optional(e) @@ -5767,8 +5775,10 @@ pub fn get_mini_completed_job<'a, 'e, A: sqlx::Acquire<'e, Database = Postgres> "SELECT j.id, j.workspace_id, j.runnable_id AS \"runnable_id: ScriptHash\", q.scheduled_for, q.started_at, j.parent_job, j.flow_innermost_root_job, j.runnable_path, j.kind as \"kind!: JobKind\", j.permissioned_as, j.created_by, j.script_lang AS \"script_lang: ScriptLang\", j.permissioned_as_email, j.flow_step_id, j.trigger_kind AS \"trigger_kind: TriggerKindLabel\", j.trigger, j.priority, j.concurrent_limit, j.tag, j.cache_ttl, q.cache_ignore_s3_path, q.runnable_settings_handle, - COALESCE(j.args->'build_binary_only' = 'true'::jsonb, false) AS \"build_binary_only!\" + COALESCE(j.args->'build_binary_only' = 'true'::jsonb, false) AS \"build_binary_only!\", + p.job_token_scopes AS \"job_token_scopes?\" FROM v2_job j LEFT JOIN v2_job_queue q ON j.id = q.id + LEFT JOIN job_perms p ON p.job_id = j.id WHERE j.id = $1 AND j.workspace_id = $2", id, w_id @@ -6018,6 +6028,10 @@ pub async fn push<'c, 'd>( end_user_email: Option, trigger: Option, suspended_mode: Option, + // Caps the new job's token on top of the target's own `job_token_scopes`: the scopes of + // the job (or job token) this push acts for, `None` when nothing above it restricts it. + // A job may never hold a token wider than the job that created it. + scope_ceiling: Option<&[String]>, ) -> Result<(Uuid, Transaction<'c, Postgres>), Error> { Box::pin(push_inner( db, @@ -6049,6 +6063,7 @@ pub async fn push<'c, 'd>( end_user_email, trigger, suspended_mode, + scope_ceiling, )) .await } @@ -6084,6 +6099,7 @@ async fn push_inner<'c, 'd>( end_user_email: Option, trigger: Option, suspended_mode: Option, + scope_ceiling: Option<&[String]>, ) -> Result<(Uuid, Transaction<'c, Postgres>), Error> { // The worker builds a preview's `_MODULES` arg into the job as its module code. Every // caller-reachable value lands in `args` or `extra` (webhook query and headers go to @@ -6317,6 +6333,8 @@ async fn push_inner<'c, 'd>( debouncing_settings: DebouncingSettings, retry_settings: RetrySettings, labels: Option>, + /// The target's own `job_token_scopes` setting. + job_token_scopes: Option>, /// A `dependencies` job that only compiles an already-deployed script's binary. /// It shares the job kind, but not the queue policy lock generation needs. build_binary_only: bool, @@ -6339,6 +6357,7 @@ async fn push_inner<'c, 'd>( debouncing_settings, retry_settings, labels, + job_token_scopes, build_binary_only, } = match job_payload { JobPayload::ScriptHash { @@ -6353,6 +6372,7 @@ async fn push_inner<'c, 'd>( concurrency_settings, debouncing_settings, labels, + job_token_scopes, } => { if apply_preprocessor { preprocessed = Some(false); @@ -6370,6 +6390,7 @@ async fn push_inner<'c, 'd>( dedicated_worker, _low_level_priority: priority, labels, + job_token_scopes, ..Default::default() } } @@ -6585,6 +6606,7 @@ async fn push_inner<'c, 'd>( ..Default::default() }, JobPayload::RawFlow { mut value, path, restarted_from } => { + refuse_step_scopes_on_outdated_workers(&value).await?; add_virtual_items_if_necessary(&mut value.modules); let flow_status: FlowStatus = match restarted_from { @@ -6708,20 +6730,21 @@ async fn push_inner<'c, 'd>( // script's `dedicated_worker` (it drives the dedicated tag below), // but the SingleStepFlow payload doesn't — resolve it from the // script row so a dedicated-worker script keeps its dedicated pool. - let dedicated_worker = if let Some(h) = &hash { + // The script's `job_token_scopes` is resolved the same way. + let (dedicated_worker, job_token_scopes) = if let Some(h) = &hash { // Read on the non-RLS pool: push_inner is also entered with RLS // isolation variants under which the script row may be invisible, // which would mis-resolve dedicated_worker routing. - sqlx::query_scalar::<_, Option>( - "SELECT dedicated_worker FROM script WHERE hash = $1 AND workspace_id = $2", + sqlx::query_as::<_, (Option, Option>)>( + "SELECT dedicated_worker, job_token_scopes FROM script WHERE hash = $1 AND workspace_id = $2", ) .bind(h.0) .bind(workspace_id) .fetch_optional(db) .await? - .flatten() + .unwrap_or((None, None)) } else { - None + (None, None) }; break 'ssf JobPayloadUntagged { runnable_id: hash.map(|h| h.0), @@ -6733,6 +6756,7 @@ async fn push_inner<'c, 'd>( }, language, dedicated_worker, + job_token_scopes, concurrency_settings, debouncing_settings, retry_settings: retry.as_ref().map(RetrySettings::from).unwrap_or_default(), @@ -6903,7 +6927,14 @@ async fn push_inner<'c, 'd>( ..Default::default() } } - JobPayload::Flow { path, dedicated_worker, apply_preprocessor, version, labels } => { + JobPayload::Flow { + path, + dedicated_worker, + apply_preprocessor, + version, + labels, + job_token_scopes, + } => { let mut ntx = tx.into_tx().await?; // Do not use the lite version unless all workers are updated. let data = if *DISABLE_FLOW_SCRIPT @@ -6922,6 +6953,7 @@ async fn push_inner<'c, 'd>( tx = PushIsolationLevel::Transaction(ntx); let mut value = data.value().clone(); + refuse_step_scopes_on_outdated_workers(&value).await?; let priority = value.priority; let cache_ttl = value.cache_ttl.map(|x| x as i32); let cache_ignore_s3_path = value.cache_ignore_s3_path; @@ -6970,6 +7002,7 @@ async fn push_inner<'c, 'd>( concurrency_settings, debouncing_settings, labels, + job_token_scopes, ..Default::default() } } @@ -7034,7 +7067,21 @@ async fn push_inner<'c, 'd>( memory_id: None, no_inherited_flow_env: false, }; + // The completed job's own scopes are gone with its `job_perms` row, so the restart + // takes the flow's current setting (and the restarting caller's ceiling). + let job_token_scopes = match &flow_path { + Some(flow_path) => sqlx::query_scalar::<_, Option>>( + "SELECT job_token_scopes FROM flow WHERE path = $1 AND workspace_id = $2", + ) + .bind(flow_path) + .bind(workspace_id) + .fetch_optional(db) + .await? + .flatten(), + None => None, + }; let value = flow_data.value(); + refuse_step_scopes_on_outdated_workers(value).await?; let priority = value.priority; let concurrency_settings = value.concurrency_settings.clone(); let debouncing_settings = value.debouncing_settings.clone(); @@ -7058,6 +7105,7 @@ async fn push_inner<'c, 'd>( _low_level_priority: priority, concurrency_settings, debouncing_settings, + job_token_scopes, ..Default::default() } } @@ -7177,7 +7225,14 @@ async fn push_inner<'c, 'd>( .map(|e| (Some(e.0), e.1)) .unwrap_or_else(|| (None, None)); - let tag = if dedicated_worker.is_some_and(|x| x) { + let job_token_scopes = windmill_common::scopes::intersect_job_token_scopes( + scope_ceiling, + job_token_scopes.as_deref(), + ); + + // A dedicated worker runs every job it serves with its own unscoped worker token, so a + // job with a restricted token runs on the regular workers of its language instead. + let tag = if dedicated_worker.is_some_and(|x| x) && job_token_scopes.is_none() { let flow_prefix = if job_kind == JobKind::Flow || job_kind == JobKind::FlowDependencies { "flow/" } else { @@ -7505,9 +7560,9 @@ async fn push_inner<'c, 'd>( INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null) ), inserted_job_perms AS ( - INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, end_user_email) - values ($1, $32, $33, $34, $35, $36, $37, $2, $41) - ON CONFLICT (job_id) DO UPDATE SET email = EXCLUDED.email, username = EXCLUDED.username, is_admin = EXCLUDED.is_admin, is_operator = EXCLUDED.is_operator, folders = EXCLUDED.folders, groups = EXCLUDED.groups, workspace_id = EXCLUDED.workspace_id, end_user_email = EXCLUDED.end_user_email + INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, end_user_email, job_token_scopes) + values ($1, $32, $33, $34, $35, $36, $37, $2, $41, $47) + ON CONFLICT (job_id) DO UPDATE SET email = EXCLUDED.email, username = EXCLUDED.username, is_admin = EXCLUDED.is_admin, is_operator = EXCLUDED.is_operator, folders = EXCLUDED.folders, groups = EXCLUDED.groups, workspace_id = EXCLUDED.workspace_id, end_user_email = EXCLUDED.end_user_email, job_token_scopes = EXCLUDED.job_token_scopes ) INSERT INTO v2_job_queue (workspace_id, id, running, scheduled_for, started_at, tag, priority, cache_ignore_s3_path, runnable_settings_handle) @@ -7562,6 +7617,7 @@ async fn push_inner<'c, 'd>( labels.as_deref() as Option<&[String]>, runnable_path, workspace_id, + job_token_scopes.as_deref() as Option<&[String]>, ) .execute(&mut *tx) .warn_after_seconds(1) @@ -8525,6 +8581,7 @@ pub async fn get_same_worker_job( v2_job.trigger, v2_job.trigger_kind, v2_job.visible_to_owner, + p.job_token_scopes, v2_job.raw_code, v2_job.raw_lock, v2_job.raw_flow, diff --git a/backend/windmill-queue/src/schedule.rs b/backend/windmill-queue/src/schedule.rs index 97b175fa9e..81be3cb7e8 100644 --- a/backend/windmill-queue/src/schedule.rs +++ b/backend/windmill-queue/src/schedule.rs @@ -103,6 +103,7 @@ async fn get_schedule_metadata<'c>( on_behalf_of, _runnable_settings_handle, _labels, + _job_token_scopes, ) = windmill_common::get_latest_hash_for_path( &mut **tx, db, @@ -368,7 +369,8 @@ pub async fn push_scheduled_job<'c>( .warn_after_seconds_with_sql(1, "get_flow_version_info_from_version".to_string()) .await?; let on_behalf_of = flow_info.on_behalf_of(&schedule.workspace_id, db).await?; - let FlowVersionInfo { version, tag, dedicated_worker, labels, .. } = flow_info; + let FlowVersionInfo { version, tag, dedicated_worker, labels, job_token_scopes, .. } = + flow_info; ( JobPayload::Flow { @@ -377,6 +379,7 @@ pub async fn push_scheduled_job<'c>( apply_preprocessor: false, version, labels, + job_token_scopes, }, tag, None, @@ -442,6 +445,7 @@ pub async fn push_scheduled_job<'c>( on_behalf_of, runnable_settings_handle, labels, + job_token_scopes, ) = windmill_common::get_latest_hash_for_path( &mut *tx, db, @@ -535,6 +539,7 @@ pub async fn push_scheduled_job<'c>( concurrency_time_window_s, ), labels, + job_token_scopes, }, if schedule.tag.as_ref().is_some_and(|x| x != "") { schedule.tag.clone() @@ -664,6 +669,7 @@ pub async fn push_scheduled_job<'c>( JobTriggerKind::Schedule, )), None, + None, ) .warn_after_seconds_with_sql(1, "push in push_scheduled_job".to_string()) .await?; diff --git a/backend/windmill-queue/tests/debounce_test.rs b/backend/windmill-queue/tests/debounce_test.rs index d74f20ff3f..14f7ec2dd9 100644 --- a/backend/windmill-queue/tests/debounce_test.rs +++ b/backend/windmill-queue/tests/debounce_test.rs @@ -3458,6 +3458,7 @@ mod debounce { visible_to_owner: false, permissioned_as_end_user_email: None, runnable_settings_handle: rs_handle, + job_token_scopes: None, }; let pulled = PulledJob { @@ -3699,6 +3700,7 @@ mod debounce { visible_to_owner: false, permissioned_as_end_user_email: None, runnable_settings_handle: rs_handle, + job_token_scopes: None, }; let pulled = PulledJob { diff --git a/backend/windmill-queue/tests/native_retry_test.rs b/backend/windmill-queue/tests/native_retry_test.rs index 3b2290a50f..9b0e05a74c 100644 --- a/backend/windmill-queue/tests/native_retry_test.rs +++ b/backend/windmill-queue/tests/native_retry_test.rs @@ -45,6 +45,7 @@ mod native_retry { cache_ignore_s3_path: None, runnable_settings_handle: handle, build_binary_only: false, + job_token_scopes: None, } } @@ -165,6 +166,39 @@ mod native_retry { Ok(()) } + // A retry keeps the restriction of the run it replaces even once that run's `job_perms` + // row is gone (swept after it left the queue): the completed job carries its scopes. + #[sqlx::test(migrations = "../migrations", fixtures("base", "schedule_push"))] + async fn retry_keeps_the_restriction_of_a_swept_run(db: Pool) -> anyhow::Result<()> { + let retry = Retry { + constant: ConstantDelay { attempts: 1, seconds: 1 }, + exponential: Default::default(), + retry_if: None, + }; + let handle = insert_rs( + RunnableSettings { + debouncing_settings: None, + concurrency_settings: None, + retry_settings: RetrySettings::from(&retry).insert_cached(&db).await?, + }, + &db, + ) + .await?; + let root_id = Uuid::new_v4(); + let mut root = mini(root_id, None, handle); + root.job_token_scopes = Some(vec!["jobs:run".to_string()]); + + assert!(maybe_enqueue_native_script_retry(&db, &root, &None, &no_result).await?); + let (r1_id, ..) = retry_by_attempt(&db, root_id, 1).await.expect("retry 1 exists"); + let scopes: Option> = + sqlx::query_scalar("SELECT job_token_scopes FROM job_perms WHERE job_id = $1") + .bind(r1_id) + .fetch_one(&db) + .await?; + assert_eq!(scopes, Some(vec!["jobs:run".to_string()])); + Ok(()) + } + // Cancellation always wins over a pending retry. #[sqlx::test(migrations = "../migrations", fixtures("base", "schedule_push"))] async fn canceled_job_does_not_retry(db: Pool) -> anyhow::Result<()> { diff --git a/backend/windmill-queue/tests/schedule_push.rs b/backend/windmill-queue/tests/schedule_push.rs index c68c59bb16..ed50215473 100644 --- a/backend/windmill-queue/tests/schedule_push.rs +++ b/backend/windmill-queue/tests/schedule_push.rs @@ -93,6 +93,7 @@ mod schedule_push { cache_ignore_s3_path: None, runnable_settings_handle: None, build_binary_only: false, + job_token_scopes: None, } } diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index 773616a807..f05fff66f8 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -249,6 +249,7 @@ impl RunJob { None, None, None, + None, ) .await .expect("push has to succeed"); @@ -296,6 +297,7 @@ impl RunJob { None, None, None, + None, ) .await .expect("push has to succeed"); @@ -969,6 +971,7 @@ pub async fn run_deployed_relative_imports( debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .push(&db2) .await; diff --git a/backend/windmill-trigger/src/trigger_helpers.rs b/backend/windmill-trigger/src/trigger_helpers.rs index 7e47613d1a..ae805d195e 100644 --- a/backend/windmill-trigger/src/trigger_helpers.rs +++ b/backend/windmill-trigger/src/trigger_helpers.rs @@ -976,6 +976,7 @@ async fn trigger_script_with_retry_and_error_handler<'c>( None, Some(trigger), suspended_mode, + None, ) .await?; diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index a2fc7fd58d..209390004a 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -48,6 +48,10 @@ pub struct Flow { pub on_behalf_of: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// Caps the scopes of the token minted for each job of this flow; `None` = unrestricted. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub job_token_scopes: Option>, /// Labels inherited from the parent folder, computed at read time. Not stored on the flow row. #[sqlx(default)] #[serde(default, skip_serializing_if = "Option::is_none")] @@ -145,6 +149,15 @@ pub struct NewFlow { pub ws_error_handler_muted: Option, #[serde(default)] pub labels: Option>, + /// Absent keeps the deployed flow's value, so a client unaware of the setting cannot + /// drop a restriction by saving; `null` clears it. + #[sqlx(skip)] + #[serde( + default, + deserialize_with = "crate::more_serde::double_option", + skip_serializing_if = "Option::is_none" + )] + pub job_token_scopes: Option>>, /// Caller-intent flag (set by the CLI / git sync): when true, deploying /// this flow must NOT delete an existing user draft at the same path. /// Transient — never persisted. @@ -183,6 +196,12 @@ pub struct EditFlow { pub ws_error_handler_muted: Option, #[serde(default)] pub labels: Option>, + #[serde( + default, + deserialize_with = "crate::more_serde::double_option", + skip_serializing_if = "Option::is_none" + )] + pub job_token_scopes: Option>>, #[serde(default)] pub skip_draft_deletion: Option, } @@ -207,6 +226,7 @@ impl EditFlow { preserve_on_behalf_of: self.preserve_on_behalf_of, ws_error_handler_muted: self.ws_error_handler_muted, labels: self.labels, + job_token_scopes: self.job_token_scopes, skip_draft_deletion: self.skip_draft_deletion, } } @@ -615,6 +635,9 @@ pub struct FlowModule { pub pass_flow_input_directly: Option, #[serde(skip_serializing_if = "Option::is_none")] pub debouncing: Option, + /// Caps the token of the jobs this step runs, on top of the flow's own restriction. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub job_token_scopes: Option>, } #[derive(Deserialize, Serialize, Debug, Clone)] @@ -902,6 +925,9 @@ pub struct AgentTool { /// Overrides the description auto-derived from the underlying runnable. #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, + /// Caps the token of this tool's jobs, on top of the agent step's own restriction. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub job_token_scopes: Option>, pub value: ToolValue, } @@ -915,6 +941,7 @@ impl AgentTool { self.id = flow_module.id; self.summary = flow_module.summary; + self.job_token_scopes = flow_module.job_token_scopes; self.value = ToolValue::FlowModule(module_value); } } @@ -927,6 +954,7 @@ impl From<&AgentTool> for Option { id: tool.id.clone(), value: to_raw_value(module_value), summary: tool.summary.clone(), + job_token_scopes: tool.job_token_scopes.clone(), ..Default::default() }), ToolValue::Mcp(_) => None, @@ -1331,6 +1359,7 @@ pub fn add_virtual_items_if_necessary(modules: &mut Vec) { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }); } } diff --git a/backend/windmill-types/src/jobs.rs b/backend/windmill-types/src/jobs.rs index 530b8a948d..9768373b91 100644 --- a/backend/windmill-types/src/jobs.rs +++ b/backend/windmill-types/src/jobs.rs @@ -515,6 +515,8 @@ pub enum JobPayload { concurrency_settings: ConcurrencySettings, debouncing_settings: DebouncingSettings, labels: Option>, + /// The script's `job_token_scopes` setting, read with the rest of the payload. + job_token_scopes: Option>, }, FlowNode { id: FlowNodeId, @@ -579,6 +581,8 @@ pub enum JobPayload { apply_preprocessor: bool, version: i64, labels: Option>, + /// The flow's `job_token_scopes` setting, read with the rest of the payload. + job_token_scopes: Option>, }, RestartedFlow { completed_job_id: Uuid, diff --git a/backend/windmill-types/src/scripts.rs b/backend/windmill-types/src/scripts.rs index 9e29731505..1e4a8858ed 100644 --- a/backend/windmill-types/src/scripts.rs +++ b/backend/windmill-types/src/scripts.rs @@ -331,7 +331,7 @@ pub const SCRIPT_COLUMNS: &str = concat!( "timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, ", "visible_to_runner_only, auto_kind, codebase, has_preprocessor, ", "on_behalf_of, ", - "assets, modules, labels, concurrency_key, concurrent_limit, ", + "assets, modules, labels, job_token_scopes, concurrency_key, concurrent_limit, ", "concurrency_time_window_s, debounce_key, debounce_delay_s, runnable_settings_handle", ); @@ -401,6 +401,9 @@ pub struct Script { pub modules: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// Caps the scopes of the token minted for each job of this script; `None` = unrestricted. + #[serde(skip_serializing_if = "Option::is_none")] + pub job_token_scopes: Option>, /// Labels inherited from the parent folder, computed at read time. Not stored on the script row. #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] @@ -580,6 +583,14 @@ pub struct NewScript { pub auto_parent: Option, #[serde(default)] pub labels: Option>, + /// Absent keeps the parent version's value, so a client unaware of the setting + /// cannot drop a restriction by redeploying; `null` clears it. + #[serde( + default, + deserialize_with = "crate::more_serde::double_option", + skip_serializing_if = "Option::is_none" + )] + pub job_token_scopes: Option>>, /// Caller-intent flag (set by the CLI / git sync): when true, deploying /// this script must NOT delete an existing user draft at the same path. /// Transient — never persisted. Deliberately excluded from `impl Hash` @@ -628,6 +639,7 @@ impl Hash for NewScript { self.preserve_on_behalf_of.hash(state); self.assets.hash(state); self.labels.hash(state); + self.job_token_scopes.hash(state); if let Some(modules) = &self.modules { let mut sorted: Vec<_> = modules.iter().collect(); sorted.sort_by_key(|(k, _)| *k); diff --git a/backend/windmill-worker/src/ai/tools.rs b/backend/windmill-worker/src/ai/tools.rs index 930a329be4..9e50ddd6c0 100644 --- a/backend/windmill-worker/src/ai/tools.rs +++ b/backend/windmill-worker/src/ai/tools.rs @@ -599,6 +599,16 @@ async fn enqueue_windmill_tool( None, None, None, + // A tool never holds a wider token than the agent calling it, and its own setting + // narrows that further. + windmill_common::scopes::intersect_job_token_scopes( + ctx.job.job_token_scopes.as_deref(), + windmill_common::scopes::step_job_token_scopes( + tool_module.job_token_scopes.as_deref(), + ) + .as_deref(), + ) + .as_deref(), ) .await?; diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 958caeaefc..08b561358e 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -2745,6 +2745,7 @@ mod tests { let mut its = HashMap::new(); its.insert(key.to_string(), js(expr)); AgentTool { + job_token_scopes: None, id: id.to_string(), summary: None, description: None, @@ -2772,6 +2773,7 @@ mod tests { script_tool("a", "x", "authoring_flow_expr"), script_tool("b", "y", "keep_me"), AgentTool { + job_token_scopes: None, id: "m".to_string(), summary: None, description: None, @@ -2819,6 +2821,7 @@ mod tests { fn narrow_roster_keeps_the_entries_a_run_named() { fn named(id: &str, summary: &str) -> AgentTool { AgentTool { + job_token_scopes: None, id: id.to_string(), summary: Some(summary.to_string()), description: None, @@ -2834,6 +2837,7 @@ mod tests { } fn mcp(id: &str, summary: &str, path: &str) -> AgentTool { AgentTool { + job_token_scopes: None, id: id.to_string(), summary: Some(summary.to_string()), description: None, @@ -2846,6 +2850,7 @@ mod tests { } fn websearch(id: &str, summary: Option<&str>) -> AgentTool { AgentTool { + job_token_scopes: None, id: id.to_string(), summary: summary.map(str::to_string), description: None, diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index c4a792c6a0..bb47d3e4be 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -2896,6 +2896,9 @@ pub async fn handle_wac_v2_output( concurrency_settings: ConcurrencySettings::default(), debouncing_settings: DebouncingSettings::default(), labels: None, + // Capped by the parent at push, which already holds this + // script's setting. + job_token_scopes: None, }) } else { Err(error::Error::internal_err( @@ -3023,6 +3026,7 @@ pub async fn handle_wac_v2_output( apply_preprocessor: false, version: flow_info.version, labels: flow_info.labels.clone(), + job_token_scopes: flow_info.job_token_scopes.clone(), }; let on_behalf_of = flow_info.on_behalf_of(&job.workspace_id, db).await?; (ChildRunnable::Deployed(payload), on_behalf_of) @@ -3272,6 +3276,7 @@ pub async fn handle_wac_v2_output( None, // end_user_email None, // trigger None, // suspended_mode + job.job_token_scopes.as_deref(), ) .await?; diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 9c012fd117..cd8923d408 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -2309,6 +2309,7 @@ mod tests { visible_to_owner: false, permissioned_as_end_user_email: None, runnable_settings_handle: None, + job_token_scopes: None, } } diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 6b51073b84..b88d3341d3 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -16,8 +16,6 @@ use tokio::time::timeout; // Re-export proxy env-var snapshots so callers (including EE modules) // can keep importing them via `crate::{NO_PROXY, HTTP_PROXY, HTTPS_PROXY}`. use windmill_common::client::AuthedClient; -use windmill_common::db::UserDbWithAuthed; -use windmill_common::get_latest_deployed_hash_for_path; use windmill_common::jobs::InlineScriptTarget; use windmill_common::jobs::RunInlineScriptFnParams; use windmill_common::jobs::WorkerInternalServerInlineUtils; @@ -3595,8 +3593,12 @@ pub async fn run_worker( // dispatch by path and return before that check, so a job sent down them // would run with whatever arguments survived the failure. let fails_before_running = job.pre_run_error.is_some(); + // A dedicated worker or flow runner runs every job it gets with its own + // unscoped worker token, so a job with a restricted token runs here, with the + // token minted for it, whichever tag brought it. + let restricted = job.job_token_scopes.is_some(); - if !dedicated_workers.is_empty() && !fails_before_running { + if !dedicated_workers.is_empty() && !fails_before_running && !restricted { let dedicated_worker_tx = job.runnable_path.as_ref().and_then(|path| { // For flow steps inside branches/loops, runnable_path includes // nesting segments (e.g. f/flow/branchone-0/a) but the dedicated @@ -3641,7 +3643,9 @@ pub async fn run_worker( NextJob::Http(_) => None, }; - if let Some(flow_runners) = flow_runners.filter(|_| !fails_before_running) { + if let Some(flow_runners) = + flow_runners.filter(|_| !fails_before_running && !restricted) + { let key_o = job.flow_step_id.as_ref().map(|x| x.to_string()); if let Some(key) = key_o { if let Some(flow_runner_tx) = flow_runners.runners.get(&key) { @@ -7234,30 +7238,8 @@ pub fn init_worker_internal_server_inline_utils( run_inline_script: Arc::new(|params: RunInlineScriptFnParams| { Box::pin(async move { let (script_hash, runnable_path) = match params.target { - InlineScriptTarget::Path(ref path) => { - let db = params - .conn - .as_sql() - .ok_or_else(|| { - error::Error::InternalErr( - "run_inline_script by path requires a SQL connection" - .to_string(), - ) - })? - .clone(); - let authed_ref = params.user_db.as_ref().map(|(_, a)| a.to_authed_ref()); - let user_db_authed = - params.user_db.as_ref().zip(authed_ref.as_ref()).map( - |((udb, _), ar)| UserDbWithAuthed { db: udb.clone(), authed: ar }, - ); - let script_hash_info = get_latest_deployed_hash_for_path( - user_db_authed, - db, - ¶ms.workspace_id, - path, - ) - .await?; - (ScriptHash(script_hash_info.hash), Some(path.clone())) + InlineScriptTarget::Path { ref path, hash } => { + (ScriptHash(hash), Some(path.clone())) } InlineScriptTarget::Hash(hash) => (ScriptHash(hash), None), }; diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 7c1ea23de4..445b9af720 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -2366,6 +2366,7 @@ async fn advance_flow_status( THEN v2_job_status.flow_leaf_jobs ELSE JSONB_SET(COALESCE(v2_job_status.flow_leaf_jobs, '{}'::JSONB), ARRAY[$7::TEXT], $8) END FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id + LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id WHERE v2_job_status.id = $1 AND v2_job_queue.id = $1 RETURNING v2_job_queue.workspace_id, @@ -2402,7 +2403,8 @@ async fn advance_flow_status( v2_job.trigger, v2_job.trigger_kind as \"trigger_kind: TriggerKindLabel\", v2_job.visible_to_owner, - NULL as permissioned_as_end_user_email", + NULL as permissioned_as_end_user_email, + job_perms.job_token_scopes", flow, &step_path as &[&str], step, @@ -4327,11 +4329,15 @@ async fn push_next_flow_job( }; // only start runners if we're not already in a squash for loop + // Runners would run its steps with their own unscoped token: a restricted flow, or a loop + // that restricts itself or any step in it, runs them as regular jobs. let start_runners = flow_runners.is_none() + && flow_job.job_token_scopes.is_none() && matches!( next_status, NextStatus::NextLoopIteration { start_runners: true, .. } - ); + ) + && !restricts_any_step(module); let do_not_pass_runners = matches!(next_status, NextStatus::NextStep { .. }) && flow_runners @@ -4746,6 +4752,16 @@ async fn push_next_flow_job( end_user_email, None, None, + // A step never holds a wider token than the flow running it, and its own setting + // narrows that further. + windmill_common::scopes::intersect_job_token_scopes( + flow_job.job_token_scopes.as_deref(), + windmill_common::scopes::step_job_token_scopes( + module.job_token_scopes.as_deref(), + ) + .as_deref(), + ) + .as_deref(), ) .warn_after_seconds(2) .await?; @@ -6330,6 +6346,16 @@ pub fn raw_script_to_payload( } } +/// Whether `module`, or any step or agent tool under it, sets `job_token_scopes`. +fn restricts_any_step(module: &FlowModule) -> bool { + let mut any = false; + let _ = FlowModule::traverse_modules(&vec![module.clone()], &mut |m: &FlowModule| { + any |= m.job_token_scopes.is_some(); + Ok(()) + }); + any +} + async fn flow_to_payload( path: String, delete_after_use: bool, @@ -6339,13 +6365,14 @@ async fn flow_to_payload( ) -> Result { let flow_info = get_latest_flow_version_info_for_path(None, &db, w_id, &path, true).await?; let on_behalf_of = flow_info.on_behalf_of(w_id, &db).await?; - let FlowVersionInfo { version, tag, .. } = flow_info; + let FlowVersionInfo { version, tag, job_token_scopes, .. } = flow_info; let payload = JobPayload::Flow { path, dedicated_worker: None, apply_preprocessor: false, version, labels: None, + job_token_scopes, }; Ok(JobPayloadWithTag { payload, @@ -6419,6 +6446,7 @@ pub async fn script_to_payload( delete_after_use, delete_after_secs, timeout, + job_token_scopes, runnable_settings: ScriptRunnableSettingsInline { concurrency_settings, debouncing_settings }, .. @@ -6436,6 +6464,7 @@ pub async fn script_to_payload( priority, apply_preprocessor: apply_preprocessor.unwrap_or(false), labels: None, + job_token_scopes, }, tag_override.to_owned().or(tag), delete_after_use, diff --git a/backend/windmill-worker/src/worker_lockfiles.rs b/backend/windmill-worker/src/worker_lockfiles.rs index 861a9625e9..b608601991 100644 --- a/backend/windmill-worker/src/worker_lockfiles.rs +++ b/backend/windmill-worker/src/worker_lockfiles.rs @@ -137,6 +137,7 @@ async fn maybe_queue_binary_prebuild( None, None, None, + None, ) .await?; tx.commit().await?; diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index 6a346b8251..a266834f41 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -52,6 +52,7 @@ export interface FlowFile { schema?: any; on_behalf_of_email?: string; has_on_behalf_of?: boolean; + job_token_scopes?: string[] | null; // Mirrors granular ACLs on the flow path. Omitted from flow.yaml when no // perms are set. The CLI applies diffs through /acls/add and /acls/remove // (see applyExtraPermsDiff) — never through update_flow — so a perm-only @@ -273,12 +274,22 @@ export async function pushFlow( // so a perm-only edit never bumps the flow version. Strip the field from the // body that goes to update_flow / create_flow and treat it as a separate // step both for the up-to-date short-circuit and after the deploy. - const { extra_perms: localPerms, ...localFlowBody } = localFlow as FlowFile & { + const { + extra_perms: localPerms, + job_token_scopes: localJobTokenScopes, + ...localFlowBody + } = localFlow as FlowFile & { extra_perms?: Record; }; + // Always sent, unlike the other settings: the server keeps a restriction the body omits, + // so a flow.yaml without the key has to clear it explicitly. + const jobTokenScopes = localJobTokenScopes ?? null; if (flow) { - if (isSuperset(localFlowBody, flow)) { + if ( + isSuperset(localFlowBody, flow) && + JSON.stringify(jobTokenScopes) === JSON.stringify(flow.job_token_scopes ?? null) + ) { log.info(colors.green(`Flow ${remotePath} is up to date`)); } else { log.info(colors.bold.yellow(`Updating flow ${remotePath}...`)); @@ -289,6 +300,7 @@ export async function pushFlow( path: remotePath.replaceAll(SEP, "/"), deployment_message: message, ...localFlowBody, + job_token_scopes: jobTokenScopes, ...preserveFields, // Preserve any user draft at this path (see backend skip_draft_deletion). skip_draft_deletion: true, @@ -304,6 +316,7 @@ export async function pushFlow( path: remotePath.replaceAll(SEP, "/"), deployment_message: message, ...localFlowBody, + job_token_scopes: jobTokenScopes, ...preserveFields, // Preserve any user draft at this path (see backend skip_draft_deletion). skip_draft_deletion: true, diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 06db3db1bf..4938b6aeb3 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -636,6 +636,9 @@ export async function handleFile( envs: typed?.envs, modules: modules, labels: typed?.labels, + // Always sent: the server keeps a restriction the body omits, so a script.yaml without + // the key has to clear it explicitly. + job_token_scopes: typed?.job_token_scopes ?? null, }; const hasOnBehalfOf = (typed as any)?.has_on_behalf_of ?? !!typed?.on_behalf_of_email; @@ -712,6 +715,7 @@ export async function handleFile( (hasOnBehalfOf ? true : typed.on_behalf_of_email == remote.on_behalf_of_email) && deepEqual(typed.envs, remote.envs) && deepEqual(typed.labels ?? null, remote.labels ?? null) && + deepEqual(typed.job_token_scopes ?? null, remote.job_token_scopes ?? null) && deepEqual(modules ?? null, remote.modules ?? null)) ) { log.info(colors.green(`Script ${remotePath} is up to date`)); diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index d087448dad..feca594fed 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6187,7 +6187,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":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"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, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"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, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"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"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"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"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n\`memory_id\`). Without a memory id the agent runs without memory.\\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the \`memory_id\` here.\\nThe step's own \`memory_id\` is not read while this kind is set; switch the kind to \`window\`\\nto use it. Without a \`context_length\`, or with 0, it is \`off\` and reads \`previous_messages\`.\\n","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"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's \`memory_id\`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n"}},"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","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to \`off\` with \`previous_messages\` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see \`memory_id\`. While it is off, a step can supply its history in \`previous_messages\`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","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","dbt"]},"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":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"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\\nflow.input syntax. Required unless memory is off and \`previous_messages\` supplies\\nthe prompt; image output always needs it.\\n"},"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"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the \`memory_id\` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as \`flow_input.customer_id\` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile \`memory\` is \`window\` or \`compaction\`: it is ignored when memory is off, and an\\nolder \`auto\` or \`manual\` memory reads neither history input.\\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while \`memory\` is off or absent: managed memory\\nignores it, and an older \`auto\` or \`manual\` memory reads neither history input.\\n"},"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"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in \`tools\` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\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"}}},"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."},"agent":{"type":"string","description":"Path of a reusable \`ai_agent\` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n\`agent\` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["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":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"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, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"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, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"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"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"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"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"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"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n\`memory_id\`). Without a memory id the agent runs without memory.\\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the \`memory_id\` here.\\nThe step's own \`memory_id\` is not read while this kind is set; switch the kind to \`window\`\\nto use it. Without a \`context_length\`, or with 0, it is \`off\` and reads \`previous_messages\`.\\n","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"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's \`memory_id\`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n"}},"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","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to \`off\` with \`previous_messages\` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see \`memory_id\`. While it is off, a step can supply its history in \`previous_messages\`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","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","dbt"]},"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":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"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."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"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\\nflow.input syntax. Required unless memory is off and \`previous_messages\` supplies\\nthe prompt; image output always needs it.\\n"},"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"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the \`memory_id\` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as \`flow_input.customer_id\` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile \`memory\` is \`window\` or \`compaction\`: it is ignored when memory is off, and an\\nolder \`auto\` or \`manual\` memory reads neither history input.\\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while \`memory\` is off or absent: managed memory\\nignores it, and an older \`auto\` or \`manual\` memory reads neither history input.\\n"},"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"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in \`tools\` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\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"}}},"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."},"agent":{"type":"string","description":"Path of a reusable \`ai_agent\` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n\`agent\` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["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 diff --git a/cli/windmill-utils-internal/src/deploy.ts b/cli/windmill-utils-internal/src/deploy.ts index 0d70e231cf..71447c6b1f 100644 --- a/cli/windmill-utils-internal/src/deploy.ts +++ b/cli/windmill-utils-internal/src/deploy.ts @@ -458,6 +458,8 @@ export async function deployItem( path, requestBody: { ...flow, + // Sent even when unset: the backend keeps a restriction an omitted field leaves out. + job_token_scopes: flow.job_token_scopes ?? null, preserve_on_behalf_of: preserveOnBehalfOf, on_behalf_of_email: onBehalfOf, // Usernames are per-workspace, so the source's principal names nobody in @@ -471,6 +473,8 @@ export async function deployItem( workspace: workspaceTo, requestBody: { ...flow, + // Sent even when unset: the backend keeps a restriction an omitted field leaves out. + job_token_scopes: flow.job_token_scopes ?? null, preserve_on_behalf_of: preserveOnBehalfOf, on_behalf_of_email: onBehalfOf, // Usernames are per-workspace, so the source's principal names nobody in @@ -498,6 +502,7 @@ export async function deployItem( requestBody: { ...script, lock: script.lock, + job_token_scopes: script.job_token_scopes ?? null, parent_hash: parentHash, preserve_on_behalf_of: preserveOnBehalfOf, on_behalf_of_email: onBehalfOf, @@ -889,6 +894,7 @@ export async function getItemValue( summary: flow.summary, description: flow.description, value: flow.value, + job_token_scopes: flow.job_token_scopes ?? null, }; } else if (kind === "script") { const script = await provider.getScriptByPath({ workspace, path }); @@ -898,6 +904,7 @@ export async function getItemValue( schema: script.schema, summary: script.summary, language: script.language, + job_token_scopes: script.job_token_scopes ?? null, }; } else if (kind === "app" || kind === "raw_app") { return await provider.getAppByPath({ workspace, path }); diff --git a/docs/auth-surface.md b/docs/auth-surface.md index 15f2538cc5..38e5cefbf6 100644 --- a/docs/auth-surface.md +++ b/docs/auth-surface.md @@ -52,6 +52,29 @@ Symbols, not line numbers, are cited: they drift less. with that reader's permissions, outside the folder too. `ai_skill` bodies are likewise in play for every reader until turned off. The resource ACL is the only control, so granting write on a folder grants that influence. +- **Restricted job tokens** (`job_token_scopes` on scripts and flows): a job's effective scopes + are stored on its `job_perms` row at push and minted into its token + (`create_token_for_owner`); an empty set is minted as `NO_API_ACCESS_SCOPE`, since + `scopes: Some([])` reads as unscoped. Every `push` takes a `scope_ceiling`, and the job gets the + ceiling ∩ the target's own setting: flow steps take their flow job's, agent tools their agent's, + WAC children their parent's, retries the run they replace, and API pushes the calling job's + (`caller_scope_ceiling`) — a scoped user or webhook token caps nothing. The row is swept once a + job leaves the queue, so a restart of a completed flow takes the flow's current setting. Schedules, + triggers and error/success handlers start from the target's own setting. A restricted token + keeps only the runtime routes about its own job (`is_own_job_runtime_route`) and the reads of + its own flow run (`flow_run_read_route_job`, checked against its lineage: the orchestrator + evaluates a step's `results.x` with the token of the step that just finished). That lineage is + trusted, so a restricted job token, even an admin's, can only place a job it starts in its own + run (`unclaimable_run_lineage`); a route that + authenticates a token itself instead of through the route layer must call + `check_job_token_scope`. A restricted job never runs on a dedicated worker, which uses its own + unscoped token for every job. `$var:`/`$res:` args are resolved through the API with the job's + own token, so they need read scopes. A deploy that omits the setting keeps the deployed value + and `null` clears it, so an unaware client cannot drop a restriction; setting one is refused + until every worker supports it, since an older worker mints without it. The setting belongs to + the deployed version: an older script hash run by hash runs with that version's setting (a + restricted caller still caps it). Write scopes on scripts, flows, schedules or triggers let a + job escape its restriction. - **A remote deploy token is a credential for another instance**, held per account and workspace (`remote_deploy_token`, encrypted under the workspace key, re-keyed by `set_encryption_key`). Its `email` references `password(email)` with `ON DELETE/UPDATE CASCADE`, so whatever deletes or diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 289dfa21d4..7d92e08484 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -618,7 +618,9 @@ on_behalf_of: flow.on_behalf_of, preserve_on_behalf_of: $preserveOnBehalfOf || undefined, deployment_message: deploymentMsg || undefined, - labels: (flow as any).labels + labels: (flow as any).labels, + // Sent explicitly: an omitted field keeps the deployed value. + job_token_scopes: flow.job_token_scopes ?? null } }) // Gated for operators without `manage_triggers`, who cannot have captures to move. @@ -670,7 +672,9 @@ on_behalf_of: flow.on_behalf_of, preserve_on_behalf_of: $preserveOnBehalfOf || undefined, deployment_message: deploymentMsg || undefined, - labels: (flow as any).labels + labels: (flow as any).labels, + // Sent explicitly: an omitted field keeps the deployed value. + job_token_scopes: flow.job_token_scopes ?? null } }) } diff --git a/frontend/src/lib/components/JobTokenScopesSetting.svelte b/frontend/src/lib/components/JobTokenScopesSetting.svelte new file mode 100644 index 0000000000..6ef1ecf3c4 --- /dev/null +++ b/frontend/src/lib/components/JobTokenScopesSetting.svelte @@ -0,0 +1,89 @@ + + +
+ { + if (value != null) { + value = null + } else { + setMode('oidc_only') + } + }} + options={{ + right: 'Restrict the job token', + rightTooltip: + "Limit what the WM_TOKEN of this " + + kind + + "'s jobs can do through the API, e.g. only mint an OIDC token. Use it for code that handles untrusted input, such as AI agents." + }} + /> + {#if value != null} + setMode(v)}> + {#snippet children({ item })} + + + + {/snippet} + + {#if mode === 'custom'} + value ?? [], (scopes) => (value = [...scopes])} + emptyLabel="No scopes selected. The job token can only reach its own job." + /> + {/if} +

+ {#if kind === 'step'} + The WM_TOKEN of every job this step runs can only do what these scopes allow, on top + of the flow's own restriction. Loop and branch bodies, and AI agent tools, inherit it. + {:else} + The WM_TOKEN of every job of this {kind} can only do what these scopes allow. Jobs + it starts, {kind === 'flow' ? 'its steps, ' : ''}and AI agent tools inherit the restriction. + {/if} + Variables and resources passed as $var:/$res: inputs, an AI agent's provider + resource, relative imports and object storage each need a read scope, and script state + (getState/setState) needs resources:write. Write scopes on scripts, + flows, schedules or triggers let a job escape the restriction. Jobs with a restricted token never + run on dedicated workers. +

+ {/if} +
diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 05d0f68496..62f9851fcc 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -83,6 +83,7 @@ import { fade } from 'svelte/transition' import Popover from './Popover.svelte' import Toggle from './Toggle.svelte' + import JobTokenScopesSetting from './JobTokenScopesSetting.svelte' import ScriptSchema from './ScriptSchema.svelte' import Section from './Section.svelte' import Label from './Label.svelte' @@ -787,7 +788,9 @@ preserve_on_behalf_of: preserveOnBehalfOf || undefined, assets: script.assets, modules: script.modules, - labels: script.labels + labels: script.labels, + // Sent explicitly: an omitted field keeps the deployed value. + job_token_scopes: script.job_token_scopes ?? null } }) @@ -2065,6 +2068,9 @@ /> +
+ +
{/if} + +
+ +
diff --git a/frontend/src/lib/components/flows/content/FlowSettings.svelte b/frontend/src/lib/components/flows/content/FlowSettings.svelte index d607da6ac1..079647112c 100644 --- a/frontend/src/lib/components/flows/content/FlowSettings.svelte +++ b/frontend/src/lib/components/flows/content/FlowSettings.svelte @@ -5,6 +5,7 @@ import Path from '$lib/components/Path.svelte' import FlowCard from '../common/FlowCard.svelte' import Toggle from '$lib/components/Toggle.svelte' + import JobTokenScopesSetting from '$lib/components/JobTokenScopesSetting.svelte' import { Alert, Button, SecondsInput } from '$lib/components/common' import { getContext } from 'svelte' import type { FlowEditorContext } from '../types' @@ -101,6 +102,7 @@ }, { name: 'Error Handler Muted', active: Boolean(flowStore.val.ws_error_handler_muted) }, { name: 'Invisible to Others', active: Boolean(flowStore.val.visible_to_runner_only) }, + { name: 'Restricted Job Token', active: flowStore.val.job_token_scopes != null }, { name: 'Shared Directory', active: Boolean(flowStore.val.value.same_worker) }, { name: 'Preserve Step Tags', active: Boolean(flowStore.val.value.preserve_step_tags) }, { name: 'Cache Results', active: Boolean(flowStore.val.value.cache_ttl) }, @@ -724,6 +726,13 @@ type="flow" /> {/if} + + diff --git a/frontend/src/lib/components/flows/header/FlowYamlEditor.svelte b/frontend/src/lib/components/flows/header/FlowYamlEditor.svelte index 9879b2899b..cd9d7647cf 100644 --- a/frontend/src/lib/components/flows/header/FlowYamlEditor.svelte +++ b/frontend/src/lib/components/flows/header/FlowYamlEditor.svelte @@ -86,6 +86,9 @@ if (parsed['on_behalf_of'] !== undefined) { flowStore.val.on_behalf_of = parsed['on_behalf_of'] } + if (parsed['job_token_scopes'] !== undefined) { + flowStore.val.job_token_scopes = parsed['job_token_scopes'] + } flowStore.val.value = parsed.value flowStore.val.schema = parsed.schema flowStore.val.tag = parsed.tag diff --git a/frontend/src/lib/components/flows/types.ts b/frontend/src/lib/components/flows/types.ts index 40e9ddc952..eff88e6920 100644 --- a/frontend/src/lib/components/flows/types.ts +++ b/frontend/src/lib/components/flows/types.ts @@ -41,6 +41,7 @@ export type ExtendedOpenFlow = OpenFlow & { visible_to_runner_only?: boolean on_behalf_of_email?: string on_behalf_of?: string + job_token_scopes?: string[] | null } export type FlowInputEditorState = { diff --git a/frontend/src/lib/components/flows/utils.svelte.ts b/frontend/src/lib/components/flows/utils.svelte.ts index 2b479abceb..7104deb382 100644 --- a/frontend/src/lib/components/flows/utils.svelte.ts +++ b/frontend/src/lib/components/flows/utils.svelte.ts @@ -107,6 +107,9 @@ export function filteredContentForExport(flow: ExtendedOpenFlow) { if (flow.tag) { o['tag'] = flow.tag } + if (flow.job_token_scopes != null) { + o['job_token_scopes'] = flow.job_token_scopes + } return o } @@ -119,6 +122,7 @@ export function cleanFlow(flow: OpenFlow | any): OpenFlow & { visible_to_runner_only?: boolean on_behalf_of_email?: string on_behalf_of?: string + job_token_scopes?: string[] | null } { const newFlow: Flow = $state.snapshot(flow) diff --git a/frontend/src/lib/components/scripts/WacExportDrawer.svelte b/frontend/src/lib/components/scripts/WacExportDrawer.svelte index 59f1dca1b0..ee2c402216 100644 --- a/frontend/src/lib/components/scripts/WacExportDrawer.svelte +++ b/frontend/src/lib/components/scripts/WacExportDrawer.svelte @@ -46,6 +46,7 @@ if (script.visible_to_runner_only) exportData.visible_to_runner_only = script.visible_to_runner_only if (script.auto_kind) exportData.auto_kind = script.auto_kind if (script.has_preprocessor) exportData.has_preprocessor = script.has_preprocessor + if (script.job_token_scopes != null) exportData.job_token_scopes = script.job_token_scopes if (script.modules && Object.keys(script.modules).length > 0) exportData.modules = script.modules scriptData = exportData diff --git a/frontend/src/lib/components/settings/ScopeSelector.svelte b/frontend/src/lib/components/settings/ScopeSelector.svelte index 24c9b99562..b3293ab36e 100644 --- a/frontend/src/lib/components/settings/ScopeSelector.svelte +++ b/frontend/src/lib/components/settings/ScopeSelector.svelte @@ -15,6 +15,8 @@ class?: string /** Renders above the scope-list card, below the Selected Scopes summary. */ topSlot?: Snippet + /** What an empty selection means, shown when no scope is selected. */ + emptyLabel?: string } interface ScopeState { @@ -38,7 +40,8 @@ selectedScopes = $bindable([]), disabled = false, class: className = '', - topSlot + topSlot, + emptyLabel = 'No scopes selected. Token will have full access.' }: Props = $props() let scopeDomains = $state(null) @@ -532,7 +535,7 @@ {#if selectedScopes.length === 0} -

No scopes selected. Token will have full access.

+

{emptyLabel}

{:else if hasAdministratorScope}

Administrator scope grants full access to all resources.

' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"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, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"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, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"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"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"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"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","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"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"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","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","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","dbt"]},"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":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"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\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"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"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"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"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\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"}}},"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."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime \u2014 including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["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 +{"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":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"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, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"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, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"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"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"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"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"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"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","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"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"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","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","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","dbt"]},"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":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"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."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"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\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"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"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"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"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\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"}}},"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."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime \u2014 including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["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/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index c659bcab12..27dc888291 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -3347,7 +3347,7 @@ class SqlQuery: export const OPENFLOW_SCHEMA = `## 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":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"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, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"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, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"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"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"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"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n\`memory_id\`). Without a memory id the agent runs without memory.\\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the \`memory_id\` here.\\nThe step's own \`memory_id\` is not read while this kind is set; switch the kind to \`window\`\\nto use it. Without a \`context_length\`, or with 0, it is \`off\` and reads \`previous_messages\`.\\n","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"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's \`memory_id\`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n"}},"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","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to \`off\` with \`previous_messages\` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see \`memory_id\`. While it is off, a step can supply its history in \`previous_messages\`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","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","dbt"]},"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":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"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\\nflow.input syntax. Required unless memory is off and \`previous_messages\` supplies\\nthe prompt; image output always needs it.\\n"},"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"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the \`memory_id\` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as \`flow_input.customer_id\` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile \`memory\` is \`window\` or \`compaction\`: it is ignored when memory is off, and an\\nolder \`auto\` or \`manual\` memory reads neither history input.\\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while \`memory\` is off or absent: managed memory\\nignores it, and an older \`auto\` or \`manual\` memory reads neither history input.\\n"},"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"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in \`tools\` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\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"}}},"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."},"agent":{"type":"string","description":"Path of a reusable \`ai_agent\` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n\`agent\` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["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":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"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, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"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, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"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"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"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"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"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"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n\`memory_id\`). Without a memory id the agent runs without memory.\\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the \`memory_id\` here.\\nThe step's own \`memory_id\` is not read while this kind is set; switch the kind to \`window\`\\nto use it. Without a \`context_length\`, or with 0, it is \`off\` and reads \`previous_messages\`.\\n","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"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's \`memory_id\`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n"}},"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","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to \`off\` with \`previous_messages\` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see \`memory_id\`. While it is off, a step can supply its history in \`previous_messages\`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","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","dbt"]},"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":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"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."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"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\\nflow.input syntax. Required unless memory is off and \`previous_messages\` supplies\\nthe prompt; image output always needs it.\\n"},"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"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the \`memory_id\` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as \`flow_input.customer_id\` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile \`memory\` is \`window\` or \`compaction\`: it is ignored when memory is off, and an\\nolder \`auto\` or \`manual\` memory reads neither history input.\\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while \`memory\` is off or absent: managed memory\\nignores it, and an older \`auto\` or \`manual\` memory reads neither history input.\\n"},"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"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in \`tools\` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\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"}}},"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."},"agent":{"type":"string","description":"Path of a reusable \`ai_agent\` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n\`agent\` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["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"]}}`; export const CLI_COMMANDS = `# Windmill CLI Commands diff --git a/system_prompts/auto-generated/skills/write-flow/SKILL.md b/system_prompts/auto-generated/skills/write-flow/SKILL.md index 29f79a2ca5..a6a654c863 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -589,4 +589,4 @@ 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":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"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, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"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, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"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"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"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"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","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"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"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","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","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","dbt"]},"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":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"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\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"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"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"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"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\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"}}},"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."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime \u2014 including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["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":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"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, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"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, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"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"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"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"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"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"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","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"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"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","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","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","dbt"]},"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":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"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."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"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\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"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"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"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"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\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"}}},"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."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime \u2014 including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["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"]}}