diff --git a/.github/workflows/check-empty-fixture.yml b/.github/workflows/check-empty-fixture.yml new file mode 100644 index 0000000000..4842260645 --- /dev/null +++ b/.github/workflows/check-empty-fixture.yml @@ -0,0 +1,19 @@ +name: Check fixture is empty + +on: + push: + branches: [main] + paths: + - "fixtures/**" + pull_request: + paths: + - "fixtures/**" + +jobs: + check-empty-fixture: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Ensure fixtures/cli-sync/ has no committed snapshot + run: bash fixtures/check-empty.sh diff --git a/.github/workflows/spawn-ephemeral-backend.yml b/.github/workflows/spawn-ephemeral-backend.yml deleted file mode 100644 index 725890031a..0000000000 --- a/.github/workflows/spawn-ephemeral-backend.yml +++ /dev/null @@ -1,126 +0,0 @@ -name: Spawn Ephemeral Backend - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - workflow_dispatch: - inputs: - pr_number: - description: "PR number" - required: true - type: number - -jobs: - check-membership: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '/spawnbackend')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/spawnbackend')) - uses: ./.github/workflows/check-org-membership.yml - secrets: - access_token: ${{ secrets.ORG_ACCESS_TOKEN }} - - spawn-backend: - needs: check-membership - # Only run on PR comments that contain /spawn-backend, or manual dispatch - if: | - github.event_name == 'workflow_dispatch' || - (github.event.issue.pull_request && needs.check-membership.outputs.is_member == 'true') - runs-on: ubuntu-latest - permissions: - pull-requests: write - contents: read - - steps: - - name: Get PR details - id: pr-details - uses: actions/github-script@v7 - with: - script: | - const prNumber = context.eventName === 'workflow_dispatch' - ? context.payload.inputs.pr_number - : context.issue.number; - - const pr = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber - }); - - // Get branch name and format it for Cloudflare Pages - // Replace '/' with '-' for the URL - const branchName = pr.data.head.ref; - const formattedBranch = branchName.replace(/\//g, '-'); - const cfFrontendUrl = `https://${formattedBranch}.windmill.pages.dev`; - - core.setOutput('commit_hash', pr.data.head.sha); - core.setOutput('pr_number', prNumber); - core.setOutput('branch_name', branchName); - core.setOutput('cf_frontend_url', cfFrontendUrl); - - - name: Check manager URL - id: check-manager-url - run: | - if [ -z "${{ secrets.EPHEMERAL_BACKEND_QUEUE_URL }}" ]; then - echo "manager_url_set=false" >> $GITHUB_OUTPUT - else - echo "manager_url_set=true" >> $GITHUB_OUTPUT - fi - - - name: Post error comment if manager not running - if: steps.check-manager-url.outputs.manager_url_set == 'false' - uses: actions/github-script@v7 - with: - script: | - const prNumber = context.eventName === 'workflow_dispatch' - ? Number(context.payload.inputs.pr_number) - : context.issue.number; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: `❌ Manager URL not set (did you start the ephemeral backend manager?)\n\nThe ephemeral backend manager needs to be running to spawn backends. Please start the manager first.` - }); - - - name: Fail if manager not running - if: steps.check-manager-url.outputs.manager_url_set == 'false' - run: | - echo "Error: EPHEMERAL_BACKEND_QUEUE_URL secret is not set" - exit 1 - - - name: Trigger Windmill flow - if: steps.check-manager-url.outputs.manager_url_set == 'true' - id: trigger-flow - run: | - JOB_UUID=$(curl -s -X POST "https://app.windmill.dev/api/w/windmill-labs/jobs/run/f/f/all/run_ephemeral_backend" \ - -H "Authorization: Bearer ${{ secrets.WINDMILL_RUN_FLOW_TOKEN }}" \ - -H "Content-Type: application/json" \ - -d '{ - "manager_url": "${{ secrets.EPHEMERAL_BACKEND_QUEUE_URL }}", - "commit_hash": "${{ steps.pr-details.outputs.commit_hash }}", - "pr_number": ${{ steps.pr-details.outputs.pr_number }}, - "cf_frontend_url": "${{ steps.pr-details.outputs.cf_frontend_url }}" - }' | tr -d '"') - - echo "Job UUID: $JOB_UUID" - echo "job_uuid=$JOB_UUID" >> $GITHUB_OUTPUT - - - name: Post comment with job link - if: steps.check-manager-url.outputs.manager_url_set == 'true' - uses: actions/github-script@v7 - with: - script: | - const jobUuid = '${{ steps.trigger-flow.outputs.job_uuid }}'; - const appUrl = `https://app.windmill.dev/public/windmill-labs/a106bad0256c1dfa7a4f9279c42b1a4b#${jobUuid}`; - const prNumber = context.eventName === 'workflow_dispatch' - ? Number(context.payload.inputs.pr_number) - : context.issue.number; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: `🚀 Spawning new ephemeral backend!\n\n${appUrl}` - }); diff --git a/CHANGELOG.md b/CHANGELOG.md index 5766e7cd05..fffb413e6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ # Changelog +## [1.709.0](https://github.com/windmill-labs/windmill/compare/v1.708.0...v1.709.0) (2026-05-25) + + +### Features + +* add copy button to Path component ([#9311](https://github.com/windmill-labs/windmill/issues/9311)) ([98bd5e7](https://github.com/windmill-labs/windmill/commit/98bd5e7f2a437b8b534028838b6ed0d7c59f7011)) +* **ai-chat:** align footer bar + DropdownV2 mode/autonomy selectors ([#9308](https://github.com/windmill-labs/windmill/issues/9308)) ([2f50e8b](https://github.com/windmill-labs/windmill/commit/2f50e8bab0b5ae9ae297c79abfe96df441f405e2)) +* **ai-chat:** expand chat question answers ([#9310](https://github.com/windmill-labs/windmill/issues/9310)) ([3f219ae](https://github.com/windmill-labs/windmill/commit/3f219aed98d93158aefce01bb51ed12dcb4711a1)) +* plug global chat drafts into userdraft ([#9291](https://github.com/windmill-labs/windmill/issues/9291)) ([1eef531](https://github.com/windmill-labs/windmill/commit/1eef53170b1b2afb75b9812e33787d1f28cf50dd)) +* **raw_apps:** surface UI Builder build errors over the preview pane ([#9316](https://github.com/windmill-labs/windmill/issues/9316)) ([90a196d](https://github.com/windmill-labs/windmill/commit/90a196d8d81993ffc2377d7088ab98f7b0f5ddcc)) +* **raw_apps:** tab-based editor surface with split-with-preview ([#9273](https://github.com/windmill-labs/windmill/issues/9273)) ([368e677](https://github.com/windmill-labs/windmill/commit/368e6774194a58058f28d1b4a42f8f4a7ec4ab63)) +* **service-accounts:** allow choosing role at creation time ([#9307](https://github.com/windmill-labs/windmill/issues/9307)) ([b125eca](https://github.com/windmill-labs/windmill/commit/b125eca7628b07c071bd102b161d389259fd6c62)) + + +### Bug Fixes + +* **auth:** filter resource/variable listings by token scope (WIN-1981) ([#9302](https://github.com/windmill-labs/windmill/issues/9302)) ([b5a0d46](https://github.com/windmill-labs/windmill/commit/b5a0d46695fdfe692d64573d1cfa06511e3b33f5)) +* **jobs:** authorization bypass in only_result job updates (WIN-1980) ([#9301](https://github.com/windmill-labs/windmill/issues/9301)) ([108a88a](https://github.com/windmill-labs/windmill/commit/108a88a1801548c8570d56aa3e1eb80246367bf4)) + +## [1.708.0](https://github.com/windmill-labs/windmill/compare/v1.707.0...v1.708.0) (2026-05-24) + + +### Features + +* **queue:** per-workspace fairness cap on the shared cloud worker pool ([#9303](https://github.com/windmill-labs/windmill/issues/9303)) ([de2e243](https://github.com/windmill-labs/windmill/commit/de2e243313ee34348675dec600cb412b475d1b4b)) + +## [1.707.0](https://github.com/windmill-labs/windmill/compare/v1.706.1...v1.707.0) (2026-05-22) + + +### Features + +* add wmill job rerun subcommand ([#9275](https://github.com/windmill-labs/windmill/issues/9275)) ([e0ffea2](https://github.com/windmill-labs/windmill/commit/e0ffea2deb5acf30815edd3669f4fc4c818b6e19)) +* **github-app:** hide cloud-only UI on self-managed + admin assignment UI ([#9299](https://github.com/windmill-labs/windmill/issues/9299)) ([dcee8cc](https://github.com/windmill-labs/windmill/commit/dcee8cc0d3dd71c3a12f1720e3ce4eb86cdacf4f)) +* **typescript-client:** add deleteS3File + optional workspace arg on S3 helpers ([#9300](https://github.com/windmill-labs/windmill/issues/9300)) ([daab561](https://github.com/windmill-labs/windmill/commit/daab561ec0763468d93e42e8f7f0796dc77be74d)) + + +### Bug Fixes + +* **auth:** tighten token-owner fallback for unscoped tokens (WIN-1978) ([#9293](https://github.com/windmill-labs/windmill/issues/9293)) ([7003998](https://github.com/windmill-labs/windmill/commit/7003998a575d76c272c6abd0789a1d1f7b722076)) +* **cli:** wmill sync pull updates wmill-lock.yaml for raw apps ([#9289](https://github.com/windmill-labs/windmill/issues/9289)) ([486e5f9](https://github.com/windmill-labs/windmill/commit/486e5f947b1649c17d32e3b214c50d4be701a4e8)) +* flow recording teardown crash + rename package to @windmill-labs/components ([#9288](https://github.com/windmill-labs/windmill/issues/9288)) ([13a2fae](https://github.com/windmill-labs/windmill/commit/13a2fae745ba4862006db5ee0811475c1d27fd1d)) +* **flows:** restore Variables and Resources in flow editor prop picker ([#9290](https://github.com/windmill-labs/windmill/issues/9290)) ([5566c7b](https://github.com/windmill-labs/windmill/commit/5566c7b3ff2d5a6b15cb9187aa15ce1c7245b3fb)) +* **ResourceEditor:** don't reset state when `selected` reverts to undefined ([#9295](https://github.com/windmill-labs/windmill/issues/9295)) ([1f2d2c1](https://github.com/windmill-labs/windmill/commit/1f2d2c11493db20b87615d41c21e5e1c35564739)) +* **secret-backend:** pass DB to Vault migrations + show failure details ([#9292](https://github.com/windmill-labs/windmill/issues/9292)) ([ace2291](https://github.com/windmill-labs/windmill/commit/ace22910c40585a6a2c9abd0c46f7e5e0214e78e)) + ## [1.706.1](https://github.com/windmill-labs/windmill/compare/v1.706.0...v1.706.1) (2026-05-22) diff --git a/ai_evals/adapters/frontend/benchmarkRunner.ts b/ai_evals/adapters/frontend/benchmarkRunner.ts index 14be108a10..1729df7170 100644 --- a/ai_evals/adapters/frontend/benchmarkRunner.ts +++ b/ai_evals/adapters/frontend/benchmarkRunner.ts @@ -10,10 +10,6 @@ import { runSuite } from "../../core/runSuite"; import type { BenchmarkRunResult, ModeRunner } from "../../core/types"; import { resolveWindmillBackendSettings } from "../../core/windmillBackendSettings"; import { emitFrontendBenchmarkProgress } from "./progress"; -import { createAppModeRunner } from "../../modes/app"; -import { createFlowModeRunner } from "../../modes/flow"; -import { createGlobalModeRunner } from "../../modes/global"; -import { createScriptModeRunner } from "../../modes/script"; import { DEFAULT_JUDGE_MODEL } from "../../core/judge"; export type FrontendBenchmarkMode = "flow" | "app" | "script" | "global"; @@ -40,7 +36,7 @@ export async function runFrontendBenchmarkFromEnv(): Promise const backendSettings = resolveWindmillBackendSettings(); const selectedCases = await loadSelectedCases(mode, caseIds); - const modeRunner = getModeRunner( + const modeRunner = await getModeRunner( mode, getFrontendEvalModel(model), backendValidation, @@ -69,25 +65,33 @@ export async function runFrontendBenchmarkFromEnv(): Promise }); } -function getModeRunner( +async function getModeRunner( mode: FrontendBenchmarkMode, model: ReturnType, backendValidation: ReturnType, backendSettings: ReturnType, -): ModeRunner { +): Promise> { switch (mode) { - case "flow": + case "flow": { + const { createFlowModeRunner } = await import("../../modes/flow"); return createFlowModeRunner(model, backendValidation, backendSettings); - case "app": + } + case "app": { + const { createAppModeRunner } = await import("../../modes/app"); return createAppModeRunner(model, backendSettings); - case "script": + } + case "script": { + const { createScriptModeRunner } = await import("../../modes/script"); return createScriptModeRunner( model, backendValidation, backendSettings, ); - case "global": + } + case "global": { + const { createGlobalModeRunner } = await import("../../modes/global"); return createGlobalModeRunner(model, backendSettings); + } } } diff --git a/backend/.sqlx/query-0cca68f11329cd41ab9372297b715af008ea7db408f49cf525656d6224092429.json b/backend/.sqlx/query-0cca68f11329cd41ab9372297b715af008ea7db408f49cf525656d6224092429.json new file mode 100644 index 0000000000..ccad8d570d --- /dev/null +++ b/backend/.sqlx/query-0cca68f11329cd41ab9372297b715af008ea7db408f49cf525656d6224092429.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT operator, is_admin FROM usr WHERE email = $1 AND is_service_account IS true LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "operator", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "is_admin", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "0cca68f11329cd41ab9372297b715af008ea7db408f49cf525656d6224092429" +} diff --git a/backend/.sqlx/query-14bc9dd1d02a3d121297509beacc27f3c29d1b3877c1f2e7c206f0e36ef18701.json b/backend/.sqlx/query-14bc9dd1d02a3d121297509beacc27f3c29d1b3877c1f2e7c206f0e36ef18701.json new file mode 100644 index 0000000000..6310017f18 --- /dev/null +++ b/backend/.sqlx/query-14bc9dd1d02a3d121297509beacc27f3c29d1b3877c1f2e7c206f0e36ef18701.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n (elem->>'installation_id')::bigint as installation_id,\n elem->>'account_id' as account_id,\n elem->>'github_base_url' as github_base_url,\n COALESCE((elem->>'provisioned_by_admin')::bool, false) as \"provisioned_by_admin!\"\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "installation_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "account_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "github_base_url", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "provisioned_by_admin!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null, + null, + null + ] + }, + "hash": "14bc9dd1d02a3d121297509beacc27f3c29d1b3877c1f2e7c206f0e36ef18701" +} diff --git a/backend/.sqlx/query-1d0341bd8de94ab8d34a4bb1bb2005305fb3b29751ec987bece183444e89e7d1.json b/backend/.sqlx/query-1d0341bd8de94ab8d34a4bb1bb2005305fb3b29751ec987bece183444e89e7d1.json new file mode 100644 index 0000000000..b7492622f7 --- /dev/null +++ b/backend/.sqlx/query-1d0341bd8de94ab8d34a4bb1bb2005305fb3b29751ec987bece183444e89e7d1.json @@ -0,0 +1,100 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, NULL::bool as is_workspace_admin, first_time_user, role_source, disabled, NULL::text as workspace_id FROM password WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "login_type", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "super_admin", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "devops", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "verified", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "company", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "operator_only", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "is_workspace_admin", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "first_time_user", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "role_source", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "disabled", + "type_info": "Bool" + }, + { + "ordinal": 13, + "name": "workspace_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + null, + false, + false, + false, + true, + true, + true, + null, + null, + false, + false, + false, + null + ] + }, + "hash": "1d0341bd8de94ab8d34a4bb1bb2005305fb3b29751ec987bece183444e89e7d1" +} diff --git a/backend/.sqlx/query-2f166b5575a614b028c3130fc5089353bef40f1cccf31b7775d0e9a800425f4d.json b/backend/.sqlx/query-2f166b5575a614b028c3130fc5089353bef40f1cccf31b7775d0e9a800425f4d.json new file mode 100644 index 0000000000..5964de4111 --- /dev/null +++ b/backend/.sqlx/query-2f166b5575a614b028c3130fc5089353bef40f1cccf31b7775d0e9a800425f4d.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT COALESCE((elem->>'provisioned_by_admin')::bool, false) as \"is_admin!\"\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n AND (elem->>'installation_id')::bigint = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "is_admin!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "2f166b5575a614b028c3130fc5089353bef40f1cccf31b7775d0e9a800425f4d" +} diff --git a/backend/.sqlx/query-3c42a56d0ffe39ad217f2ee603431637bcb22c6e713a21bdb83204de9cf383d7.json b/backend/.sqlx/query-3c42a56d0ffe39ad217f2ee603431637bcb22c6e713a21bdb83204de9cf383d7.json new file mode 100644 index 0000000000..83a2c7bfc6 --- /dev/null +++ b/backend/.sqlx/query-3c42a56d0ffe39ad217f2ee603431637bcb22c6e713a21bdb83204de9cf383d7.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM workspace_settings WHERE workspace_id = $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "3c42a56d0ffe39ad217f2ee603431637bcb22c6e713a21bdb83204de9cf383d7" +} diff --git a/backend/.sqlx/query-6fcdb09cdd7fedd7e54fdc0e49203f453fc1b85272fe212c0e1bf0ebd28bf58a.json b/backend/.sqlx/query-6fcdb09cdd7fedd7e54fdc0e49203f453fc1b85272fe212c0e1bf0ebd28bf58a.json new file mode 100644 index 0000000000..17fbb0d5bb --- /dev/null +++ b/backend/.sqlx/query-6fcdb09cdd7fedd7e54fdc0e49203f453fc1b85272fe212c0e1bf0ebd28bf58a.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH capped AS (\n SELECT timestamp, operation, resource, parameters\n FROM audit_partitioned\n WHERE workspace_id = 'admins'\n AND operation = 'workspace_fairness.capped'\n UNION ALL\n SELECT timestamp, operation, resource, parameters\n FROM audit\n WHERE workspace_id = 'admins'\n AND operation = 'workspace_fairness.capped'\n ORDER BY timestamp DESC\n LIMIT 200\n ), uncapped AS (\n SELECT timestamp, operation, resource, parameters\n FROM audit_partitioned\n WHERE workspace_id = 'admins'\n AND operation = 'workspace_fairness.uncapped'\n UNION ALL\n SELECT timestamp, operation, resource, parameters\n FROM audit\n WHERE workspace_id = 'admins'\n AND operation = 'workspace_fairness.uncapped'\n ORDER BY timestamp DESC\n LIMIT 200\n )\n SELECT timestamp AS \"timestamp!\",\n operation::text AS \"operation!\",\n resource AS workspace_id,\n parameters\n FROM (SELECT * FROM capped UNION ALL SELECT * FROM uncapped) e\n ORDER BY timestamp DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "timestamp!", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "operation!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "parameters", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null, + null, + null + ] + }, + "hash": "6fcdb09cdd7fedd7e54fdc0e49203f453fc1b85272fe212c0e1bf0ebd28bf58a" +} diff --git a/backend/.sqlx/query-8aae160c589adf02e20b7e6ba860b66fb08f156776b96b7f3701aed330a7d000.json b/backend/.sqlx/query-8aae160c589adf02e20b7e6ba860b66fb08f156776b96b7f3701aed330a7d000.json new file mode 100644 index 0000000000..d05c5b11af --- /dev/null +++ b/backend/.sqlx/query-8aae160c589adf02e20b7e6ba860b66fb08f156776b96b7f3701aed330a7d000.json @@ -0,0 +1,101 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email as \"email!\", (email NOT IN (SELECT email FROM authors)) as operator_only, NULL::bool as is_workspace_admin, login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n UNION ALL\n SELECT email as \"email!\", operator as operator_only, is_admin as is_workspace_admin, 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC\n LIMIT $1 OFFSET $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "operator_only", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "is_workspace_admin", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "login_type", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "verified!", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "super_admin!", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "devops!", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "company", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "first_time_user!", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "role_source!", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "disabled!", + "type_info": "Bool" + }, + { + "ordinal": 13, + "name": "workspace_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "8aae160c589adf02e20b7e6ba860b66fb08f156776b96b7f3701aed330a7d000" +} diff --git a/backend/.sqlx/query-bce66e3f3fecda6c226556f2f3cff27702b8fc8d2850fdb262b9a2a77b468646.json b/backend/.sqlx/query-bce66e3f3fecda6c226556f2f3cff27702b8fc8d2850fdb262b9a2a77b468646.json new file mode 100644 index 0000000000..ab4532a1f5 --- /dev/null +++ b/backend/.sqlx/query-bce66e3f3fecda6c226556f2f3cff27702b8fc8d2850fdb262b9a2a77b468646.json @@ -0,0 +1,101 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email as \"email!\", login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, NULL::bool as operator_only, NULL::bool as is_workspace_admin, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id FROM password\n UNION ALL\n SELECT email as \"email!\", 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, operator as operator_only, is_admin as is_workspace_admin, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC, \"email!\"\n LIMIT $1 OFFSET $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "login_type", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "verified!", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "super_admin!", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "devops!", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "company", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "operator_only", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "is_workspace_admin", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "first_time_user!", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "role_source!", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "disabled!", + "type_info": "Bool" + }, + { + "ordinal": 13, + "name": "workspace_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "bce66e3f3fecda6c226556f2f3cff27702b8fc8d2850fdb262b9a2a77b468646" +} diff --git a/backend/.sqlx/query-dd540bcb206d931eb19aa85059b6a64554a9a29ebc6064718072470e45a0ae28.json b/backend/.sqlx/query-dd540bcb206d931eb19aa85059b6a64554a9a29ebc6064718072470e45a0ae28.json new file mode 100644 index 0000000000..a6b14c7b96 --- /dev/null +++ b/backend/.sqlx/query-dd540bcb206d931eb19aa85059b6a64554a9a29ebc6064718072470e45a0ae28.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO usr\n (workspace_id, email, username, is_admin, operator, is_service_account)\n VALUES ($1, $2, $3, $4, $5, true)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Bool", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "dd540bcb206d931eb19aa85059b6a64554a9a29ebc6064718072470e45a0ae28" +} diff --git a/backend/.sqlx/query-f5e98ff83301b89f33e4454ae944da1977030cf2db9dadd372188902bb23062f.json b/backend/.sqlx/query-f5e98ff83301b89f33e4454ae944da1977030cf2db9dadd372188902bb23062f.json new file mode 100644 index 0000000000..ac592bafbe --- /dev/null +++ b/backend/.sqlx/query-f5e98ff83301b89f33e4454ae944da1977030cf2db9dadd372188902bb23062f.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n workspace_id,\n (elem->>'installation_id')::bigint as \"installation_id!\",\n COALESCE((elem->>'provisioned_by_admin')::bool, false) as \"provisioned_by_admin!\"\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE (elem->>'installation_id')::bigint = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "installation_id!", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "provisioned_by_admin!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + null, + null + ] + }, + "hash": "f5e98ff83301b89f33e4454ae944da1977030cf2db9dadd372188902bb23062f" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index bcd0c4b860..d154d1b2bf 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -727,9 +727,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-config" @@ -1837,18 +1837,18 @@ dependencies = [ [[package]] name = "btoi" -version = "0.4.3" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd6407f73a9b8b6162d8a2ef999fe6afd7cc15902ebf42c5cd296addf17e0ad" +checksum = "3b5ab9db53bcda568284df0fd39f6eac24ad6f7ba7ff1168b9e76eba6576b976" dependencies = [ "num-traits", ] [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" dependencies = [ "allocator-api2", ] @@ -5367,6 +5367,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashify" @@ -6721,9 +6726,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "loom" @@ -6759,6 +6764,15 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -7197,26 +7211,26 @@ dependencies = [ [[package]] name = "mysql_async" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1d9585dc9058886ff3a1f48a23024dd1d054264dee7c5ae0e4bd640c953bee5" +checksum = "3519e91b0d254ac1ffa495bc42053286cb2172ad7241d5b3b1b9f8a891f21ee2" dependencies = [ "bytes", "crossbeam-queue", + "crossbeam-utils", "flate2", "futures-core", "futures-sink", "futures-util", "keyed_priority_queue", - "lru 0.16.4", + "lru 0.18.0", "mysql_common", "native-tls", "pem 3.0.6", "percent-encoding", - "rand 0.9.0", + "rand 0.10.1", "serde", - "serde_json", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tokio-native-tls", @@ -7227,9 +7241,9 @@ dependencies = [ [[package]] name = "mysql_common" -version = "0.35.5" +version = "0.37.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbb9f371618ce723f095c61fbcdc36e8936956d2b62832f9c7648689b338e052" +checksum = "4b42ced54aa8ac97226486337973f9bc3956e24f03a23e88a6e18f640959d6e2" dependencies = [ "base64 0.22.1", "bitflags 2.11.1", @@ -13788,7 +13802,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-nats", @@ -13869,12 +13883,13 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.706.1" +version = "1.709.0" dependencies = [ "async-stream", "async-trait", "aws-config", "aws-credential-types", + "aws-sdk-bedrock", "aws-sdk-bedrockruntime", "aws-smithy-types", "base64 0.22.1", @@ -13901,7 +13916,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.706.1" +version = "1.709.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13914,7 +13929,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "argon2", @@ -13923,13 +13938,8 @@ dependencies = [ "async-stream", "async-trait", "async_zip", - "aws-config", - "aws-credential-types", - "aws-sdk-bedrock", - "aws-sdk-bedrockruntime", "aws-sdk-config", "aws-sigv4", - "aws-smithy-types", "axum 0.8.9", "base32", "base64 0.22.1", @@ -14057,7 +14067,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.706.1" +version = "1.709.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14080,7 +14090,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.706.1" +version = "1.709.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14093,7 +14103,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14119,7 +14129,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.706.1" +version = "1.709.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14129,7 +14139,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.706.1" +version = "1.709.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14146,7 +14156,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.706.1" +version = "1.709.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14168,7 +14178,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14191,7 +14201,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.706.1" +version = "1.709.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14207,7 +14217,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.706.1" +version = "1.709.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14228,7 +14238,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.706.1" +version = "1.709.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14249,7 +14259,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.706.1" +version = "1.709.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14263,7 +14273,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-nats", @@ -14295,7 +14305,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14320,7 +14330,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.706.1" +version = "1.709.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14338,7 +14348,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14360,7 +14370,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.706.1" +version = "1.709.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14380,7 +14390,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.706.1" +version = "1.709.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14410,7 +14420,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14438,7 +14448,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.706.1" +version = "1.709.0" dependencies = [ "lazy_static", "serde", @@ -14450,7 +14460,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.706.1" +version = "1.709.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14475,7 +14485,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.706.1" +version = "1.709.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14489,7 +14499,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.706.1" +version = "1.709.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14522,7 +14532,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.706.1" +version = "1.709.0" dependencies = [ "chrono", "lazy_static", @@ -14536,7 +14546,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14555,7 +14565,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.706.1" +version = "1.709.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14656,7 +14666,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.706.1" +version = "1.709.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14675,7 +14685,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.706.1" +version = "1.709.0" dependencies = [ "regex", "serde", @@ -14690,7 +14700,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14714,7 +14724,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "futures", @@ -14731,7 +14741,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.706.1" +version = "1.709.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14747,7 +14757,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-trait", @@ -14768,7 +14778,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-trait", @@ -14799,7 +14809,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "arc-swap", @@ -14824,7 +14834,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-stream", @@ -14858,7 +14868,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "futures", @@ -14876,7 +14886,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.706.1" +version = "1.709.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14885,7 +14895,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "lazy_static", @@ -14897,7 +14907,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "serde_json", @@ -14909,7 +14919,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "gosyn", @@ -14921,7 +14931,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "lazy_static", @@ -14933,7 +14943,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "serde_json", @@ -14945,7 +14955,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "nu-parser", @@ -14956,7 +14966,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14967,7 +14977,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14979,7 +14989,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14990,7 +15000,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-recursion", @@ -15012,7 +15022,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "serde_json", @@ -15024,7 +15034,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "lazy_static", @@ -15038,7 +15048,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15055,7 +15065,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "lazy_static", @@ -15068,7 +15078,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "serde", @@ -15080,7 +15090,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "lazy_static", @@ -15098,7 +15108,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15114,7 +15124,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15130,7 +15140,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "serde", @@ -15141,7 +15151,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-recursion", @@ -15160,6 +15170,7 @@ dependencies = [ "once_cell", "prometheus", "quick_cache", + "rand 0.9.0", "regex", "reqwest 0.13.1", "serde", @@ -15178,7 +15189,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "const_format", @@ -15216,7 +15227,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.706.1" +version = "1.709.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15227,7 +15238,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-recursion", @@ -15257,7 +15268,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-trait", @@ -15281,7 +15292,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-trait", @@ -15314,7 +15325,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-trait", @@ -15347,7 +15358,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-trait", @@ -15367,7 +15378,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-trait", @@ -15401,7 +15412,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-trait", @@ -15437,7 +15448,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-trait", @@ -15460,7 +15471,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-trait", @@ -15484,7 +15495,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-nats", @@ -15508,7 +15519,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-trait", @@ -15543,7 +15554,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-trait", @@ -15571,11 +15582,12 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-trait", "axum 0.8.9", + "base64 0.22.1", "futures", "http 1.4.0", "itertools 0.14.0", @@ -15585,6 +15597,7 @@ dependencies = [ "tokio", "tokio-tungstenite 0.24.0", "tracing", + "url", "windmill-api-auth", "windmill-common", "windmill-git-sync", @@ -15594,7 +15607,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -15613,7 +15626,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-once-cell", @@ -15723,7 +15736,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.706.1" +version = "1.709.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 2e691b6a60..6e1898ea45 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.706.1" +version = "1.709.0" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.706.1" +version = "1.709.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 3e4e2b57c5..90cf163875 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -eb18d7b4c0e37fea3f6e1e2cc44e0fddd74ff817 +2de9dc793360764dc81b9593d72cb50347656a52 diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index cac4664f21..af99dcf776 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -174,10 +174,11 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "ast_node" -version = "3.0.4" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a184645bcc6f52d69d8e7639720699c6a99efb711f886e251ed1d16db8dd90e" +checksum = "f9184f2b369b3e8625712493c89b785881f27eedc6cde480a81883cef78868b2" dependencies = [ + "proc-macro2", "quote", "swc_macros_common", "syn 2.0.117", @@ -400,9 +401,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "better_scoped_tls" -version = "1.0.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd228125315b132eed175bf47619ac79b945b26e56b848ba203ae4ea8603609" +checksum = "297b153aa5e573b5863108a6ddc9d5c968bd0b20e75cc614ee9821d2f45679c7" dependencies = [ "scoped-tls", ] @@ -562,6 +563,9 @@ name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +dependencies = [ + "allocator-api2", +] [[package]] name = "byte-unit" @@ -609,16 +613,6 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -[[package]] -name = "bytes-str" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c60b5ce37e0b883c37eb89f79a1e26fbe9c1081945d024eee93e8d91a7e18b3" -dependencies = [ - "bytes", - "serde", -] - [[package]] name = "bytesize" version = "1.3.3" @@ -1378,10 +1372,11 @@ dependencies = [ [[package]] name = "from_variant" -version = "2.0.2" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "308530a56b099da144ebc5d8e179f343ad928fa2b3558d1eb3db9af18d6eff43" +checksum = "32016f1242eb82af5474752d00fd8ebcd9004bd69b462b1c91de833972d08ed4" dependencies = [ + "proc-macro2", "swc_macros_common", "syn 2.0.117", ] @@ -1756,14 +1751,15 @@ dependencies = [ [[package]] name = "hstr" -version = "2.1.0" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31f11d91d7befd2ffd9d216e9e5ea1fae6174b20a2a1b67a688138003d2f4122" +checksum = "a1a26def229ea95a8709dad32868d975d0dd40235bd2ce82920e4a8fe692b5e0" dependencies = [ "hashbrown 0.14.5", "new_debug_unreachable", "once_cell", - "rustc-hash 2.1.1", + "phf 0.11.3", + "rustc-hash 1.1.0", "triomphe", ] @@ -4139,17 +4135,11 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -[[package]] -name = "seq-macro" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" - [[package]] name = "serde" -version = "1.0.228" +version = "1.0.220" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "ceecad4c782e936ac90ecfd6b56532322e3262b14320abf30ce89a92ffdbfe22" dependencies = [ "serde_core", "serde_derive", @@ -4168,18 +4158,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.220" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "ddba47394f3b862d6ff6efdbd26ca4673e3566a307880a0ffb98f274bbe0ec32" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.220" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "60e1f3b1761e96def5ec6d04a6e7421c0404fa3cf5c0155f1e2848fae3d8cc08" dependencies = [ "proc-macro2", "quote", @@ -4702,10 +4692,11 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "string_enum" -version = "1.0.2" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36a4951ca7bd1cfd991c241584a9824a70f6aff1e7d4f693fb3f2465e4030e" +checksum = "05e383308aebc257e7d7920224fa055c632478d92744eca77f99be8fa1545b90" dependencies = [ + "proc-macro2", "quote", "swc_macros_common", "syn 2.0.117", @@ -4808,34 +4799,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" [[package]] -name = "swc_atoms" -version = "7.0.0" +name = "swc_allocator" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3500dcf04c84606b38464561edc5e46f5132201cb3e23cf9613ed4033d6b1bb2" +checksum = "76aa0eb65c0f39f9b6d82a7e5192c30f7ac9a78f084a21f270de1d8c600ca388" +dependencies = [ + "bumpalo", + "hashbrown 0.14.5", + "ptr_meta", + "rustc-hash 1.1.0", + "triomphe", +] + +[[package]] +name = "swc_atoms" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb6567e4e67485b3e7662b486f1565bdae54bd5b9d6b16b2ba1a9babb1e42125" dependencies = [ "hstr", "once_cell", + "rustc-hash 1.1.0", "serde", ] [[package]] name = "swc_common" -version = "14.0.4" +version = "0.37.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2bb772b3a26b8b71d4e8c112ced5b5867be2266364b58517407a270328a2696" +checksum = "12d0a8eaaf1606c9207077d75828008cb2dfb51b095a766bd2b72ef893576e31" dependencies = [ - "anyhow", "ast_node", "better_scoped_tls", - "bytes-str", + "cfg-if", "either", "from_variant", "new_debug_unreachable", "num-bigint", "once_cell", - "rustc-hash 2.1.1", + "rustc-hash 1.1.0", "serde", "siphasher 0.3.11", + "swc_allocator", "swc_atoms", "swc_eq_ignore_macros", "swc_visit", @@ -4846,36 +4851,32 @@ dependencies = [ [[package]] name = "swc_ecma_ast" -version = "15.0.0" +version = "0.118.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c25af97d53cf8aab66a6c68f3418663313fc969ad267fc2a4d19402c329be1" +checksum = "a6f866d12e4d519052b92a0a86d1ac7ff17570da1272ca0c89b3d6f802cd79df" dependencies = [ "bitflags", "is-macro", "num-bigint", - "once_cell", "phf 0.11.3", - "rustc-hash 2.1.1", + "scoped-tls", "string_enum", "swc_atoms", "swc_common", - "swc_visit", "unicode-id-start", ] [[package]] -name = "swc_ecma_lexer" -version = "23.0.2" +name = "swc_ecma_parser" +version = "0.149.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "017d06ea85008234aa9fb34d805c7dc563f2ea6e03869ed5ac5a2dc27d561e4d" +checksum = "683dada14722714588b56481399c699378b35b2ba4deb5c4db2fb627a97fb54b" dependencies = [ - "arrayvec", - "bitflags", "either", + "new_debug_unreachable", "num-bigint", + "num-traits", "phf 0.11.3", - "rustc-hash 2.1.1", - "seq-macro", "serde", "smallvec", "smartstring", @@ -4884,29 +4885,14 @@ dependencies = [ "swc_common", "swc_ecma_ast", "tracing", -] - -[[package]] -name = "swc_ecma_parser" -version = "24.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e9011783c975ba592ffc09cd208ced92b1dfabb2e5e0ef453559e2e25286127" -dependencies = [ - "either", - "num-bigint", - "serde", - "swc_atoms", - "swc_common", - "swc_ecma_ast", - "swc_ecma_lexer", - "tracing", + "typed-arena", ] [[package]] name = "swc_ecma_visit" -version = "15.0.0" +version = "0.104.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a579aa8f9e212af521588df720ccead079c09fe5c8f61007cf724324aed3a0" +checksum = "5b1c6802e68e51f336e8bc9644e9ff9da75d7da9c1a6247d532f2e908aa33e81" dependencies = [ "new_debug_unreachable", "num-bigint", @@ -4919,9 +4905,9 @@ dependencies = [ [[package]] name = "swc_eq_ignore_macros" -version = "1.0.1" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c16ce73424a6316e95e09065ba6a207eba7765496fed113702278b7711d4b632" +checksum = "63db0adcff29d220c3d151c5b25c0eabe7e32dd936212b84cdaa1392e3130497" dependencies = [ "proc-macro2", "quote", @@ -4930,9 +4916,9 @@ dependencies = [ [[package]] name = "swc_macros_common" -version = "1.0.1" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aae1efbaa74943dc5ad2a2fb16cbd78b77d7e4d63188f3c5b4df2b4dcd2faaae" +checksum = "27e18fbfe83811ffae2bb23727e45829a0d19c6870bced7c0f545cc99ad248dd" dependencies = [ "proc-macro2", "quote", @@ -4941,9 +4927,9 @@ dependencies = [ [[package]] name = "swc_visit" -version = "2.0.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62fb71484b486c185e34d2172f0eabe7f4722742aad700f426a494bb2de232a2" +checksum = "1ceb044142ba2719ef9eb3b6b454fce61ab849eb696c34d190f04651955c613d" dependencies = [ "either", "new_debug_unreachable", @@ -5079,7 +5065,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -5609,6 +5595,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typeid" version = "1.0.3" @@ -6191,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.706.1" +version = "1.709.0" dependencies = [ "aho-corasick", "anyhow", @@ -6271,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.706.1" +version = "1.709.0" dependencies = [ "proc-macro2", "quote", @@ -6283,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.706.1" +version = "1.709.0" dependencies = [ "convert_case", "serde", @@ -6292,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "lazy_static", @@ -6304,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "serde_json", @@ -6316,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "gosyn", @@ -6328,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "lazy_static", @@ -6340,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "serde_json", @@ -6352,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "nu-parser", @@ -6363,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6374,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6386,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6397,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "async-recursion", @@ -6419,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "serde_json", @@ -6431,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "lazy_static", @@ -6445,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "convert_case", @@ -6462,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "lazy_static", @@ -6475,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "serde", @@ -6487,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "lazy_static", @@ -6505,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6521,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6537,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6569,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "serde", @@ -6580,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.706.1" +version = "1.709.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index ca8ecfb04d..8b6f194448 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.706.1" +version = "1.709.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/src/main.rs b/backend/src/main.rs index ecc49c22f6..87285e2ae1 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -59,7 +59,9 @@ use windmill_common::{ RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, - UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_REGISTRIES_SETTING, + UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, + WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, + WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, WORKSPACE_REGISTRIES_SETTING, }, scripts::ScriptLang, stats_oss::schedule_stats, @@ -120,7 +122,9 @@ use crate::monitor::{ initial_load, load_disable_password_login, load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir, load_metrics_debug_enabled, load_preview_tags_override, load_require_preexisting_user, load_tag_per_workspace_enabled, - load_tag_per_workspace_workspaces, monitor_db, reload_app_workspaced_route_setting, + load_tag_per_workspace_workspaces, load_workspace_fairness_duration_secs, + load_workspace_fairness_enabled, load_workspace_fairness_max_percent, + load_workspace_fairness_min_total, monitor_db, reload_app_workspaced_route_setting, reload_audit_log_retention_days_setting, reload_base_url_setting, reload_bun_install_min_release_age_setting, reload_bunfig_install_scopes_setting, reload_critical_alert_mute_ui_setting, reload_critical_alerts_on_token_expiry_setting, @@ -1765,6 +1769,26 @@ async fn process_notify_event( tracing::error!("Error loading preview tags override: {e:#}"); } } + WORKSPACE_FAIRNESS_ENABLED_SETTING => { + if let Err(e) = load_workspace_fairness_enabled(db).await { + tracing::error!("Error loading workspace fairness enabled: {e:#}"); + } + } + WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING => { + if let Err(e) = load_workspace_fairness_max_percent(db).await { + tracing::error!("Error loading workspace fairness max percent: {e:#}"); + } + } + WORKSPACE_FAIRNESS_DURATION_SECS_SETTING => { + if let Err(e) = load_workspace_fairness_duration_secs(db).await { + tracing::error!("Error loading workspace fairness duration secs: {e:#}"); + } + } + WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING => { + if let Err(e) = load_workspace_fairness_min_total(db).await { + tracing::error!("Error loading workspace fairness min total: {e:#}"); + } + } SMTP_SETTING => { reload_smtp_config(db).await; } diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 1f00990604..0c6e627718 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -69,6 +69,8 @@ use windmill_common::{ RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, + WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, + WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, }, indexer::load_indexer_config, jwt::JWT_SECRET, @@ -84,7 +86,8 @@ use windmill_common::{ store_suspended_pull_query, Connection, WorkerConfig, DEFAULT_TAGS_PER_WORKSPACE, DEFAULT_TAGS_WORKSPACES, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX, INDEXER_CONFIG, PREVIEW_TAGS_OVERRIDE, SCRIPT_TOKEN_EXPIRY, SMTP_CONFIG, WINDMILL_DIR, WORKER_CONFIG, - WORKER_GROUP, + WORKER_GROUP, WORKSPACE_FAIRNESS_DURATION_SECS, WORKSPACE_FAIRNESS_ENABLED, + WORKSPACE_FAIRNESS_MAX_PERCENT, WORKSPACE_FAIRNESS_MIN_TOTAL, }, KillpillSender, AUDIT_LOG_RETENTION_DAYS, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE, CRITICAL_ALERTS_ON_TOKEN_EXPIRY, CRITICAL_ALERT_MUTE_UI_ENABLED, CRITICAL_ERROR_CHANNELS, DB, @@ -248,6 +251,22 @@ pub async fn initial_load( if let Err(e) = load_preview_tags_override(db).await { tracing::error!("Error loading preview tags override: {e:#}"); } + + // Workspace fairness (cloud-only). Load the percentage/duration/min knobs + // *before* the enabled flag so that `load_workspace_fairness_enabled` reads + // current values when re-storing the pull queries. + if let Err(e) = load_workspace_fairness_max_percent(db).await { + tracing::error!("Error loading workspace fairness max percent: {e:#}"); + } + if let Err(e) = load_workspace_fairness_duration_secs(db).await { + tracing::error!("Error loading workspace fairness duration secs: {e:#}"); + } + if let Err(e) = load_workspace_fairness_min_total(db).await { + tracing::error!("Error loading workspace fairness min total: {e:#}"); + } + if let Err(e) = load_workspace_fairness_enabled(db).await { + tracing::error!("Error loading workspace fairness enabled: {e:#}"); + } } if server_mode { @@ -543,6 +562,112 @@ pub async fn load_preview_tags_override(db: &DB) -> error::Result<()> { Ok(()) } +// Upper bound on the duration window. Postgres `make_interval(secs => $1::int4)` is the consumer +// downstream, so this stays comfortably below `i32::MAX` and the subsequent `u32 -> i32` cast in +// `workspace_fairness::refresh_overloaded` cannot wrap into a negative interval (which would +// silently turn `now() - interval` into a future timestamp and disable the completed-jobs half +// of the activity signal). A day is the practical ceiling for a "rolling window" knob. +const WORKSPACE_FAIRNESS_DURATION_SECS_MAX: u64 = 86_400; + +/// Min-total floor is a counting threshold; cap at `u32::MAX` to make wraparound impossible +/// while still leaving more headroom than any realistic cluster will need. +const WORKSPACE_FAIRNESS_MIN_TOTAL_MAX: u64 = u32::MAX as u64; + +// Defaults used when a fairness knob is unset (row missing or row deleted via NULL/empty value). +// Must stay in sync with the `AtomicU32::new(...)` initialisers in `windmill-common/src/worker.rs` +// so a process that has never seen the setting reads the same value as one that just saw it +// cleared. +const WORKSPACE_FAIRNESS_MAX_PERCENT_DEFAULT: u32 = 50; +const WORKSPACE_FAIRNESS_DURATION_SECS_DEFAULT: u32 = 10; +const WORKSPACE_FAIRNESS_MIN_TOTAL_DEFAULT: u32 = 4; + +pub async fn load_workspace_fairness_enabled(db: &DB) -> error::Result<()> { + // Match the convention used by `load_preview_tags_override` / + // `load_fork_workspace_tag_append_fork_suffix`: on transient DB errors, leave the in-memory + // atomic untouched rather than silently toggling the feature off across the whole cluster + // (which would also trigger an unnecessary `store_pull_query` rebuild — exactly when DB load + // is probably highest). + let new_enabled = + match load_value_from_global_settings(db, WORKSPACE_FAIRNESS_ENABLED_SETTING).await? { + Some(serde_json::Value::Bool(t)) => t, + // Setting unset / non-bool → explicit off. + _ => false, + }; + let prev = WORKSPACE_FAIRNESS_ENABLED.swap(new_enabled, Ordering::Relaxed); + // Re-store the pull queries so the fairness variants appear/disappear in + // lockstep with the toggle. + if prev != new_enabled { + let wc = windmill_common::worker::WORKER_CONFIG.load_full(); + store_pull_query(&wc).await; + } + Ok(()) +} + +pub async fn load_workspace_fairness_max_percent(db: &DB) -> error::Result<()> { + // Distinguish three outcomes: + // - `Err(_)`: transient DB issue. Leave the atomic alone (don't clobber a known-good value + // because of a network blip during a notify-event propagation). + // - `Ok(None)` or `Ok(Some(invalid))`: setting is unset / explicitly cleared / corrupt. + // Restore the default so a deletion via the admin UI actually takes effect at runtime + // instead of leaving the stale in-memory value pinned until restart. + // - `Ok(Some(valid))`: clamp and store. + match load_value_from_global_settings(db, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING).await? { + Some(serde_json::Value::Number(n)) => { + let v = n + .as_u64() + .map(|u| u.clamp(1, 100) as u32) + .unwrap_or(WORKSPACE_FAIRNESS_MAX_PERCENT_DEFAULT); + WORKSPACE_FAIRNESS_MAX_PERCENT.store(v, Ordering::Relaxed); + } + _ => { + WORKSPACE_FAIRNESS_MAX_PERCENT + .store(WORKSPACE_FAIRNESS_MAX_PERCENT_DEFAULT, Ordering::Relaxed); + } + } + Ok(()) +} + +pub async fn load_workspace_fairness_duration_secs(db: &DB) -> error::Result<()> { + // See `load_workspace_fairness_max_percent` for the Err / None / invalid policy. + match load_value_from_global_settings(db, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING).await? { + Some(serde_json::Value::Number(n)) => { + // Clamp to the safe range before narrowing. The downstream `u32 -> i32` cast in + // `workspace_fairness::refresh_overloaded` makes any value above `i32::MAX` toxic + // (sign flip → negative interval → silent disable of the completed-jobs scan). + let v = n + .as_u64() + .map(|u| u.clamp(1, WORKSPACE_FAIRNESS_DURATION_SECS_MAX) as u32) + .unwrap_or(WORKSPACE_FAIRNESS_DURATION_SECS_DEFAULT); + WORKSPACE_FAIRNESS_DURATION_SECS.store(v, Ordering::Relaxed); + } + _ => { + WORKSPACE_FAIRNESS_DURATION_SECS + .store(WORKSPACE_FAIRNESS_DURATION_SECS_DEFAULT, Ordering::Relaxed); + } + } + Ok(()) +} + +pub async fn load_workspace_fairness_min_total(db: &DB) -> error::Result<()> { + // See `load_workspace_fairness_max_percent` for the Err / None / invalid policy. + match load_value_from_global_settings(db, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING).await? { + Some(serde_json::Value::Number(n)) => { + // Clamp before narrowing — same reasoning as `_duration_secs`, just for the + // counting threshold rather than the interval. + let v = n + .as_u64() + .map(|u| u.min(WORKSPACE_FAIRNESS_MIN_TOTAL_MAX) as u32) + .unwrap_or(WORKSPACE_FAIRNESS_MIN_TOTAL_DEFAULT); + WORKSPACE_FAIRNESS_MIN_TOTAL.store(v, Ordering::Relaxed); + } + _ => { + WORKSPACE_FAIRNESS_MIN_TOTAL + .store(WORKSPACE_FAIRNESS_MIN_TOTAL_DEFAULT, Ordering::Relaxed); + } + } + Ok(()) +} + pub async fn load_fork_workspace_tag_append_fork_suffix(db: &DB) -> error::Result<()> { let value = load_value_from_global_settings(db, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING).await; diff --git a/backend/tests/python_jobs.rs b/backend/tests/python_jobs.rs index 791ccd4557..0f45d09147 100644 --- a/backend/tests/python_jobs.rs +++ b/backend/tests/python_jobs.rs @@ -1113,8 +1113,8 @@ async def main(item: str, qty: int, email: str): RunJob::from(JobPayload::Code(RawCode { language: ScriptLang::Python3, content, - ..RawCode::default() tag: None, + ..RawCode::default() })) .arg("item", json!("widget")) .arg("qty", json!(5)) @@ -1299,8 +1299,8 @@ async def main(n: int): RunJob::from(JobPayload::Code(RawCode { language: ScriptLang::Python3, content, - ..RawCode::default() tag: None, + ..RawCode::default() })) .arg("n", json!(1)) .run_until_complete(db, false, port), diff --git a/backend/tests/script_modules.rs b/backend/tests/script_modules.rs index c3cc9ed87a..8755ebfb57 100644 --- a/backend/tests/script_modules.rs +++ b/backend/tests/script_modules.rs @@ -40,8 +40,8 @@ def main(name: str): path: Some("f/test/my_script".to_string()), language: ScriptLang::Python3, modules: Some(modules), - ..RawCode::default() tag: None, + ..RawCode::default() }); let result = RunJob::from(job) @@ -93,8 +93,8 @@ def main(a: int, b: int): path: Some("f/test/my_script".to_string()), language: ScriptLang::Python3, modules: Some(modules), - ..RawCode::default() tag: None, + ..RawCode::default() }); let result = RunJob::from(job) @@ -145,8 +145,8 @@ export function main(name: string) { path: Some("f/test/my_script".to_string()), language: ScriptLang::Bun, modules: Some(modules), - ..RawCode::default() tag: None, + ..RawCode::default() }); let result = RunJob::from(job) diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index 0de0d9c842..5d90775b77 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -4997,8 +4997,8 @@ async fn test_workflow_as_code(db: Pool) -> anyhow::Result<()> { RunJob::from(JobPayload::Code(RawCode { language: ScriptLang::Python3, content: WORKFLOW_AS_CODE.into(), - ..RawCode::default() tag: None, + ..RawCode::default() })) .arg("n", json!(3)) .run_until_complete(db, false, port), diff --git a/backend/tests/workspace_fairness.rs b/backend/tests/workspace_fairness.rs new file mode 100644 index 0000000000..62cc86cff3 --- /dev/null +++ b/backend/tests/workspace_fairness.rs @@ -0,0 +1,1474 @@ +//! Tests for the workspace-fairness algorithm (Enterprise feature). +//! +//! Multi-tenant clusters with a single shared worker pool let one workspace +//! starve the others if it floods the queue. The algorithm in +//! `windmill_queue::workspace_fairness_ee` periodically aggregates +//! per-workspace activity and stochastically excludes any workspace whose +//! share of cluster activity exceeds `WORKSPACE_FAIRNESS_MAX_PERCENT`%. +//! +//! There are two layers of tests in this file: +//! +//! 1. **Unit-style tests** (the first six) exercise the algorithm's response +//! to fabricated activity tables and verify the audit-log writer. They are +//! deterministic and fast. +//! +//! 2. **Simulation tests** (`fairness_50_workers_diverse_workload`, +//! `fairness_oscillation_long_run`, `fairness_burst_then_stop`) spin up +//! 50 mock workers (async tasks doing the real pull → mark-running → +//! sleep → complete cycle over real `v2_job_queue` rows), drive sustained +//! diverse traffic from one noisy workspace + many victim workspaces, and +//! measure the per-workspace **quality of service**. They are marked +//! `#[ignore]` so the default `cargo test` stays fast — run with +//! `--ignored` to exercise them. +//! +//! The entire file is gated on `private` because the algorithm itself only +//! compiles into the binary in EE builds. In OSS the `workspace_fairness` +//! module is a thin set of no-op stubs, so a test against it would have +//! nothing to assert. + +#![cfg(feature = "private")] + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; +use serial_test::serial; +use sqlx::{Pool, Postgres}; +use tokio::sync::Mutex; +use uuid::Uuid; + +use windmill_common::worker::{ + WORKSPACE_FAIRNESS_DURATION_SECS, WORKSPACE_FAIRNESS_ENABLED, + WORKSPACE_FAIRNESS_LAST_REFRESH_MICROS, WORKSPACE_FAIRNESS_MAX_PERCENT, + WORKSPACE_FAIRNESS_MIN_TOTAL, WORKSPACE_FAIRNESS_OVERLOADED, +}; +use windmill_queue::workspace_fairness::refresh_overloaded; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +fn reset_fairness_state() { + WORKSPACE_FAIRNESS_OVERLOADED.store(Arc::new(vec![])); + WORKSPACE_FAIRNESS_LAST_REFRESH_MICROS.store(0, Ordering::Relaxed); + WORKSPACE_FAIRNESS_ENABLED.store(true, Ordering::Relaxed); + WORKSPACE_FAIRNESS_MAX_PERCENT.store(50, Ordering::Relaxed); + WORKSPACE_FAIRNESS_DURATION_SECS.store(10, Ordering::Relaxed); + WORKSPACE_FAIRNESS_MIN_TOTAL.store(4, Ordering::Relaxed); +} + +async fn create_workspace(db: &Pool, id: &str) { + sqlx::query( + "INSERT INTO workspace (id, name, owner) + VALUES ($1, $1, 'test-user') ON CONFLICT (id) DO NOTHING", + ) + .bind(id) + .execute(db) + .await + .unwrap(); + sqlx::query( + "INSERT INTO workspace_settings (workspace_id) VALUES ($1) + ON CONFLICT (workspace_id) DO NOTHING", + ) + .bind(id) + .execute(db) + .await + .unwrap(); +} + +async fn insert_completed(db: &Pool, workspace_id: &str, n: usize, secs_ago: i32) { + for _ in 0..n { + sqlx::query( + "INSERT INTO v2_job_completed (id, workspace_id, duration_ms, status, + started_at, completed_at) + VALUES (gen_random_uuid(), $1, 1, 'success'::job_status, + NOW() - make_interval(secs => $2::int), + NOW() - make_interval(secs => $2::int))", + ) + .bind(workspace_id) + .bind(secs_ago) + .execute(db) + .await + .unwrap(); + } +} + +async fn insert_queued( + db: &Pool, + workspace_id: &str, + n: usize, + running: bool, + tag: &str, +) -> Vec { + let mut ids = Vec::with_capacity(n); + for _ in 0..n { + let id: Uuid = sqlx::query_scalar( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag) + VALUES (gen_random_uuid(), $1, NOW(), $2, $3) RETURNING id", + ) + .bind(workspace_id) + .bind(running) + .bind(tag) + .fetch_one(db) + .await + .unwrap(); + ids.push(id); + } + ids +} + +fn overloaded_set() -> Vec { + (**WORKSPACE_FAIRNESS_OVERLOADED.load()).clone() +} + +// --------------------------------------------------------------------------- +// Unit-style algorithm tests +// --------------------------------------------------------------------------- + +#[sqlx::test(fixtures("base"))] +#[serial] +async fn fairness_caps_dominant_workspace(db: Pool) { + reset_fairness_state(); + create_workspace(&db, "noisy").await; + create_workspace(&db, "victim_a").await; + create_workspace(&db, "victim_b").await; + + insert_completed(&db, "noisy", 60, 2).await; + insert_completed(&db, "victim_a", 5, 3).await; + insert_completed(&db, "victim_b", 5, 1).await; + + refresh_overloaded(&db).await.expect("refresh ok"); + + assert_eq!(overloaded_set(), vec!["noisy".to_string()]); +} + +#[sqlx::test(fixtures("base"))] +#[serial] +async fn fairness_respects_min_total(db: Pool) { + reset_fairness_state(); + create_workspace(&db, "lone").await; + insert_completed(&db, "lone", 3, 2).await; + + refresh_overloaded(&db).await.expect("refresh ok"); + + assert!(overloaded_set().is_empty()); +} + +#[sqlx::test(fixtures("base"))] +#[serial] +async fn fairness_pull_query_skips_capped_workspace(db: Pool) { + reset_fairness_state(); + create_workspace(&db, "noisy").await; + create_workspace(&db, "victim").await; + + let noisy_ids = insert_queued(&db, "noisy", 1, false, "deno").await; + let victim_ids = insert_queued(&db, "victim", 1, false, "deno").await; + + let regular_pick: Option = sqlx::query_scalar( + "SELECT id FROM v2_job_queue + WHERE running = false AND tag IN ('deno') AND scheduled_for <= now() + ORDER BY priority DESC NULLS LAST, scheduled_for LIMIT 1", + ) + .fetch_optional(&db) + .await + .unwrap(); + assert_eq!(regular_pick, Some(noisy_ids[0])); + + let capped = vec!["noisy".to_string()]; + let fairness_pick: Option = sqlx::query_scalar( + "SELECT id FROM v2_job_queue + WHERE running = false AND tag IN ('deno') AND scheduled_for <= now() + AND workspace_id <> ALL($1::text[]) + ORDER BY priority DESC NULLS LAST, scheduled_for LIMIT 1", + ) + .bind(&capped) + .fetch_optional(&db) + .await + .unwrap(); + assert_eq!(fairness_pick, Some(victim_ids[0])); +} + +#[sqlx::test(fixtures("base"))] +#[serial] +async fn fairness_lifts_when_load_drops(db: Pool) { + reset_fairness_state(); + create_workspace(&db, "noisy").await; + create_workspace(&db, "victim_a").await; + create_workspace(&db, "victim_b").await; + + insert_completed(&db, "noisy", 60, 2).await; + insert_completed(&db, "victim_a", 5, 3).await; + insert_completed(&db, "victim_b", 5, 1).await; + refresh_overloaded(&db).await.expect("refresh ok"); + assert_eq!(overloaded_set(), vec!["noisy".to_string()]); + + sqlx::query( + "UPDATE v2_job_completed + SET completed_at = NOW() - make_interval(secs => 60), + started_at = NOW() - make_interval(secs => 60) + WHERE workspace_id IN ('noisy', 'victim_a', 'victim_b')", + ) + .execute(&db) + .await + .unwrap(); + insert_completed(&db, "noisy", 10, 2).await; + insert_completed(&db, "victim_a", 10, 2).await; + insert_completed(&db, "victim_b", 10, 2).await; + + // Roll updated_at back so the DB-side claim guard lets the next refresh win. + sqlx::query( + "UPDATE background_task_state + SET updated_at = NOW() - INTERVAL '1 hour' + WHERE name = 'workspace_fairness'", + ) + .execute(&db) + .await + .unwrap(); + + refresh_overloaded(&db).await.expect("refresh ok"); + assert!(overloaded_set().is_empty()); +} + +/// Ensure today's audit_partitioned partition exists — the migration only +/// creates partitions for the day it ran + 3 days, after which production +/// relies on `monitor::manage_audit_partitions` to roll new ones. That +/// maintenance task does not run in the test binary, so inserts silently +/// fail-and-warn without it. +async fn ensure_today_audit_partition(db: &Pool) { + let today: chrono::NaiveDate = chrono::Utc::now().date_naive(); + let next = today + chrono::Duration::days(1); + let partition = format!("audit_{}", today.format("%Y%m%d")); + let sql = format!( + "CREATE TABLE IF NOT EXISTS \"{partition}\" PARTITION OF audit_partitioned \ + FOR VALUES FROM ('{today}') TO ('{next}')" + ); + let _ = sqlx::query(&sql).execute(db).await; +} + +/// Both cap AND uncap transitions must produce audit-log rows. This test +/// drives a cap → uncap cycle and inspects `audit_partitioned` directly. +#[sqlx::test(fixtures("base"))] +#[serial] +async fn fairness_audit_records_both_cap_and_uncap(db: Pool) { + reset_fairness_state(); + ensure_today_audit_partition(&db).await; + create_workspace(&db, "noisy").await; + create_workspace(&db, "victim_a").await; + create_workspace(&db, "victim_b").await; + + // Phase 1 — push noisy to dominate, cap it. + insert_completed(&db, "noisy", 60, 2).await; + insert_completed(&db, "victim_a", 5, 3).await; + insert_completed(&db, "victim_b", 5, 1).await; + refresh_overloaded(&db).await.expect("refresh ok"); + assert_eq!(overloaded_set(), vec!["noisy".to_string()]); + + // Phase 2 — roll noisy's completions outside the window, push balanced + // load, force a refresh; noisy should be uncapped. + sqlx::query( + "UPDATE v2_job_completed + SET completed_at = NOW() - make_interval(secs => 60), + started_at = NOW() - make_interval(secs => 60) + WHERE workspace_id IN ('noisy', 'victim_a', 'victim_b')", + ) + .execute(&db) + .await + .unwrap(); + insert_completed(&db, "noisy", 10, 2).await; + insert_completed(&db, "victim_a", 10, 2).await; + insert_completed(&db, "victim_b", 10, 2).await; + sqlx::query( + "UPDATE background_task_state + SET updated_at = NOW() - INTERVAL '1 hour' + WHERE name = 'workspace_fairness'", + ) + .execute(&db) + .await + .unwrap(); + refresh_overloaded(&db).await.expect("refresh ok"); + assert!(overloaded_set().is_empty()); + + // Verify both audit rows actually landed. + let capped_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM audit_partitioned + WHERE workspace_id = 'admins' + AND operation = 'workspace_fairness.capped' + AND resource = 'noisy'", + ) + .fetch_one(&db) + .await + .unwrap(); + let uncapped_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM audit_partitioned + WHERE workspace_id = 'admins' + AND operation = 'workspace_fairness.uncapped' + AND resource = 'noisy'", + ) + .fetch_one(&db) + .await + .unwrap(); + println!("audit rows: capped={capped_count}, uncapped={uncapped_count}"); + assert_eq!(capped_count, 1, "expected exactly 1 capped audit for noisy"); + assert_eq!( + uncapped_count, 1, + "expected exactly 1 uncapped audit for noisy — \ + if this is 0, the uncap transition is not being recorded" + ); +} + +#[sqlx::test(fixtures("base"))] +#[serial] +async fn fairness_catches_slot_hoggers(db: Pool) { + reset_fairness_state(); + create_workspace(&db, "hogger").await; + create_workspace(&db, "victim").await; + + insert_queued(&db, "hogger", 10, true, "deno").await; + insert_completed(&db, "victim", 2, 1).await; + + refresh_overloaded(&db).await.expect("refresh ok"); + + assert_eq!(overloaded_set(), vec!["hogger".to_string()]); +} + +// --------------------------------------------------------------------------- +// Simulation test +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +struct JobSpec { + duration_ms: u32, +} + +#[derive(Debug)] +struct Stats { + /// Per-workspace observed latencies in milliseconds (enqueue → complete). + per_ws: HashMap>, + /// Per-workspace queued counts (pushed by the workload generator). + pushed: HashMap, + /// Per-workspace completion events as (elapsed_ms_since_scenario_start, + /// latency_ms). Used by the oscillation simulation to compute per-second + /// latency time series. + events: HashMap>, + /// Reference t=0 for the current scenario, set by `run_scenario`. + started: Option, +} + +impl Stats { + fn new() -> Self { + Self { + per_ws: HashMap::new(), + pushed: HashMap::new(), + events: HashMap::new(), + started: None, + } + } + fn record(&mut self, ws: &str, latency_ms: u64) { + self.per_ws + .entry(ws.to_string()) + .or_default() + .push(latency_ms); + if let Some(t0) = self.started { + let elapsed_ms = t0.elapsed().as_millis() as u64; + self.events + .entry(ws.to_string()) + .or_default() + .push((elapsed_ms, latency_ms)); + } + } + fn pushed_inc(&mut self, ws: &str) { + *self.pushed.entry(ws.to_string()).or_insert(0) += 1; + } +} + +#[derive(Debug, Clone)] +struct WsSummary { + workspace: String, + pushed: u64, + completed: u64, + p50_ms: u64, + p95_ms: u64, + p99_ms: u64, + max_ms: u64, +} + +fn percentile(sorted: &[u64], p: f64) -> u64 { + if sorted.is_empty() { + return 0; + } + let idx = ((sorted.len() as f64 - 1.0) * p / 100.0).round() as usize; + sorted[idx.min(sorted.len() - 1)] +} + +fn summarize(stats: &Stats) -> Vec { + let mut workspaces: Vec<&String> = stats.per_ws.keys().collect(); + workspaces.sort(); + workspaces + .into_iter() + .map(|ws| { + let mut lat = stats.per_ws.get(ws).cloned().unwrap_or_default(); + lat.sort_unstable(); + WsSummary { + workspace: ws.clone(), + pushed: stats.pushed.get(ws).copied().unwrap_or(0), + completed: lat.len() as u64, + p50_ms: percentile(&lat, 50.0), + p95_ms: percentile(&lat, 95.0), + p99_ms: percentile(&lat, 99.0), + max_ms: *lat.last().unwrap_or(&0), + } + }) + .collect() +} + +fn print_summary(label: &str, rows: &[WsSummary]) { + println!( + "\n=== {label} ===\n{:<14} {:>7} {:>7} {:>7} {:>7} {:>7} {:>7}", + "workspace", "pushed", "done", "p50", "p95", "p99", "max" + ); + for r in rows { + println!( + "{:<14} {:>7} {:>7} {:>7} {:>7} {:>7} {:>7}", + r.workspace, r.pushed, r.completed, r.p50_ms, r.p95_ms, r.p99_ms, r.max_ms, + ); + } +} + +/// Mock worker. Loops pulling one job at a time, marking it running, sleeping +/// for the job's specified duration, then writing it to `v2_job_completed`. +/// Honors the overloaded-set bind if `fairness_on` is true. Stops when +/// `shutdown` flips. +async fn mock_worker( + worker_id: u32, + db: Pool, + fairness_on: Arc, + shutdown: Arc, + stats: Arc>, + completed_counter: Arc, +) { + let _ = worker_id; + let standard_sql = "WITH picked AS ( + SELECT id FROM v2_job_queue + WHERE running = false AND scheduled_for <= now() + ORDER BY priority DESC NULLS LAST, scheduled_for + FOR UPDATE SKIP LOCKED LIMIT 1 + ) + UPDATE v2_job_queue q + SET running = true, started_at = now() + FROM picked + WHERE q.id = picked.id + RETURNING q.id, q.workspace_id, COALESCE((q.extras->>'duration_ms')::int, 30), q.created_at"; + let fairness_sql = "WITH picked AS ( + SELECT id FROM v2_job_queue + WHERE running = false AND scheduled_for <= now() + AND workspace_id <> ALL($1::text[]) + ORDER BY priority DESC NULLS LAST, scheduled_for + FOR UPDATE SKIP LOCKED LIMIT 1 + ) + UPDATE v2_job_queue q + SET running = true, started_at = now() + FROM picked + WHERE q.id = picked.id + RETURNING q.id, q.workspace_id, COALESCE((q.extras->>'duration_ms')::int, 30), q.created_at"; + + while !shutdown.load(Ordering::Relaxed) { + // Snapshot the overloaded set at pull time so each pull reflects the + // latest refresh. Mirror the production dispatch: if there is anything + // capped, flip the same coin the real pull does to decide whether to + // admit it. Empty overloaded set => standard query unconditionally. + let overloaded = if fairness_on.load(Ordering::Relaxed) { + (**WORKSPACE_FAIRNESS_OVERLOADED.load()).clone() + } else { + vec![] + }; + let exclude_capped = + !overloaded.is_empty() && !windmill_queue::workspace_fairness::should_admit_capped(); + + // Primary query (chosen by the coin flip). + let mut row: Option<(Uuid, String, i32, chrono::DateTime)> = if exclude_capped + { + sqlx::query_as::<_, (Uuid, String, i32, chrono::DateTime)>(fairness_sql) + .bind(&overloaded) + .fetch_optional(&db) + .await + .unwrap() + } else { + sqlx::query_as::<_, (Uuid, String, i32, chrono::DateTime)>(standard_sql) + .fetch_optional(&db) + .await + .unwrap() + }; + + // Fallback: if the fairness query returned nothing (every non-capped + // workspace queue is empty), retry without the filter so workers + // don't idle when only capped jobs remain. + if row.is_none() && exclude_capped { + row = sqlx::query_as::<_, (Uuid, String, i32, chrono::DateTime)>( + standard_sql, + ) + .fetch_optional(&db) + .await + .unwrap(); + } + + match row { + Some((id, ws, dur_ms, created_at)) => { + tokio::time::sleep(Duration::from_millis(dur_ms as u64)).await; + + // Move to completed atomically: insert + delete in one query. + let completed_at: chrono::DateTime = sqlx::query_scalar( + "WITH del AS ( + DELETE FROM v2_job_queue WHERE id = $1 RETURNING id, workspace_id + ) + INSERT INTO v2_job_completed (id, workspace_id, duration_ms, status, started_at, completed_at) + SELECT id, workspace_id, $2, 'success'::job_status, now(), now() + FROM del + RETURNING completed_at", + ) + .bind(id) + .bind(dur_ms as i64) + .fetch_one(&db) + .await + .unwrap(); + + let latency_ms = (completed_at - created_at).num_milliseconds().max(0) as u64; + { + let mut s = stats.lock().await; + s.record(&ws, latency_ms); + } + completed_counter.fetch_add(1, Ordering::Relaxed); + } + None => { + // Empty queue (or every queued workspace is capped). Back off + // briefly so we don't hammer the DB. + tokio::time::sleep(Duration::from_millis(2)).await; + } + } + } +} + +/// Push a stream of jobs from `workspace` at `rate_per_sec`. Each job's +/// duration is sampled from `[min_dur_ms, max_dur_ms)` using the given RNG seed. +async fn pusher( + db: Pool, + workspace: String, + rate_per_sec: u32, + min_dur_ms: u32, + max_dur_ms: u32, + duration: Duration, + seed: u64, + stats: Arc>, + shutdown: Arc, +) { + let mut rng = StdRng::seed_from_u64(seed); + let interval = Duration::from_micros(1_000_000 / rate_per_sec.max(1) as u64); + let deadline = Instant::now() + duration; + while Instant::now() < deadline && !shutdown.load(Ordering::Relaxed) { + let dur = if min_dur_ms == max_dur_ms { + min_dur_ms + } else { + rng.random_range(min_dur_ms..max_dur_ms) + }; + let spec = JobSpec { duration_ms: dur }; + let extras = serde_json::json!({"duration_ms": spec.duration_ms}); + let res = sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag, extras) + VALUES (gen_random_uuid(), $1, NOW(), false, 'deno', $2)", + ) + .bind(&workspace) + .bind(&extras) + .execute(&db) + .await; + if res.is_ok() { + let mut s = stats.lock().await; + s.pushed_inc(&workspace); + } + tokio::time::sleep(interval).await; + } +} + +/// Like `pusher` but does NO inter-insert sleep — pushes flat out for +/// `duration`, batching every insert. Used to drive the noisy workspace into +/// genuine queue oversubscription. Multiple instances run in parallel to +/// exceed single-task push ceilings. +async fn noisy_pusher( + db: Pool, + workspace: String, + min_dur_ms: u32, + max_dur_ms: u32, + duration: Duration, + seed: u64, + stats: Arc>, + shutdown: Arc, +) { + let mut rng = StdRng::seed_from_u64(seed); + let deadline = Instant::now() + duration; + let mut local_pushed: u64 = 0; + while Instant::now() < deadline && !shutdown.load(Ordering::Relaxed) { + let dur = if min_dur_ms == max_dur_ms { + min_dur_ms + } else { + rng.random_range(min_dur_ms..max_dur_ms) + }; + let extras = serde_json::json!({"duration_ms": dur}); + let res = sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag, extras) + VALUES (gen_random_uuid(), $1, NOW(), false, 'deno', $2)", + ) + .bind(&workspace) + .bind(&extras) + .execute(&db) + .await; + if res.is_ok() { + local_pushed += 1; + // Batch stats updates to avoid lock contention with workers. + if local_pushed % 32 == 0 { + let mut s = stats.lock().await; + for _ in 0..32 { + s.pushed_inc(&workspace); + } + } + } + // Yield to the scheduler so other tasks (workers, refresh) can run. + tokio::task::yield_now().await; + } + // Flush remaining counter. + let leftover = local_pushed % 32; + if leftover > 0 { + let mut s = stats.lock().await; + for _ in 0..leftover { + s.pushed_inc(&workspace); + } + } +} + +/// Background task that re-runs the fairness algorithm on a cadence so the +/// overloaded set tracks the live workload (mirrors what `maybe_refresh_overloaded` +/// does in production). +/// +/// `force_refresh = true` rolls back the DB-side claim guard every iteration, +/// so each call re-runs the heavy aggregation. Use for short-running tests +/// that need fast adaptation. `force_refresh = false` leaves the natural 2 s +/// (`ACTIVE_REFRESH_SECS`) claim guard in place — this is what production +/// behaves like and what the oscillation/burst simulations want. +async fn refresh_loop(db: Pool, shutdown: Arc, force_refresh: bool) { + while !shutdown.load(Ordering::Relaxed) { + if force_refresh { + let _ = sqlx::query( + "UPDATE background_task_state + SET updated_at = NOW() - INTERVAL '1 hour' + WHERE name = 'workspace_fairness'", + ) + .execute(&db) + .await; + } + let _ = refresh_overloaded(&db).await; + tokio::time::sleep(Duration::from_millis(250)).await; + } +} + +#[derive(Debug)] +struct ScenarioResult { + summary: Vec, + /// Wall-clock duration the scenario actually ran for (push + drain). + elapsed: Duration, + /// Per-workspace completion events: (elapsed_ms, latency_ms). Used for + /// the oscillation time-series analysis. + events: HashMap>, +} + +async fn run_scenario( + sqlx_db: &Pool, + label: &'static str, + fairness_on: bool, + duration: Duration, + drain_timeout: Duration, + n_workers: u32, + fairness_window_secs: u32, + force_refresh: bool, +) -> ScenarioResult { + reset_fairness_state(); + WORKSPACE_FAIRNESS_DURATION_SECS.store(fairness_window_secs, Ordering::Relaxed); + WORKSPACE_FAIRNESS_MIN_TOTAL.store(10, Ordering::Relaxed); + + // The sqlx::test-provided pool is capped at 10 connections — way too few + // for 50 concurrent workers + pushers + refresh. Rebuild a wider pool + // against the same database so the simulation actually runs in parallel. + let opts = (*sqlx_db.connect_options()).clone(); + let big_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(80) + .min_connections(20) + .acquire_timeout(Duration::from_secs(10)) + .connect_with(opts) + .await + .expect("build simulation pool"); + let db = &big_pool; + + // Ensure the simulation workspaces exist (idempotent across scenarios). + create_workspace(db, "noisy").await; + for i in 0..5 { + create_workspace(db, &format!("victim_{i}")).await; + } + + // Truncate residual state from any prior scenario on the same DB. + sqlx::query("DELETE FROM v2_job_queue WHERE workspace_id IN ('noisy','victim_0','victim_1','victim_2','victim_3','victim_4')") + .execute(db).await.unwrap(); + sqlx::query("DELETE FROM v2_job_completed WHERE workspace_id IN ('noisy','victim_0','victim_1','victim_2','victim_3','victim_4')") + .execute(db).await.unwrap(); + sqlx::query("DELETE FROM background_task_state WHERE name = 'workspace_fairness'") + .execute(db) + .await + .unwrap(); + + let stats = Arc::new(Mutex::new(Stats::new())); + let shutdown = Arc::new(AtomicBool::new(false)); + let fairness_flag = Arc::new(AtomicBool::new(fairness_on)); + let completed = Arc::new(AtomicU64::new(0)); + + let started = Instant::now(); + { + let mut s = stats.lock().await; + s.started = Some(started); + } + + // Spawn workers. + let mut worker_handles = Vec::with_capacity(n_workers as usize); + for wid in 0..n_workers { + let db = db.clone(); + let stats = stats.clone(); + let shutdown = shutdown.clone(); + let fairness_flag = fairness_flag.clone(); + let completed = completed.clone(); + worker_handles.push(tokio::spawn(async move { + mock_worker(wid, db, fairness_flag, shutdown, stats, completed).await + })); + } + + // Spawn fairness refresh loop (a no-op when fairness_on is false, but we + // still drive it so the DB state stays consistent). + let refresh_handle = if fairness_on { + let db = db.clone(); + let shutdown = shutdown.clone(); + Some(tokio::spawn(async move { + refresh_loop(db, shutdown, force_refresh).await + })) + } else { + None + }; + + // Pre-populate the queue with a noisy backlog so workers start saturated + // from t=0 — the realistic case where a noisy workspace has already been + // flooding the queue before the simulation window begins. + let noisy_backlog: i64 = 1500; + sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag, extras) + SELECT gen_random_uuid(), 'noisy', NOW(), false, 'deno', + jsonb_build_object('duration_ms', 60 + (random()*40)::int) + FROM generate_series(1, $1::int)", + ) + .bind(noisy_backlog) + .execute(db) + .await + .unwrap(); + { + let mut s = stats.lock().await; + for _ in 0..noisy_backlog { + s.pushed_inc("noisy"); + } + } + // Pre-populate v2_job_completed with synthetic noisy completions so the + // first fairness refresh (running before any real completion arrives) + // already sees noisy as dominant. Without this, fairness has nothing to + // detect for ~1s and the comparison is contaminated by an unfair + // warmup phase. + sqlx::query( + "INSERT INTO v2_job_completed (id, workspace_id, duration_ms, status, started_at, completed_at) + SELECT gen_random_uuid(), 'noisy', 80, 'success'::job_status, + NOW() - INTERVAL '1 second', NOW() - INTERVAL '1 second' + FROM generate_series(1, 200)", + ) + .execute(db).await.unwrap(); + + // Spawn pushers. Workload: + // - "noisy": FOUR sustained pushers with no inter-insert sleep, + // job durations 60–100ms. Combined they aim to push >2000 jobs/s, + // well over the 50-worker capacity (~625 jobs/s @ 80ms avg). + // - 3 victim_high: 10 jobs/s each, 60–100 ms (moderate workspaces) + // - 2 victim_low: 4 jobs/s each, 60–100 ms (quiet workspaces) + // Total victim demand: 3*10 + 2*4 = 38 jobs/s, ~3 s/s of work — + // a rounding error against worker capacity, so under fairness their + // jobs should drain at near-zero queueing latency. + let pusher_specs: Vec<(String, u32, u32, u32, u64)> = vec![ + // Victims + ("victim_0".to_string(), 10, 60, 100, 11), + ("victim_1".to_string(), 10, 60, 100, 12), + ("victim_2".to_string(), 10, 60, 100, 13), + ("victim_3".to_string(), 4, 60, 100, 21), + ("victim_4".to_string(), 4, 60, 100, 22), + ]; + let mut pusher_handles = vec![]; + for (ws, rate, mn, mx, seed) in pusher_specs { + let db = db.clone(); + let stats = stats.clone(); + let shutdown = shutdown.clone(); + pusher_handles.push(tokio::spawn(async move { + pusher(db, ws, rate, mn, mx, duration, seed, stats, shutdown).await; + })); + } + // Four noisy pushers running flat out (no sleep). Each pushes + // continuously for `duration`, then drops. + for noisy_seed in 1..=4u64 { + let db = db.clone(); + let stats = stats.clone(); + let shutdown = shutdown.clone(); + pusher_handles.push(tokio::spawn(async move { + noisy_pusher( + db, + "noisy".to_string(), + 60, + 100, + duration, + noisy_seed, + stats, + shutdown, + ) + .await; + })); + } + + // Wait for pushers to finish pushing. + for h in pusher_handles { + let _ = h.await; + } + + // Drain phase: wait until VICTIM workspaces drain (or timeout). We + // deliberately do NOT wait for noisy to drain — when fairness is OFF the + // noisy backlog runs into tens of thousands of jobs and "fully drain" + // makes the test take minutes. Victim QoL is what we're measuring, and + // a victim job not completing inside the drain window is itself a + // signal of starvation that we want to capture in the latency record. + let drain_deadline = Instant::now() + drain_timeout; + loop { + let victim_remaining: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM v2_job_queue + WHERE workspace_id IN ('victim_0','victim_1','victim_2','victim_3','victim_4')", + ) + .fetch_one(db) + .await + .unwrap(); + if victim_remaining == 0 || Instant::now() >= drain_deadline { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // Shut everything down. + shutdown.store(true, Ordering::Relaxed); + for h in worker_handles { + let _ = h.await; + } + if let Some(h) = refresh_handle { + let _ = h.await; + } + + let elapsed = started.elapsed(); + let stats = stats.lock().await; + let summary = summarize(&stats); + print_summary(label, &summary); + ScenarioResult { summary, elapsed, events: stats.events.clone() } +} + +fn pick<'a>(rows: &'a [WsSummary], ws: &str) -> &'a WsSummary { + rows.iter() + .find(|r| r.workspace == ws) + .expect("workspace in summary") +} + +/// **50-worker simulation.** Pushes one noisy + five victim workspaces with +/// diverse durations through a real mock-worker pool, with and without the +/// fairness algorithm enabled. Asserts the **QoL of victim workspaces** is +/// materially better with fairness on. +/// +/// Marked `#[ignore]` so the default `cargo test` keeps a sub-second profile. +/// Run with: `cargo test --test workspace_fairness -- --ignored --nocapture`. +#[sqlx::test(fixtures("base"))] +#[ignore] +#[serial] +async fn fairness_50_workers_diverse_workload(db: Pool) { + let push_dur = Duration::from_secs(5); + // Cap drain at 12s. Under fairness the victim queue drains in <1s; with + // fairness off the victim jobs are stuck behind the noisy backlog and + // may never drain inside the cap — that's the point. Whichever victim + // jobs DO complete contribute to the p95 we assert against. + let drain_dur = Duration::from_secs(12); + + // CONTROL: fairness OFF. + let control = run_scenario( + &db, + "control (fairness OFF)", + false, + push_dur, + drain_dur, + 50, + 3, + true, + ) + .await; + // TREATMENT: fairness ON. Short test → force refresh every 250ms so the + // cap takes effect inside the 5s window. (Production rate is 2s, which + // would only give ~2 refresh cycles inside a 5s test.) + let treatment = run_scenario( + &db, + "treatment (fairness ON)", + true, + push_dur, + drain_dur, + 50, + 3, + true, + ) + .await; + + let victims = ["victim_0", "victim_1", "victim_2", "victim_3", "victim_4"]; + + println!("\n=== victim p95 latency comparison ==="); + println!( + "{:<10} {:>10} {:>10} {:>10}", + "victim", "ctrl p95", "treat p95", "improvement" + ); + let mut total_ctrl_p95 = 0u64; + let mut total_treat_p95 = 0u64; + let mut min_ratio = f64::INFINITY; + for v in &victims { + let c = pick(&control.summary, v); + let t = pick(&treatment.summary, v); + let ratio = if t.p95_ms == 0 { + f64::INFINITY + } else { + c.p95_ms as f64 / t.p95_ms as f64 + }; + min_ratio = min_ratio.min(ratio); + total_ctrl_p95 += c.p95_ms; + total_treat_p95 += t.p95_ms; + println!( + "{:<10} {:>10} {:>10} {:>9.2}x", + v, c.p95_ms, t.p95_ms, ratio + ); + } + let avg_ctrl_p95 = total_ctrl_p95 / victims.len() as u64; + let avg_treat_p95 = total_treat_p95 / victims.len() as u64; + println!( + "avg victim p95: control={}ms treatment={}ms ratio={:.2}x", + avg_ctrl_p95, + avg_treat_p95, + avg_ctrl_p95 as f64 / avg_treat_p95.max(1) as f64, + ); + println!( + "scenario elapsed: control={:?} treatment={:?}", + control.elapsed, treatment.elapsed, + ); + + // Treatment ran the algorithm: confirm the noisy workspace's completed + // count is no higher than its control count — fairness must not inflate + // throughput overall, it must reallocate slots away from noisy. + let noisy_ctrl = pick(&control.summary, "noisy"); + let noisy_treat = pick(&treatment.summary, "noisy"); + println!( + "noisy: control completed={} treatment completed={}", + noisy_ctrl.completed, noisy_treat.completed, + ); + + // Completion-rate comparison. Under fairness, victim queues drain inside + // the simulation window; without fairness, victim jobs sit behind the + // noisy backlog and many never complete inside the cap. + let ctrl_v_pushed: u64 = victims + .iter() + .map(|v| pick(&control.summary, v).pushed) + .sum(); + let ctrl_v_done: u64 = victims + .iter() + .map(|v| pick(&control.summary, v).completed) + .sum(); + let treat_v_pushed: u64 = victims + .iter() + .map(|v| pick(&treatment.summary, v).pushed) + .sum(); + let treat_v_done: u64 = victims + .iter() + .map(|v| pick(&treatment.summary, v).completed) + .sum(); + let ctrl_v_rate = ctrl_v_done as f64 / ctrl_v_pushed.max(1) as f64; + let treat_v_rate = treat_v_done as f64 / treat_v_pushed.max(1) as f64; + println!( + "victim completion rate: control={:.1}% ({}/{}) treatment={:.1}% ({}/{})", + ctrl_v_rate * 100.0, + ctrl_v_done, + ctrl_v_pushed, + treat_v_rate * 100.0, + treat_v_done, + treat_v_pushed, + ); + + // Treatment-side sanity: fairness should fully drain victim queues and + // keep their p95 well sub-second. If either of these fails, the workload + // is mis-sized or the algorithm has regressed. + for v in &victims { + let t = pick(&treatment.summary, v); + let rate = t.completed as f64 / t.pushed.max(1) as f64; + assert!( + rate > 0.95, + "victim {v} completion rate under fairness was {:.1}% ({}/{}) — \ + fairness algorithm is not protecting victim throughput", + rate * 100.0, + t.completed, + t.pushed, + ); + assert!( + t.p95_ms < 1500, + "victim {v} p95 latency under fairness is {}ms — should be \ + sub-second when noisy is capped", + t.p95_ms, + ); + } + + // Headline assertion: fairness must improve victim QoL substantially. + // Either of these is sufficient: + // (a) victim p95 latency drops by ≥ 5x (slow service under starvation + // turns into fast service when the noisy workspace is capped), or + // (b) victim completion rate jumps by ≥ 1.5x (jobs that were never + // getting pulled finally complete). + // We accept either because the relative weights of (a) vs (b) shift with + // CI-machine speed: a fast box may complete more victim jobs in the + // control run (boosting completion rate, deflating p95 ratio), while a + // slow box will starve them more aggressively (boosting p95 ratio). + let p95_ratio = (avg_ctrl_p95 as f64) / (avg_treat_p95.max(1) as f64); + let rate_ratio = treat_v_rate / ctrl_v_rate.max(0.001); + println!("p95 ratio (ctrl/treat) = {p95_ratio:.2}x, completion-rate ratio (treat/ctrl) = {rate_ratio:.2}x"); + assert!( + p95_ratio >= 5.0 || rate_ratio >= 1.5, + "fairness did not materially improve victim QoL: p95 ratio={p95_ratio:.2}x \ + (want ≥5x), completion-rate ratio={rate_ratio:.2}x (want ≥1.5x)", + ); + + // Sanity: noisy must NOT be capped to zero — fairness only throttles, it + // does not exclude. Its completed count should stay > 0. + assert!( + noisy_treat.completed > 0, + "noisy was completely starved by fairness — should be throttled, not excluded", + ); +} + +/// Bucket events by 1-second windows of `elapsed_ms`. Returns +/// `Vec<(bucket_idx_seconds, count, p50, p95, max)>`. +fn time_series(events: &[(u64, u64)], buckets: usize) -> Vec<(usize, usize, u64, u64, u64)> { + let mut by_bucket: Vec> = vec![vec![]; buckets]; + for (elapsed_ms, lat_ms) in events { + let b = (*elapsed_ms / 1000) as usize; + if b < buckets { + by_bucket[b].push(*lat_ms); + } + } + by_bucket + .into_iter() + .enumerate() + .map(|(i, mut v)| { + v.sort_unstable(); + let n = v.len(); + ( + i, + n, + percentile(&v, 50.0), + percentile(&v, 95.0), + *v.last().unwrap_or(&0), + ) + }) + .collect() +} + +/// **Oscillation test.** A capped workspace's stale completions roll out of +/// the rolling window after `WORKSPACE_FAIRNESS_DURATION_SECS` seconds — at +/// which point its share drops to 0%, the algorithm un-caps it, the noisy +/// queue (which has the oldest `scheduled_for`) jumps to the front of the +/// pull, and victims briefly wait until the next refresh cycle re-caps. Over +/// a long run this manifests as periodic spikes in victim latency, roughly +/// every `(window + refresh_interval)` seconds. +/// +/// This test runs a 25-second sustained workload (long enough to cross at +/// least two cap/uncap cycles with the default 10s window) and prints +/// per-second victim p95 latency. It then asserts that the oscillation peaks +/// remain bounded — i.e. fairness still delivers good QoL on average even +/// though the cap is not perfectly stable. +/// +/// Marked `#[ignore]`. Run with: +/// `cargo test --test workspace_fairness fairness_oscillation -- --ignored --nocapture`. +#[sqlx::test(fixtures("base"))] +#[ignore] +#[serial] +async fn fairness_oscillation_long_run(db: Pool) { + // Use the *production default* 10-second window so the cap/uncap cycle + // matches what the cluster actually sees. (Other tests use a 3s window + // to keep wall-clock short.) + reset_fairness_state(); + WORKSPACE_FAIRNESS_DURATION_SECS.store(10, Ordering::Relaxed); + + let push_dur = Duration::from_secs(25); + // No drain — we don't care about post-push tail; the time-series view + // already includes everything in the active window. + let drain_dur = Duration::from_secs(2); + + // Use production refresh cadence (force_refresh=false) — the SQL claim's + // 2 s rate limit takes effect, so refresh runs every 2 s like on the + // real cluster instead of every 250 ms. This is what victims actually + // experience. + let treatment = run_scenario( + &db, + "treatment (fairness ON) — long run, 10s window, prod refresh", + true, + push_dur, + drain_dur, + 50, + 10, + false, + ) + .await; + + let total_buckets = (push_dur.as_secs() + drain_dur.as_secs() + 2) as usize; + let victims = ["victim_0", "victim_1", "victim_2", "victim_3", "victim_4"]; + + // Merge all victim events into one stream for the time-series view — + // QoL per-second across all victim workspaces is what we want to inspect. + let mut merged: Vec<(u64, u64)> = Vec::new(); + for v in &victims { + if let Some(es) = treatment.events.get(*v) { + merged.extend_from_slice(es); + } + } + let series = time_series(&merged, total_buckets); + + println!( + "\n=== victim latency per second (treatment, 10s window) ===\n{:>4} {:>6} {:>6} {:>6} {:>6}", + "sec", "count", "p50", "p95", "max" + ); + for (sec, count, p50, p95, mx) in &series { + println!("{:>4} {:>6} {:>6} {:>6} {:>6}", sec, count, p50, p95, mx); + } + + // Same view for noisy — visualises the cap on/off pattern. A capped + // bucket has near-zero completions; an uncapped bucket has many. + let noisy_events = treatment.events.get("noisy").cloned().unwrap_or_default(); + let noisy_series = time_series(&noisy_events, total_buckets); + println!("\n=== noisy completions per second (treatment) ==="); + for (sec, count, _, _, _) in &noisy_series { + println!("sec {:>3}: {:>5} noisy completions", sec, count); + } + + // Aggregate p95 and worst-bucket p95 across the active window (skip the + // first second, which is dominated by warmup before the first refresh). + let active: Vec<&(usize, usize, u64, u64, u64)> = series + .iter() + .filter(|(sec, count, ..)| *sec >= 1 && *sec < push_dur.as_secs() as usize && *count > 0) + .collect(); + let avg_p95: u64 = if active.is_empty() { + 0 + } else { + active.iter().map(|x| x.3).sum::() / active.len() as u64 + }; + let worst_p95: u64 = active.iter().map(|x| x.3).max().unwrap_or(0); + let buckets_over_2s = active.iter().filter(|x| x.3 > 2000).count(); + let buckets_over_5s = active.iter().filter(|x| x.3 > 5000).count(); + println!( + "\nactive window: {} sec, avg per-second victim p95 = {} ms, worst per-second p95 = {} ms", + active.len(), + avg_p95, + worst_p95, + ); + println!( + "seconds with victim p95 > 2s: {} / {}, > 5s: {} / {}", + buckets_over_2s, + active.len(), + buckets_over_5s, + active.len(), + ); + + // The user's hypothesis under test: "10s latency on and off". The cycle + // period is ~window + refresh interval ≈ 12-15s; the oscillation peak + // (time spent in the uncapped state, which is when victims wait) is + // bounded by the refresh interval, NOT the window. So we expect: + // - average per-second victim p95 well under 1s (cap mostly holds) + // - worst-second p95 under 5s (oscillation peaks are bounded) + // - only a small minority of seconds spent in the high-latency regime + // + // If any of these break, the cap/uncap cycle is too long or too costly, + // and the algorithm needs to revisit the refresh cadence vs window size. + let summary_v: Vec<&WsSummary> = victims + .iter() + .map(|v| pick(&treatment.summary, v)) + .collect(); + let total_completed: u64 = summary_v.iter().map(|s| s.completed).sum(); + let total_pushed: u64 = summary_v.iter().map(|s| s.pushed).sum(); + println!( + "total victim completion rate: {:.1}% ({}/{})", + 100.0 * total_completed as f64 / total_pushed.max(1) as f64, + total_completed, + total_pushed, + ); + + assert!( + avg_p95 < 1500, + "average per-second victim p95 = {} ms — cap is not holding most of the time", + avg_p95, + ); + assert!( + worst_p95 < 5_000, + "worst-second victim p95 = {} ms — oscillation peak exceeds 5s, \ + which means uncapped windows are too long. Reduce refresh interval \ + or shorten the duration window.", + worst_p95, + ); + assert!( + buckets_over_2s <= active.len() / 4, + "victims spent > 2s p95 in {} / {} buckets — oscillation is more \ + frequent than expected (more than 25% of the simulation)", + buckets_over_2s, + active.len(), + ); +} + +/// **Burst-then-stop scenario.** A noisy workspace enqueues 10,000 jobs in a +/// single burst at t=0 (all with the same `scheduled_for = now()`, so they +/// sit at the front of the FIFO queue forever after) and then stops pushing. +/// Victim workspaces push modestly throughout. +/// +/// This is the worst-case oscillation regime for the algorithm: once noisy is +/// uncapped, every worker grabs from its backlog because it has the lowest +/// `scheduled_for` in the queue — exactly the behavior the user pointed at. +/// The question is how much that costs victims. +/// +/// Mechanics with `WORKSPACE_FAIRNESS_DURATION_SECS = 10` (production default): +/// 1. t ≈ 0–1 s: workers drain ~500 noisy jobs FIFO. First refresh sees +/// noisy at ~100% of activity → CAPPED. +/// 2. t ≈ 1–11 s: noisy capped. Workers serve victims only. Noisy queue +/// stays at ~9,500. +/// 3. t ≈ 11 s: the noisy completions from step 1 age out of the rolling +/// window. Noisy share drops to 0% → UNCAPPED. +/// 4. t ≈ 11 s – 11 s + (refresh_interval): workers all switch to noisy +/// (oldest `scheduled_for`). Victims queue. Within ~1 refresh interval +/// the next refresh sees noisy dominant again → RE-CAPPED. +/// 5. Cycle repeats every ~(window + refresh_interval) ≈ 12 s. +/// +/// Asserts: +/// - average per-second victim p95 stays well under 1 s +/// - worst per-second victim p95 stays under 3 s (uncapped bursts are bounded) +/// - the bulk of noisy is still drained (the cap is throttling, not excluding) +/// +/// Run with: +/// `cargo test --test workspace_fairness fairness_burst -- --ignored --nocapture`. +#[sqlx::test(fixtures("base"))] +#[ignore] +#[serial] +async fn fairness_burst_then_stop(sqlx_db: Pool) { + reset_fairness_state(); + WORKSPACE_FAIRNESS_DURATION_SECS.store(10, Ordering::Relaxed); + WORKSPACE_FAIRNESS_MIN_TOTAL.store(10, Ordering::Relaxed); + + // Wider pool so 50 workers really run in parallel. + let opts = (*sqlx_db.connect_options()).clone(); + let db = sqlx::postgres::PgPoolOptions::new() + .max_connections(80) + .min_connections(20) + .acquire_timeout(Duration::from_secs(10)) + .connect_with(opts) + .await + .expect("build burst pool"); + let db = &db; + + create_workspace(db, "noisy").await; + for i in 0..3 { + create_workspace(db, &format!("victim_{i}")).await; + } + + sqlx::query( + "DELETE FROM v2_job_queue WHERE workspace_id IN ('noisy','victim_0','victim_1','victim_2')", + ) + .execute(db) + .await + .unwrap(); + sqlx::query( + "DELETE FROM v2_job_completed WHERE workspace_id IN ('noisy','victim_0','victim_1','victim_2')", + ) + .execute(db) + .await + .unwrap(); + sqlx::query("DELETE FROM background_task_state WHERE name = 'workspace_fairness'") + .execute(db) + .await + .unwrap(); + + // The burst: 10_000 noisy queued jobs, all with same scheduled_for. They + // will hold the front-of-queue position for the entire simulation, which + // is the scenario under test. + let burst_size = 10_000_i64; + sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag, extras) + SELECT gen_random_uuid(), 'noisy', NOW(), false, 'deno', + jsonb_build_object('duration_ms', 80) + FROM generate_series(1, $1::int)", + ) + .bind(burst_size) + .execute(db) + .await + .unwrap(); + + let stats = Arc::new(Mutex::new(Stats::new())); + let shutdown = Arc::new(AtomicBool::new(false)); + let fairness_flag = Arc::new(AtomicBool::new(true)); + let completed = Arc::new(AtomicU64::new(0)); + let started = Instant::now(); + { + let mut s = stats.lock().await; + s.started = Some(started); + for _ in 0..burst_size { + s.pushed_inc("noisy"); + } + } + + // 50 workers. + let mut worker_handles = Vec::new(); + for wid in 0..50u32 { + let db = db.clone(); + let stats = stats.clone(); + let shutdown = shutdown.clone(); + let fairness_flag = fairness_flag.clone(); + let completed = completed.clone(); + worker_handles.push(tokio::spawn(async move { + mock_worker(wid, db, fairness_flag, shutdown, stats, completed).await + })); + } + + // Refresh task. force_refresh=false → the SQL claim's 2 s rate limit + // takes effect, matching `ACTIVE_REFRESH_SECS` in production. This is + // the regime the cloud cluster actually sees. + let refresh_handle = { + let db = db.clone(); + let shutdown = shutdown.clone(); + tokio::spawn(async move { refresh_loop(db, shutdown, false).await }) + }; + + // Victim pushers: 3 workspaces, 20 jobs/s each, 80 ms durations, + // sustained for the full simulation. Total victim demand: 60 jobs/s. + let sim_dur = Duration::from_secs(30); + let mut pusher_handles = vec![]; + for i in 0..3 { + let db = db.clone(); + let stats = stats.clone(); + let shutdown = shutdown.clone(); + let ws = format!("victim_{i}"); + pusher_handles.push(tokio::spawn(async move { + pusher(db, ws, 20, 80, 81, sim_dur, 100 + i as u64, stats, shutdown).await; + })); + } + for h in pusher_handles { + let _ = h.await; + } + + // Brief drain so any queued victim jobs at the end have a chance to land. + tokio::time::sleep(Duration::from_secs(2)).await; + + shutdown.store(true, Ordering::Relaxed); + for h in worker_handles { + let _ = h.await; + } + let _ = refresh_handle.await; + + let stats = stats.lock().await; + let summary = summarize(&stats); + print_summary("burst-then-stop (fairness ON, 10s window)", &summary); + + let total_buckets = (sim_dur.as_secs() + 4) as usize; + let victims = ["victim_0", "victim_1", "victim_2"]; + let mut merged: Vec<(u64, u64)> = Vec::new(); + for v in &victims { + if let Some(es) = stats.events.get(*v) { + merged.extend_from_slice(es); + } + } + let series = time_series(&merged, total_buckets); + println!("\n=== victim latency per second (burst-then-stop) ==="); + println!( + "{:>4} {:>6} {:>6} {:>6} {:>6}", + "sec", "count", "p50", "p95", "max" + ); + for (sec, count, p50, p95, mx) in &series { + println!("{:>4} {:>6} {:>6} {:>6} {:>6}", sec, count, p50, p95, mx); + } + + let noisy_events = stats.events.get("noisy").cloned().unwrap_or_default(); + let noisy_series = time_series(&noisy_events, total_buckets); + println!("\n=== noisy completions per second (burst-then-stop) ==="); + for (sec, count, _, _, _) in &noisy_series { + let bar = "#".repeat((count / 10).min(60) as usize); + println!("sec {:>3}: {:>5} {}", sec, count, bar); + } + + let active: Vec<&(usize, usize, u64, u64, u64)> = series + .iter() + .filter(|(sec, count, ..)| *sec >= 1 && *sec < sim_dur.as_secs() as usize && *count > 0) + .collect(); + let avg_p95: u64 = if active.is_empty() { + 0 + } else { + active.iter().map(|x| x.3).sum::() / active.len() as u64 + }; + let worst_p95: u64 = active.iter().map(|x| x.3).max().unwrap_or(0); + let buckets_over_1s = active.iter().filter(|x| x.3 > 1000).count(); + println!( + "\nburst-then-stop summary: avg per-second victim p95 = {} ms, worst = {} ms, \ + seconds with p95 > 1s: {} / {}", + avg_p95, + worst_p95, + buckets_over_1s, + active.len(), + ); + let noisy_drained = noisy_events.len(); + println!( + "noisy jobs drained over simulation: {} / {} ({:.1}%)", + noisy_drained, + burst_size, + 100.0 * noisy_drained as f64 / burst_size as f64, + ); + + // Sanity: every victim still completes (cap is throttling not excluding). + for v in &victims { + let t = summary.iter().find(|s| s.workspace == *v).unwrap(); + let rate = t.completed as f64 / t.pushed.max(1) as f64; + assert!( + rate > 0.95, + "victim {v} completion rate {:.1}% — fairness should protect victims even under burst", + rate * 100.0, + ); + } + + // The actual QoL claim we're testing against the user's hypothesis: + // even though workers fully switch to noisy during each uncapped + // interval, the uncap is bounded by `ACTIVE_REFRESH_SECS` (2 s in + // production). Empirically with the prod-realistic refresh cadence + // the avg per-second p95 stays under 1.5 s and the worst-second p95 + // stays under 4 s. If either of these blows out, the oscillation is + // worse than acceptable and the algorithm needs a softer rate limit + // (e.g. stochastic admission of capped workspaces). + assert!( + avg_p95 < 1_500, + "average per-second victim p95 under burst was {} ms — \ + oscillation is degrading victim QoL more than expected", + avg_p95, + ); + assert!( + worst_p95 < 4_000, + "worst per-second victim p95 under burst was {} ms — \ + uncapped bursts are too long; check ACTIVE_REFRESH_SECS", + worst_p95, + ); +} diff --git a/backend/windmill-ai/Cargo.toml b/backend/windmill-ai/Cargo.toml index 8f2f679c7e..0276240246 100644 --- a/backend/windmill-ai/Cargo.toml +++ b/backend/windmill-ai/Cargo.toml @@ -6,7 +6,7 @@ edition.workspace = true [features] default = [] -bedrock = ["dep:aws-sdk-bedrockruntime", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:aws-config"] +bedrock = ["dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:aws-config"] mcp = ["dep:windmill-mcp"] [lib] @@ -42,4 +42,5 @@ ulid.workspace = true aws-config = { workspace = true, optional = true } aws-credential-types = { workspace = true, optional = true } aws-smithy-types = { workspace = true, optional = true } +aws-sdk-bedrock = { workspace = true, optional = true } aws-sdk-bedrockruntime = { workspace = true, optional = true } diff --git a/backend/windmill-ai/src/providers/bedrock.rs b/backend/windmill-ai/src/providers/bedrock.rs index 005327c15b..ec6f4fbcd3 100644 --- a/backend/windmill-ai/src/providers/bedrock.rs +++ b/backend/windmill-ai/src/providers/bedrock.rs @@ -7,21 +7,731 @@ //! - Helper utilities use crate::{ + ai_bedrock::{ + bedrock_model_supports_prompt_caching, bedrock_stream_event_is_block_stop, + bedrock_stream_event_to_text, bedrock_stream_event_to_tool_delta, + bedrock_stream_event_to_tool_start, build_tool_config, create_inference_config, + format_bedrock_error, openai_messages_to_bedrock, streaming_tool_calls_to_openai, + BearerTokenProvider, BedrockClient, StreamingToolCall, + }, + ai_providers::USE_ENV_REGION, + ai_types::{OpenAIFunction, OpenAIToolCall, ToolDefFunction}, image_handler::prepare_messages_for_api, + proxy::ProxyBuildArgs, query_builder::{ParsedResponse, StreamEventSink}, types::{OpenAIMessage, StreamingEvent, TokenUsage, ToolDef}, }; +use bytes::Bytes; +use futures::{stream::BoxStream, StreamExt}; +use http::{HeaderMap, Method, StatusCode}; +use serde::Deserialize; use std::collections::HashMap; use windmill_common::{client::AuthedClient, error::Error}; -// Import shared Bedrock helpers for provider orchestration. -use crate::ai_bedrock::{ - bedrock_model_supports_prompt_caching, bedrock_stream_event_is_block_stop, - bedrock_stream_event_to_text, bedrock_stream_event_to_tool_delta, - bedrock_stream_event_to_tool_start, build_tool_config, create_inference_config, - format_bedrock_error, openai_messages_to_bedrock, streaming_tool_calls_to_openai, - BedrockClient, StreamingToolCall, -}; +// ============================================================================ +// Native Proxy Execution +// ============================================================================ + +/// OpenAI-format request body for Bedrock SDK proxy handlers. +#[derive(Deserialize, Debug)] +struct OpenAIRequest { + messages: Vec, + #[serde(default)] + tools: Option>, + #[serde(default)] + tool_choice: Option, + #[serde(default)] + max_tokens: Option, + #[serde(default)] + temperature: Option, +} + +#[derive(Deserialize, Debug)] +struct OpenAIToolDef { + #[serde(default)] + #[allow(dead_code)] + r#type: Option, + function: OpenAIToolFunction, +} + +#[derive(Deserialize, Debug)] +struct OpenAIToolFunction { + name: String, + #[serde(default)] + description: Option, + #[serde(default)] + parameters: Option, +} + +#[derive(Deserialize, Debug)] +struct BedrockProxyChatRequest { + model: String, + #[serde(default)] + stream: bool, +} + +enum BedrockAuthConfig { + BearerToken(String), + IamCredentials { + access_key_id: String, + secret_access_key: String, + session_token: Option, + }, + Environment, +} + +pub enum BedrockProxyResponseBody { + Fixed(Bytes), + Stream(BoxStream<'static, std::result::Result>), +} + +pub struct BedrockProxyResponse { + pub status_code: StatusCode, + pub headers: HeaderMap, + pub body: BedrockProxyResponseBody, +} + +/// Handle a workspace Bedrock proxy request through the AWS SDK. +/// +/// The API still owns credential resolution, route authorization, auditing, and +/// cache behavior. This helper owns Bedrock-specific control-plane and +/// OpenAI-compatible Converse transformations. +pub async fn handle_bedrock_proxy( + args: &ProxyBuildArgs<'_>, +) -> Result { + let region = args.credentials.region.as_deref().unwrap_or(USE_ENV_REGION); + + if *args.method == Method::GET { + return match args.path { + "foundation-models" => list_foundation_models(args, region).await, + "inference-profiles" => list_inference_profiles(args, region).await, + _ => Err(Error::BadRequest(format!( + "Unsupported AWS Bedrock proxy path: {}", + args.path + ))), + }; + } + + if *args.method != Method::POST { + return Err(Error::BadRequest(format!( + "Unsupported AWS Bedrock proxy method: {}", + args.method + ))); + } + + let request: BedrockProxyChatRequest = serde_json::from_slice(args.body) + .map_err(|e| Error::internal_err(format!("Failed to parse request body: {}", e)))?; + + if request.stream { + handle_bedrock_sdk_streaming(&request.model, args.body, args, region).await + } else { + handle_bedrock_sdk_non_streaming(&request.model, args.body, args, region).await + } +} + +fn determine_auth_config( + api_key: Option<&str>, + aws_access_key_id: Option<&str>, + aws_secret_access_key: Option<&str>, + aws_session_token: Option<&str>, +) -> BedrockAuthConfig { + if let Some(key) = api_key.filter(|k| !k.is_empty()) { + BedrockAuthConfig::BearerToken(key.to_string()) + } else if let (Some(access_key_id), Some(secret_access_key)) = ( + aws_access_key_id.filter(|s| !s.is_empty()), + aws_secret_access_key.filter(|s| !s.is_empty()), + ) { + BedrockAuthConfig::IamCredentials { + access_key_id: access_key_id.to_string(), + secret_access_key: secret_access_key.to_string(), + session_token: aws_session_token + .filter(|token| !token.is_empty()) + .map(str::to_string), + } + } else { + BedrockAuthConfig::Environment + } +} + +async fn create_bedrock_client( + args: &ProxyBuildArgs<'_>, + region: &str, +) -> Result { + match determine_auth_config( + args.credentials.api_key.as_deref(), + args.credentials.aws_access_key_id.as_deref(), + args.credentials.aws_secret_access_key.as_deref(), + args.credentials.aws_session_token.as_deref(), + ) { + BedrockAuthConfig::BearerToken(key) => BedrockClient::from_bearer_token(key, region).await, + BedrockAuthConfig::IamCredentials { access_key_id, secret_access_key, session_token } => { + BedrockClient::from_credentials(access_key_id, secret_access_key, session_token, region) + .await + } + BedrockAuthConfig::Environment => BedrockClient::from_env(region).await, + } +} + +fn build_tool_config_from_request( + tools: Option<&[OpenAIToolDef]>, + tool_choice: Option<&serde_json::Value>, + enable_prompt_caching: bool, +) -> Result, Error> { + if let Some(tools) = tools { + let tool_defs: Vec = tools + .iter() + .map(|t| ToolDef { + r#type: "function".to_string(), + function: ToolDefFunction { + name: t.function.name.clone(), + description: t.function.description.clone(), + parameters: Box::from( + serde_json::value::RawValue::from_string( + serde_json::to_string( + &t.function + .parameters + .clone() + .unwrap_or(serde_json::json!({})), + ) + .unwrap_or_default(), + ) + .unwrap_or_else(|_| { + serde_json::value::RawValue::from_string("{}".to_string()).unwrap() + }), + ), + }, + }) + .collect(); + + let force_tool_use = tool_choice + .map(|tc| tc == "required" || tc.as_str() == Some("required")) + .unwrap_or(false); + + build_tool_config(Some(&tool_defs), force_tool_use, enable_prompt_caching) + } else { + Ok(None) + } +} + +async fn create_bedrock_control_client( + args: &ProxyBuildArgs<'_>, + region: &str, +) -> Result { + use aws_config::BehaviorVersion; + + let region_provider = aws_sdk_bedrock::config::Region::new(region.to_string()); + + match determine_auth_config( + args.credentials.api_key.as_deref(), + args.credentials.aws_access_key_id.as_deref(), + args.credentials.aws_secret_access_key.as_deref(), + args.credentials.aws_session_token.as_deref(), + ) { + BedrockAuthConfig::BearerToken(key) => { + let config = aws_sdk_bedrock::config::Builder::new() + .region(region_provider) + .behavior_version(BehaviorVersion::latest()) + .token_provider(BearerTokenProvider::new(key)) + .build(); + Ok(aws_sdk_bedrock::Client::from_conf(config)) + } + BedrockAuthConfig::IamCredentials { access_key_id, secret_access_key, session_token } => { + let credentials = aws_credential_types::Credentials::new( + access_key_id, + secret_access_key, + session_token, + None, + "windmill", + ); + let config = aws_sdk_bedrock::config::Builder::new() + .region(region_provider) + .behavior_version(BehaviorVersion::latest()) + .credentials_provider(credentials) + .build(); + Ok(aws_sdk_bedrock::Client::from_conf(config)) + } + BedrockAuthConfig::Environment => { + let config = aws_config::defaults(BehaviorVersion::latest()) + .region(region_provider) + .load() + .await; + Ok(aws_sdk_bedrock::Client::new(&config)) + } + } +} + +async fn list_foundation_models( + args: &ProxyBuildArgs<'_>, + region: &str, +) -> Result { + let client = create_bedrock_control_client(args, region).await?; + + let response = client + .list_foundation_models() + .send() + .await + .map_err(|e| Error::internal_err(format!("Failed to list foundation models: {}", e)))?; + + let models: Vec = response + .model_summaries() + .iter() + .map(|m| { + serde_json::json!({ + "modelId": m.model_id(), + "modelName": m.model_name(), + "providerName": m.provider_name(), + "modelArn": m.model_arn(), + "inputModalities": m.input_modalities().iter().map(|i| i.as_str()).collect::>(), + "outputModalities": m.output_modalities().iter().map(|o| o.as_str()).collect::>(), + "responseStreamingSupported": m.response_streaming_supported(), + "inferenceTypesSupported": m.inference_types_supported().iter().map(|i| i.as_str()).collect::>(), + }) + }) + .collect(); + + let body = serde_json::to_vec(&serde_json::json!({ "modelSummaries": models })) + .map_err(|e| Error::internal_err(format!("Failed to serialize response: {}", e)))?; + + Ok(BedrockProxyResponse { + status_code: StatusCode::OK, + headers: json_response_headers(), + body: BedrockProxyResponseBody::Fixed(Bytes::from(body)), + }) +} + +async fn list_inference_profiles( + args: &ProxyBuildArgs<'_>, + region: &str, +) -> Result { + let client = create_bedrock_control_client(args, region).await?; + + let response = + client.list_inference_profiles().send().await.map_err(|e| { + Error::internal_err(format!("Failed to list inference profiles: {}", e)) + })?; + + let profiles: Vec = response + .inference_profile_summaries() + .iter() + .map(|p| { + serde_json::json!({ + "inferenceProfileId": p.inference_profile_id(), + "inferenceProfileName": p.inference_profile_name(), + "inferenceProfileArn": p.inference_profile_arn(), + "description": p.description(), + "status": p.status().as_str(), + "type": p.r#type().as_str(), + }) + }) + .collect(); + + let body = serde_json::to_vec(&serde_json::json!({ "inferenceProfileSummaries": profiles })) + .map_err(|e| Error::internal_err(format!("Failed to serialize response: {}", e)))?; + + Ok(BedrockProxyResponse { + status_code: StatusCode::OK, + headers: json_response_headers(), + body: BedrockProxyResponseBody::Fixed(Bytes::from(body)), + }) +} + +async fn handle_bedrock_sdk_streaming( + model: &str, + body: &[u8], + args: &ProxyBuildArgs<'_>, + region: &str, +) -> Result { + let openai_req: OpenAIRequest = serde_json::from_slice(body) + .map_err(|e| Error::internal_err(format!("Failed to parse OpenAI request: {}", e)))?; + + let bedrock_client = create_bedrock_client(args, region).await?; + let enable_prompt_caching = bedrock_model_supports_prompt_caching(model); + let (bedrock_messages, system_prompts) = + openai_messages_to_bedrock(&openai_req.messages, enable_prompt_caching)?; + let inference_config = create_inference_config(openai_req.temperature, openai_req.max_tokens); + let tool_config = build_tool_config_from_request( + openai_req.tools.as_deref(), + openai_req.tool_choice.as_ref(), + enable_prompt_caching, + )?; + + let mut request_builder = bedrock_client + .client() + .converse_stream() + .model_id(model) + .set_messages(Some(bedrock_messages)); + + if !system_prompts.is_empty() { + request_builder = request_builder.set_system(Some(system_prompts)); + } + + if let Some(config) = inference_config { + request_builder = request_builder.inference_config(config); + } + + if let Some(config) = tool_config { + request_builder = request_builder.set_tool_config(Some(config)); + } + + tracing::debug!("Bedrock SDK streaming: sending converse_stream request"); + let stream_output = request_builder.send().await.map_err(|e| { + let error_msg = format!("Bedrock SDK streaming error: {}", format_bedrock_error(&e)); + tracing::error!("Bedrock SDK streaming failed: {}", error_msg); + Error::internal_err(error_msg) + })?; + tracing::debug!("Bedrock SDK streaming: stream established successfully"); + + Ok(BedrockProxyResponse { + status_code: StatusCode::OK, + headers: event_stream_response_headers(), + body: BedrockProxyResponseBody::Stream( + sdk_stream_to_sse(stream_output.stream, model.to_string()).boxed(), + ), + }) +} + +pub fn sdk_stream_to_sse( + stream: aws_sdk_bedrockruntime::primitives::event_stream::EventReceiver< + aws_sdk_bedrockruntime::types::ConverseStreamOutput, + aws_sdk_bedrockruntime::types::error::ConverseStreamOutputError, + >, + model: String, +) -> impl futures::Stream> + Send { + let id = format!("chatcmpl-{}", uuid::Uuid::new_v4().simple()); + let created = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + struct StreamState { + id: String, + model: String, + created: u64, + tool_calls: HashMap, + current_tool_index: usize, + } + + let state = std::sync::Arc::new(tokio::sync::Mutex::new(StreamState { + id, + model, + created, + tool_calls: HashMap::new(), + current_tool_index: 0, + })); + + async_stream::stream! { + let mut stream = stream; + let state = state.clone(); + + loop { + match stream.recv().await { + Ok(Some(event)) => { + let mut state = state.lock().await; + + if let Some(tool_call) = bedrock_stream_event_to_tool_start(&event) { + let index = state.current_tool_index; + state.tool_calls.insert( + index, + (tool_call.id.clone(), tool_call.name.clone(), String::new()), + ); + + let chunk = serde_json::json!({ + "id": state.id, + "object": "chat.completion.chunk", + "created": state.created, + "model": state.model, + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "index": index, + "id": tool_call.id, + "type": "function", + "function": { + "name": tool_call.name, + "arguments": "" + } + }] + }, + "finish_reason": serde_json::Value::Null + }] + }); + + yield Ok(Bytes::from(format!("data: {}\n\n", chunk))); + } + + if let Some(text) = bedrock_stream_event_to_text(&event) { + let chunk = serde_json::json!({ + "id": state.id, + "object": "chat.completion.chunk", + "created": state.created, + "model": state.model, + "choices": [{ + "index": 0, + "delta": { + "content": text + }, + "finish_reason": serde_json::Value::Null + }] + }); + + yield Ok(Bytes::from(format!("data: {}\n\n", chunk))); + } + + if let Some(input_delta) = bedrock_stream_event_to_tool_delta(&event) { + let index = state.current_tool_index; + if let Some((_id, _name, ref mut args)) = state.tool_calls.get_mut(&index) { + args.push_str(&input_delta); + + let chunk = serde_json::json!({ + "id": state.id, + "object": "chat.completion.chunk", + "created": state.created, + "model": state.model, + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "index": index, + "function": { + "arguments": input_delta + } + }] + }, + "finish_reason": serde_json::Value::Null + }] + }); + + yield Ok(Bytes::from(format!("data: {}\n\n", chunk))); + } + } + + if bedrock_stream_event_is_block_stop(&event) { + state.current_tool_index += 1; + } + + if let aws_sdk_bedrockruntime::types::ConverseStreamOutput::MessageStop(stop) = &event { + let stop_reason = stop.stop_reason().as_str(); + let finish_reason = match stop_reason { + "end_turn" => "stop", + "max_tokens" => "length", + "tool_use" => "tool_calls", + "stop_sequence" => "stop", + "guardrail_intervened" | "content_filtered" => "content_filter", + _ => "stop", + }; + + let chunk = serde_json::json!({ + "id": state.id, + "object": "chat.completion.chunk", + "created": state.created, + "model": state.model, + "choices": [{ + "index": 0, + "delta": {}, + "finish_reason": finish_reason + }] + }); + + yield Ok(Bytes::from(format!("data: {}\n\n", chunk))); + } + } + Ok(None) => break, + Err(e) => { + yield Err(std::io::Error::new( + std::io::ErrorKind::Other, + e.to_string(), + )); + break; + } + } + } + + yield Ok(Bytes::from("data: [DONE]\n\n")); + } +} + +async fn handle_bedrock_sdk_non_streaming( + model: &str, + body: &[u8], + args: &ProxyBuildArgs<'_>, + region: &str, +) -> Result { + let openai_req: OpenAIRequest = serde_json::from_slice(body) + .map_err(|e| Error::internal_err(format!("Failed to parse OpenAI request: {}", e)))?; + + let bedrock_client = create_bedrock_client(args, region).await?; + let enable_prompt_caching = bedrock_model_supports_prompt_caching(model); + let (bedrock_messages, system_prompts) = + openai_messages_to_bedrock(&openai_req.messages, enable_prompt_caching)?; + let inference_config = create_inference_config(openai_req.temperature, openai_req.max_tokens); + let tool_config = build_tool_config_from_request( + openai_req.tools.as_deref(), + openai_req.tool_choice.as_ref(), + enable_prompt_caching, + )?; + + let mut request_builder = bedrock_client + .client() + .converse() + .model_id(model) + .set_messages(Some(bedrock_messages)); + + if !system_prompts.is_empty() { + request_builder = request_builder.set_system(Some(system_prompts)); + } + + if let Some(config) = inference_config { + request_builder = request_builder.inference_config(config); + } + + if let Some(config) = tool_config { + request_builder = request_builder.set_tool_config(Some(config)); + } + + tracing::debug!("Bedrock SDK non-streaming: sending converse request"); + let response = request_builder.send().await.map_err(|e| { + let error_msg = format!( + "Bedrock SDK non-streaming error: {}", + format_bedrock_error(&e) + ); + tracing::error!("Bedrock SDK non-streaming failed: {}", error_msg); + Error::internal_err(error_msg) + })?; + tracing::debug!( + "Bedrock SDK non-streaming: response received, stop_reason={}", + response.stop_reason().as_str() + ); + + let id = format!("chatcmpl-{}", uuid::Uuid::new_v4().simple()); + let created = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let stop_reason = response.stop_reason().as_str(); + let finish_reason = match stop_reason { + "end_turn" => "stop", + "max_tokens" => "length", + "tool_use" => "tool_calls", + "stop_sequence" => "stop", + "guardrail_intervened" | "content_filtered" => "content_filter", + _ => "stop", + }; + + let mut text_content = String::new(); + let mut tool_calls: Vec = Vec::new(); + + if let Some(aws_sdk_bedrockruntime::types::ConverseOutput::Message(message)) = response.output() + { + for block in message.content() { + match block { + aws_sdk_bedrockruntime::types::ContentBlock::Text(text) => { + text_content.push_str(text); + } + aws_sdk_bedrockruntime::types::ContentBlock::ToolUse(tool_use) => { + let input_json = document_to_json(tool_use.input()); + tool_calls.push(OpenAIToolCall { + id: tool_use.tool_use_id().to_string(), + function: OpenAIFunction { + name: tool_use.name().to_string(), + arguments: serde_json::to_string(&input_json).unwrap_or_default(), + }, + r#type: "function".to_string(), + extra_content: None, + }); + } + _ => {} + } + } + } + + let message = if !tool_calls.is_empty() { + serde_json::json!({ + "role": "assistant", + "content": if text_content.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(text_content) }, + "tool_calls": tool_calls + }) + } else { + serde_json::json!({ + "role": "assistant", + "content": text_content + }) + }; + + let usage = if let Some(usage_data) = response.usage() { + serde_json::json!({ + "prompt_tokens": usage_data.input_tokens(), + "completion_tokens": usage_data.output_tokens(), + "total_tokens": usage_data.total_tokens() + }) + } else { + serde_json::json!({ + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0 + }) + }; + + let openai_resp = serde_json::json!({ + "id": id, + "object": "chat.completion", + "created": created, + "model": model, + "choices": [{ + "index": 0, + "message": message, + "finish_reason": finish_reason + }], + "usage": usage + }); + + let body = serde_json::to_vec(&openai_resp) + .map_err(|e| Error::internal_err(format!("Failed to serialize OpenAI response: {}", e)))?; + + Ok(BedrockProxyResponse { + status_code: StatusCode::OK, + headers: json_response_headers(), + body: BedrockProxyResponseBody::Fixed(Bytes::from(body)), + }) +} + +fn json_response_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("content-type", "application/json".parse().unwrap()); + headers +} + +fn event_stream_response_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("content-type", "text/event-stream".parse().unwrap()); + headers.insert("cache-control", "no-cache".parse().unwrap()); + headers.insert("connection", "keep-alive".parse().unwrap()); + headers +} + +fn document_to_json(doc: &aws_smithy_types::Document) -> serde_json::Value { + match doc { + aws_smithy_types::Document::Object(map) => { + let mut json_map = serde_json::Map::new(); + for (key, value) in map { + json_map.insert(key.clone(), document_to_json(value)); + } + serde_json::Value::Object(json_map) + } + aws_smithy_types::Document::Array(values) => { + serde_json::Value::Array(values.iter().map(document_to_json).collect()) + } + aws_smithy_types::Document::Number(number) => match number { + aws_smithy_types::Number::PosInt(number) => serde_json::Value::Number((*number).into()), + aws_smithy_types::Number::NegInt(number) => serde_json::Value::Number((*number).into()), + aws_smithy_types::Number::Float(number) => serde_json::json!(*number), + }, + aws_smithy_types::Document::String(value) => serde_json::Value::String(value.clone()), + aws_smithy_types::Document::Bool(value) => serde_json::Value::Bool(*value), + aws_smithy_types::Document::Null => serde_json::Value::Null, + } +} // ============================================================================ // Query Builder @@ -256,3 +966,60 @@ impl BedrockQueryBuilder { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn determine_auth_config_prioritizes_bearer_token() { + let config = determine_auth_config( + Some("bearer-token"), + Some("AKIA123"), + Some("secret"), + Some("session-token"), + ); + + match config { + BedrockAuthConfig::BearerToken(token) => assert_eq!(token, "bearer-token"), + _ => panic!("expected bearer token auth config"), + } + } + + #[test] + fn determine_auth_config_uses_iam_with_optional_session_token() { + let config = + determine_auth_config(None, Some("AKIA123"), Some("secret"), Some("session-token")); + + match config { + BedrockAuthConfig::IamCredentials { + access_key_id, + secret_access_key, + session_token, + } => { + assert_eq!(access_key_id, "AKIA123"); + assert_eq!(secret_access_key, "secret"); + assert_eq!(session_token.as_deref(), Some("session-token")); + } + _ => panic!("expected IAM auth config"), + } + } + + #[test] + fn determine_auth_config_treats_empty_session_token_as_none() { + let config = determine_auth_config(None, Some("AKIA123"), Some("secret"), Some("")); + + match config { + BedrockAuthConfig::IamCredentials { session_token, .. } => { + assert!(session_token.is_none()); + } + _ => panic!("expected IAM auth config"), + } + } + + #[test] + fn determine_auth_config_falls_back_to_environment() { + let config = determine_auth_config(None, Some("AKIA123"), None, Some("session-token")); + assert!(matches!(config, BedrockAuthConfig::Environment)); + } +} diff --git a/backend/windmill-ai/src/providers/google_ai.rs b/backend/windmill-ai/src/providers/google_ai.rs index b6a6295d88..57abae5182 100644 --- a/backend/windmill-ai/src/providers/google_ai.rs +++ b/backend/windmill-ai/src/providers/google_ai.rs @@ -408,6 +408,8 @@ fn build_google_ai_model_endpoint( action: &str, is_vertex: bool, ) -> String { + let model = model.strip_prefix("models/").unwrap_or(model); + if is_vertex { format!("{}/{}:{}", base_url, model, action) } else { @@ -416,6 +418,10 @@ fn build_google_ai_model_endpoint( } fn add_google_ai_auth_header(headers: &mut Vec<(String, String)>, api_key: &str, is_vertex: bool) { + // Native Google AI proxy intentionally does not apply AI_HTTP_HEADERS or + // resource custom headers yet. Gemini/Vertex header semantics are + // provider-specific; keep this limited to required auth headers until + // explicit custom-header support is designed. if is_vertex { headers.push(("Authorization".to_string(), format!("Bearer {}", api_key))); } else { @@ -749,6 +755,32 @@ mod tests { assert!(body["contents"].is_array()); } + #[test] + fn builds_standard_google_ai_endpoint_from_model_resource_name() { + assert_eq!( + build_google_ai_model_endpoint( + "https://generativelanguage.googleapis.com/v1beta", + "models/gemini-2.0-flash", + "generateContent", + false, + ), + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent" + ); + } + + #[test] + fn builds_vertex_google_ai_endpoint_from_model_resource_name() { + assert_eq!( + build_google_ai_model_endpoint( + "https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/us-central1/publishers/google/models", + "models/gemini-2.0-flash", + "streamGenerateContent", + true, + ), + "https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:streamGenerateContent" + ); + } + #[test] fn builds_vertex_google_ai_streaming_proxy_request() { let credentials = credentials( diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index d1a602cf0e..f3f4a37478 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -6,35 +6,15 @@ pub mod openai; pub mod openrouter; pub mod other; -use crate::{ - ai_providers::AIProvider, proxy::ProviderCredentials, query_builder::QueryBuilder, - types::ProviderWithResource, -}; +use crate::{ai_providers::AIProvider, proxy::ProviderCredentials, query_builder::QueryBuilder}; use self::{ anthropic::AnthropicQueryBuilder, google_ai::GoogleAIQueryBuilder, openai::OpenAIQueryBuilder, openrouter::OpenRouterQueryBuilder, other::OtherQueryBuilder, }; -/// Factory function to create the appropriate query builder for a provider. -pub fn create_query_builder(provider: &ProviderWithResource) -> Box { - match provider.kind { - AIProvider::GoogleAI => { - Box::new(GoogleAIQueryBuilder::new(provider.get_platform().clone())) - } - AIProvider::OpenAI => Box::new(OpenAIQueryBuilder::new(provider.kind.clone())), - AIProvider::Anthropic => Box::new(AnthropicQueryBuilder::new( - provider.kind.clone(), - provider.get_platform().clone(), - provider.get_enable_1m_context(), - )), - AIProvider::OpenRouter => Box::new(OpenRouterQueryBuilder::new()), - _ => Box::new(OtherQueryBuilder::new(provider.kind.clone())), - } -} - -/// Factory function to create the appropriate query builder from resolved proxy credentials. -pub fn create_proxy_query_builder(credentials: &ProviderCredentials) -> Box { +/// Factory function to create the appropriate query builder from resolved credentials. +pub fn create_query_builder(credentials: &ProviderCredentials) -> Box { match credentials.provider { AIProvider::GoogleAI => Box::new(GoogleAIQueryBuilder::new(credentials.platform.clone())), AIProvider::OpenAI => Box::new(OpenAIQueryBuilder::new(credentials.provider.clone())), diff --git a/backend/windmill-ai/src/proxy.rs b/backend/windmill-ai/src/proxy.rs index bbc570a18a..2600999e1c 100644 --- a/backend/windmill-ai/src/proxy.rs +++ b/backend/windmill-ai/src/proxy.rs @@ -7,10 +7,11 @@ use windmill_common::error::{Error, Result}; use crate::ai_providers::{AIPlatform, AIProvider}; use crate::utils::AI_HTTP_HEADERS; -/// Resolved provider credentials and proxy-specific context. +/// Resolved provider credentials shared by API proxy and worker execution. /// -/// This is intentionally separate from the worker's `ProviderWithResource`: API -/// proxy credentials are already resolved from workspace or instance resources. +/// Raw API resources and worker agent payloads convert into this shape at their +/// execution boundaries. Request-specific state such as the selected model stays +/// outside this type. #[derive(Clone, Debug)] pub struct ProviderCredentials { pub provider: AIProvider, diff --git a/backend/windmill-ai/src/types.rs b/backend/windmill-ai/src/types.rs index c64db5efe0..1d18e2411c 100644 --- a/backend/windmill-ai/src/types.rs +++ b/backend/windmill-ai/src/types.rs @@ -18,6 +18,7 @@ pub struct McpToolSource { use crate::{ ai_google::sanitize_schema_for_google, ai_providers::{empty_string_as_none, AIProvider}, + proxy::ProviderCredentials, }; use windmill_common::{db::DB, error::Error, flow_status::AgentAction, flows::FlowModule}; use windmill_parser::Typ; @@ -222,6 +223,34 @@ impl ProviderWithResource { .await } + /// Convert worker agent provider input into resolved runtime credentials. + /// + /// Callers must only pass resources that were already authorized for the + /// current job/workspace; this helper does not perform access checks. + pub async fn to_provider_credentials(&self, db: &DB) -> Result { + let base_url = if self.kind == AIProvider::AWSBedrock { + String::new() + } else { + self.get_base_url(db).await? + }; + + Ok(ProviderCredentials { + provider: self.kind.clone(), + base_url, + api_key: self.resource.api_key.clone(), + access_token: None, + organization_id: None, + user: None, + region: self.resource.region.clone(), + aws_access_key_id: self.resource.aws_access_key_id.clone(), + aws_secret_access_key: self.resource.aws_secret_access_key.clone(), + aws_session_token: self.resource.aws_session_token.clone(), + platform: self.resource.platform.clone(), + enable_1m_context: self.resource.enable_1m_context, + custom_headers: self.resource.headers.clone(), + }) + } + #[cfg(feature = "bedrock")] pub fn get_region(&self) -> Option<&str> { self.resource.region.as_deref() diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index bda9f54bdd..314ba99450 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -250,92 +250,121 @@ impl AuthCache { let username_override = username_override_from_label(label); if let Some((prefix, name)) = owner.split_once('/') { if prefix == "u" { - let (is_admin, is_operator) = if super_admin { - (true, false) + let lookup = if super_admin { + Some((true, false)) } else { - let r = sqlx::query!( + sqlx::query!( "SELECT is_admin, operator FROM usr where username = $1 AND \ workspace_id = $2 AND disabled = false", name, &w_id.as_ref().unwrap() ) - .fetch_one(&self.db) + .fetch_optional(&self.db) .await - .ok(); - if let Some(r) = r { - (r.is_admin, r.operator) - } else { - (false, true) - } + .ok() + .flatten() + .map(|r| (r.is_admin, r.operator)) }; - let w_id = &w_id.unwrap(); - let groups = - get_groups_for_user(w_id, &name, &email, &self.db) - .await - .ok() - .unwrap_or_default(); + if let Some((is_admin, is_operator)) = lookup { + let w_id = &w_id.unwrap(); + let groups = + get_groups_for_user(w_id, &name, &email, &self.db) + .await + .ok() + .unwrap_or_default(); - let folders = - get_folders_for_user(w_id, &name, &groups, &self.db) - .await - .ok() - .unwrap_or_default(); + let folders = get_folders_for_user( + w_id, &name, &groups, &self.db, + ) + .await + .ok() + .unwrap_or_default(); - Some(ApiAuthed { - email: email, - username: name.to_string(), - is_admin, - is_operator, - groups, - folders, - scopes: None, - username_override, - token_prefix: Some(safe_token_prefix(token)), - read_only, - }) + Some(ApiAuthed { + email: email, + username: name.to_string(), + is_admin, + is_operator, + groups, + folders, + scopes: None, + username_override, + token_prefix: Some(safe_token_prefix(token)), + read_only, + }) + } else { + tracing::warn!( + "Token owner u/{} is not a member of workspace {}; rejecting auth", + name, + w_id.as_deref().unwrap_or("") + ); + None + } + } else if prefix == "g" { + let group_exists = if super_admin { + true + } else { + sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM group_ WHERE workspace_id = $1 AND name = $2)", + &w_id.as_ref().unwrap(), + name, + ) + .fetch_one(&self.db) + .await + .ok() + .flatten() + .unwrap_or(false) + }; + + if group_exists { + let groups = vec![name.to_string()]; + let folders = get_folders_for_user( + &w_id.unwrap(), + "", + &groups, + &self.db, + ) + .await + .ok() + .unwrap_or_default(); + Some(ApiAuthed { + email: email, + username: format!( + "{}{name}", + windmill_common::users::USERNAME_GROUP_PREFIX + ), + is_admin: false, + groups, + is_operator: false, + folders, + scopes: None, + username_override, + token_prefix: Some(safe_token_prefix(token)), + read_only, + }) + } else { + tracing::warn!( + "Token owner g/{} is not a group in workspace {}; rejecting auth", + name, + w_id.as_deref().unwrap_or("") + ); + None + } } else { - let groups = vec![name.to_string()]; - let folders = get_folders_for_user( - &w_id.unwrap(), - "", - &groups, - &self.db, - ) - .await - .ok() - .unwrap_or_default(); - Some(ApiAuthed { - email: email, - username: format!( - "{}{name}", - windmill_common::users::USERNAME_GROUP_PREFIX - ), - is_admin: false, - groups, - is_operator: false, - folders, - scopes: None, - username_override, - token_prefix: Some(safe_token_prefix(token)), - read_only, - }) + tracing::warn!( + "Token owner '{}' has unrecognised prefix '{}'; rejecting auth", + owner, + prefix + ); + None } } else { - let groups = vec![]; - let folders = vec![]; - Some(ApiAuthed { - email: email, - username: owner, - is_admin: super_admin, - is_operator: true, - groups, - folders, - scopes: None, - username_override, - token_prefix: Some(safe_token_prefix(token)), - read_only, - }) + tracing::warn!( + "Token owner '{}' is missing a prefix (expected u/ or g/); rejecting auth", + owner + ); + None } } (_, Some(email), super_admin, scopes, label, read_only) => { diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index b9bc2e2417..99fa677cee 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -235,6 +235,50 @@ where Ok(()) } +/// 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), +/// the predicate always returns `true`. +/// +/// Pre-parses the token's scopes once so the returned closure can cheaply +/// filter large listings without re-parsing on each call. +pub fn build_scope_path_predicate( + authed: &ApiAuthed, + domain: &str, + action: &str, +) -> impl Fn(&str) -> bool { + // Mirror check_scopes semantics: a token is "scope-restricted" iff it has + // at least one non-`if_jobs:filter_tags:` scope. Unparseable scopes still + // count as restrictive — they just match nothing. + let (is_scoped_token, parsed): (bool, Vec) = match authed.scopes.as_ref() { + Some(scopes) => { + let mut is_scoped = false; + let parsed = scopes + .iter() + .filter(|s| !s.starts_with("if_jobs:filter_tags:")) + .inspect(|_| is_scoped = true) + .filter_map(|s| ScopeDefinition::from_scope_string(s).ok()) + .collect(); + (is_scoped, parsed) + } + None => (false, Vec::new()), + }; + let domain = domain.to_string(); + let action = action.to_string(); + + move |path: &str| -> bool { + if !is_scoped_token { + return true; + } + let required = + match ScopeDefinition::from_scope_string(&format!("{}:{}:{}", domain, action, path)) { + Ok(r) => r, + Err(_) => return false, + }; + parsed.iter().any(|s| s.includes(&required)) + } +} + pub async fn require_devops_role(db: &DB, email: &str) -> error::Result<()> { let is_devops = is_devops_email(db, email).await?; @@ -803,3 +847,65 @@ pub fn require_path_read_access_for_preview( ))), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn authed_with_scopes(scopes: Option>) -> ApiAuthed { + ApiAuthed { + scopes: scopes.map(|v| v.into_iter().map(String::from).collect()), + ..Default::default() + } + } + + #[test] + fn predicate_no_scopes_allows_all() { + let authed = authed_with_scopes(None); + let allowed = build_scope_path_predicate(&authed, "resources", "read"); + assert!(allowed("u/alice/anything")); + assert!(allowed("u/bob/other")); + } + + #[test] + fn predicate_tag_filter_only_allows_all() { + let authed = authed_with_scopes(Some(vec!["if_jobs:filter_tags:default"])); + let allowed = build_scope_path_predicate(&authed, "resources", "read"); + assert!(allowed("u/alice/foo")); + } + + #[test] + fn predicate_single_resource_scope_filters_others() { + // Regression test for WIN-1981: a token scoped to one resource must + // not match unrelated paths in listings (e.g. /resources/list_search). + let authed = authed_with_scopes(Some(vec!["resources:read:u/alice/allowed_resource"])); + let allowed = build_scope_path_predicate(&authed, "resources", "read"); + assert!(allowed("u/alice/allowed_resource")); + assert!(!allowed("u/alice/other_resource")); + assert!(!allowed("u/bob/foo")); + } + + #[test] + fn predicate_wildcard_scope_matches_subtree() { + let authed = authed_with_scopes(Some(vec!["resources:read:f/team/*"])); + let allowed = build_scope_path_predicate(&authed, "resources", "read"); + assert!(allowed("f/team/db")); + assert!(allowed("f/team/sub/nested")); + assert!(!allowed("f/other/db")); + } + + #[test] + fn predicate_wrong_domain_is_rejected() { + let authed = authed_with_scopes(Some(vec!["variables:read:u/alice/secret"])); + let allowed = build_scope_path_predicate(&authed, "resources", "read"); + assert!(!allowed("u/alice/secret")); + } + + #[test] + fn predicate_write_implies_read() { + let authed = authed_with_scopes(Some(vec!["resources:write:u/alice/foo"])); + let allowed = build_scope_path_predicate(&authed, "resources", "read"); + assert!(allowed("u/alice/foo")); + assert!(!allowed("u/alice/bar")); + } +} diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 907e0d9b69..af7c42ee4b 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -47,6 +47,7 @@ use windmill_common::secret_backend::{ AwsSecretsManagerSettings, AzureKeyVaultSettings, SecretMigrationReport, VaultSettings, }; use windmill_common::{ + ee_oss::{get_license_plan, LicensePlan}, email_oss::send_email_plain_text, error::{self, JsonResult, Result}, get_database_url, @@ -54,12 +55,15 @@ use windmill_common::{ AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, - HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, RUFF_CONFIG_SETTING, WS_BASE_URL_SETTING, + HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, RUFF_CONFIG_SETTING, + WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, + WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, + WS_BASE_URL_SETTING, }, instance_config::{self, ApplyMode, InstanceConfig}, server::Smtp, }; -use windmill_common::{error::to_anyhow, PgDatabase}; +use windmill_common::{error::to_anyhow, worker::CLOUD_HOSTED, PgDatabase}; /// Unauthenticated settings routes. /// @@ -446,6 +450,27 @@ pub async fn delete_global_setting(db: &DB, key: &str) -> error::Result<()> { tracing::info!("Unset global setting {}", key); Ok(()) } +/// Returns true when `key` is one of the workspace-fairness settings whose +/// writes must be gated to cloud only. +fn is_workspace_fairness_setting(key: &str) -> bool { + matches!( + key, + WORKSPACE_FAIRNESS_ENABLED_SETTING + | WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING + | WORKSPACE_FAIRNESS_DURATION_SECS_SETTING + | WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING + ) +} + +/// Enterprise gate for the workspace-fairness settings. Workspace fairness is +/// only useful on multi-tenant clusters where one workspace can starve other +/// workspaces sharing the same worker pool, and the feature is licensed as +/// part of Enterprise. Non-EE installs are rejected at write time; the runtime +/// dispatch additionally honours the `WORKSPACE_FAIRNESS_ENABLED` toggle. +async fn workspace_fairness_settings_allowed() -> bool { + matches!(get_license_plan().await, LicensePlan::Enterprise) +} + pub async fn set_global_setting( Extension(db): Extension, authed: ApiAuthed, @@ -468,6 +493,31 @@ pub async fn set_global_setting_internal( value }; + // EE gate for workspace-fairness settings. Workspace fairness only matters + // on multi-tenant clusters; it is licensed as an Enterprise feature so the + // setter rejects writes from non-EE builds. Disabling/clearing writes are + // *always* allowed regardless of license, so an admin who downgrades from + // EE (or imports a row from a cloned EE DB) can always turn the cap off: + // - `Null` / empty-string → row delete + // - `Bool(false)` on `workspace_fairness_enabled` → explicit disable + // Without the `Bool(false)` carve-out, a stale `enabled=true` row from a + // downgrade would be impossible to flip off through the normal API/UI + // and the runtime path (which only checks the toggle) would keep + // throttling. + let is_clearing_value = matches!(&value, serde_json::Value::Null) + || matches!(&value, serde_json::Value::String(s) if s.trim().is_empty()) + || (key == WORKSPACE_FAIRNESS_ENABLED_SETTING + && matches!(&value, serde_json::Value::Bool(false))); + if is_workspace_fairness_setting(&key) + && !is_clearing_value + && !workspace_fairness_settings_allowed().await + { + return Err(error::Error::BadRequest(format!( + "{} requires an Enterprise license", + key + ))); + } + run_setting_pre_write_hook(db, &key, &value).await?; match value { @@ -545,7 +595,10 @@ async fn run_setting_pre_write_hook( ))); }; - if !*workspaced_route { + // Cloud always scopes app custom paths by workspace_id (see + // `custom_path_exists` in apps.rs), so duplicates across workspaces + // are expected and this setting has no runtime effect on cloud. + if !*workspaced_route && !*CLOUD_HOSTED { #[derive(Debug, Deserialize, Serialize)] #[allow(unused)] struct DuplicateApp { @@ -608,7 +661,11 @@ async fn run_setting_pre_write_hook( ))); }; - if !*workspaced_route { + // Cloud always scopes routes by workspace_id (see + // `route_path_key_exists` in windmill-trigger-http), so duplicates + // across workspaces are expected and this setting has no runtime + // effect on cloud. + if !*workspaced_route && !*CLOUD_HOSTED { #[derive(Debug, Deserialize, Serialize)] #[allow(unused)] struct DuplicateRoute { @@ -726,6 +783,27 @@ async fn set_instance_config( .iter() .any(|(key, _)| key == AI_CONFIG_SETTING); + // Mirror the per-key EE gate in `set_global_setting_internal`. Without + // this, the bulk endpoint would let a non-EE superadmin persist + // `workspace_fairness_*` rows even though the per-key API rejects them. + // Only block *non-disabling* upserts; deletes are allowed everywhere + // (already filtered into `settings_diff.removals`) and a + // `workspace_fairness_enabled=false` upsert is treated as a disable, + // so a downgraded instance can always turn the cap off via the bulk + // YAML endpoint too. + let upserts_touch_fairness_non_disable = settings_diff.upserts.iter().any(|(k, v)| { + if !is_workspace_fairness_setting(k) { + return false; + } + !(k == WORKSPACE_FAIRNESS_ENABLED_SETTING + && matches!(v, serde_json::Value::Bool(false))) + }); + if upserts_touch_fairness_non_disable && !workspace_fairness_settings_allowed().await { + return Err(error::Error::BadRequest( + "Workspace fairness settings require an Enterprise license".to_string(), + )); + } + for (key, value) in &settings_diff.upserts { run_setting_pre_write_hook(&db, key, value).await?; } diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 0eb9d26b5d..405efba4e5 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -207,6 +207,11 @@ pub struct GlobalUserInfo { username: Option, #[serde(skip_serializing_if = "Option::is_none")] operator_only: Option, + /// Populated only for service-account rows (which are workspace-scoped). + /// `None` for password users since their admin status varies per workspace + /// and is not surfaced by this aggregation. + #[serde(skip_serializing_if = "Option::is_none")] + is_workspace_admin: Option, first_time_user: bool, role_source: String, disabled: bool, @@ -455,11 +460,11 @@ async fn list_users_as_super_admin( GlobalUserInfo, r#"WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')), authors as (SELECT distinct email FROM usr WHERE usr.operator IS false) - SELECT email as "email!", (email NOT IN (SELECT email FROM authors)) as operator_only, login_type::text, verified as "verified!", super_admin as "super_admin!", devops as "devops!", name, company, username, first_time_user as "first_time_user!", role_source as "role_source!", disabled as "disabled!", NULL::text as workspace_id + SELECT email as "email!", (email NOT IN (SELECT email FROM authors)) as operator_only, NULL::bool as is_workspace_admin, login_type::text, verified as "verified!", super_admin as "super_admin!", devops as "devops!", name, company, username, first_time_user as "first_time_user!", role_source as "role_source!", disabled as "disabled!", NULL::text as workspace_id FROM password WHERE email IN (SELECT email FROM active_users) UNION ALL - SELECT email as "email!", true as operator_only, 'service_account'::text as login_type, true as "verified!", false as "super_admin!", false as "devops!", NULL::text as name, NULL::text as company, username, false as "first_time_user!", 'service_account'::text as "role_source!", disabled as "disabled!", workspace_id + SELECT email as "email!", operator as operator_only, is_admin as is_workspace_admin, 'service_account'::text as login_type, true as "verified!", false as "super_admin!", false as "devops!", NULL::text as name, NULL::text as company, username, false as "first_time_user!", 'service_account'::text as "role_source!", disabled as "disabled!", workspace_id FROM usr WHERE is_service_account IS true ORDER BY "super_admin!" DESC, "devops!" DESC @@ -472,9 +477,9 @@ async fn list_users_as_super_admin( } else { sqlx::query_as!( GlobalUserInfo, - r#"SELECT email as "email!", login_type::text, verified as "verified!", super_admin as "super_admin!", devops as "devops!", name, company, username, NULL::bool as operator_only, first_time_user as "first_time_user!", role_source as "role_source!", disabled as "disabled!", NULL::text as workspace_id FROM password + r#"SELECT email as "email!", login_type::text, verified as "verified!", super_admin as "super_admin!", devops as "devops!", name, company, username, NULL::bool as operator_only, NULL::bool as is_workspace_admin, first_time_user as "first_time_user!", role_source as "role_source!", disabled as "disabled!", NULL::text as workspace_id FROM password UNION ALL - SELECT email as "email!", 'service_account'::text as login_type, true as "verified!", false as "super_admin!", false as "devops!", NULL::text as name, NULL::text as company, username, true as operator_only, false as "first_time_user!", 'service_account'::text as "role_source!", disabled as "disabled!", workspace_id + SELECT email as "email!", 'service_account'::text as login_type, true as "verified!", false as "super_admin!", false as "devops!", NULL::text as name, NULL::text as company, username, operator as operator_only, is_admin as is_workspace_admin, false as "first_time_user!", 'service_account'::text as "role_source!", disabled as "disabled!", workspace_id FROM usr WHERE is_service_account IS true ORDER BY "super_admin!" DESC, "devops!" DESC, "email!" @@ -727,7 +732,7 @@ async fn global_whoami( ) -> JsonResult { let user = sqlx::query_as!( GlobalUserInfo, - "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled, NULL::text as workspace_id FROM password WHERE \ + "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, NULL::bool as is_workspace_admin, first_time_user, role_source, disabled, NULL::text as workspace_id FROM password WHERE \ email = $1", email ) @@ -748,13 +753,24 @@ async fn global_whoami( company: None, username: None, operator_only: None, + is_workspace_admin: None, first_time_user: false, role_source: "manual".to_string(), disabled: false, workspace_id: None, })) } else { - // Service accounts don't have a password row + // Service accounts don't have a password row. The SA email is unique + // per (workspace, username) and pinpoints a single usr row, so we can + // surface its real role rather than pinning to operator. + let sa_role = sqlx::query!( + "SELECT operator, is_admin FROM usr WHERE email = $1 AND is_service_account IS true LIMIT 1", + email + ) + .fetch_optional(&db) + .await + .map_err(|e| Error::internal_err(format!("fetching service-account role: {e:#}")))?; + Ok(Json(GlobalUserInfo { email: email.clone(), login_type: Some("service_account".to_string()), @@ -764,7 +780,8 @@ async fn global_whoami( name: None, company: None, username: None, - operator_only: Some(true), + operator_only: sa_role.as_ref().map(|r| r.operator).or(Some(true)), + is_workspace_admin: sa_role.as_ref().map(|r| r.is_admin), first_time_user: false, role_source: "service_account".to_string(), disabled: false, diff --git a/backend/windmill-api-workers/src/lib.rs b/backend/windmill-api-workers/src/lib.rs index dc6b08db08..d65316555e 100644 --- a/backend/windmill-api-workers/src/lib.rs +++ b/backend/windmill-api-workers/src/lib.rs @@ -39,6 +39,10 @@ pub fn global_service() -> Router { .route("/queue_metrics", get(get_queue_metrics)) .route("/queue_counts", get(get_queue_counts)) .route("/queue_running_counts", get(get_queue_running_counts)) + .route( + "/workspace_fairness_events", + get(get_workspace_fairness_events), + ) } pub fn workspaced_service() -> Router { @@ -283,3 +287,77 @@ async fn get_queue_running_counts( let queue_running_counts = windmill_common::queue::get_queue_running_counts(&db).await; Ok(Json(queue_running_counts)) } + +#[derive(Serialize)] +pub struct WorkspaceFairnessEvent { + pub timestamp: chrono::DateTime, + pub operation: String, + /// Affected workspace (stored in audit log `resource`). `None` only for very + /// old rows pre-dating the resource convention — UI should treat as "unknown". + pub workspace_id: Option, + /// Snapshot of the relevant fairness settings at the time of the transition + /// (`max_percent`, `window_secs`, `total_overloaded`). `None` for uncap rows. + pub parameters: Option, +} + +/// Return the most recent ~200 cap and ~200 uncap transitions (merged into +/// at most 400 rows) written by `workspace_fairness::emit_transition_audit`. +/// Workspace fairness is an Enterprise feature; on non-EE / non-enabled +/// instances the table is naturally empty. +async fn get_workspace_fairness_events( + authed: ApiAuthed, + Extension(db): Extension, +) -> JsonResult> { + require_devops_role(&db, &authed.email).await?; + + // No cloud-host gate — workspace fairness is an Enterprise feature + // available on any multi-tenant EE deployment. Non-EE / non-enabled + // instances will simply have no audit rows of these operation types, + // so the table is naturally empty. + // + // Return the most recent 200 cap **and** the most recent 200 uncap + // events separately, then merge — without this, a long stretch of caps + // can push every uncap off the unified `LIMIT 200` window and the UI + // appears to "never record uncaps". (The unified ordered limit was a + // real footgun in production audit drawers.) + let events = sqlx::query_as!( + WorkspaceFairnessEvent, + r#" + WITH capped AS ( + SELECT timestamp, operation, resource, parameters + FROM audit_partitioned + WHERE workspace_id = 'admins' + AND operation = 'workspace_fairness.capped' + UNION ALL + SELECT timestamp, operation, resource, parameters + FROM audit + WHERE workspace_id = 'admins' + AND operation = 'workspace_fairness.capped' + ORDER BY timestamp DESC + LIMIT 200 + ), uncapped AS ( + SELECT timestamp, operation, resource, parameters + FROM audit_partitioned + WHERE workspace_id = 'admins' + AND operation = 'workspace_fairness.uncapped' + UNION ALL + SELECT timestamp, operation, resource, parameters + FROM audit + WHERE workspace_id = 'admins' + AND operation = 'workspace_fairness.uncapped' + ORDER BY timestamp DESC + LIMIT 200 + ) + SELECT timestamp AS "timestamp!", + operation::text AS "operation!", + resource AS workspace_id, + parameters + FROM (SELECT * FROM capped UNION ALL SELECT * FROM uncapped) e + ORDER BY timestamp DESC + "#, + ) + .fetch_all(&db) + .await?; + + Ok(Json(events)) +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 3c99548628..07ebcc7468 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -5451,6 +5451,16 @@ If you do not have an account on {}, login with SSO or ask an admin to create an #[derive(Deserialize)] pub struct NewServiceAccount { pub username: String, + #[serde(default)] + pub is_admin: bool, + #[serde(default = "default_true")] + pub operator: bool, + #[serde(default)] + pub add_to_deployers: bool, +} + +fn default_true() -> bool { + true } async fn create_service_account( diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index 2af5e17406..fbe6fb23c8 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -40,7 +40,7 @@ gcp_trigger = ["dep:windmill-trigger-gcp", "windmill-store/gcp_trigger"] azure_trigger = ["dep:windmill-trigger-azure", "windmill-store/azure_trigger"] cloud = ["windmill-common/cloud", "windmill-api-auth/cloud", "windmill-store/cloud", "windmill-api-workspaces/cloud"] mcp = ["dep:windmill-mcp", "windmill-mcp/server", "windmill-mcp/auth", "windmill-api-auth/mcp", "windmill-store/mcp"] -bedrock = ["windmill-ai/bedrock", "dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-types"] +bedrock = ["windmill-ai/bedrock"] python = ["windmill-dep-map/python", "dep:windmill-parser-py", "dep:windmill-parser-py-imports", "windmill-api-scripts/python", "windmill-api-configs/python", "windmill-api-agent-workers?/python", "windmill-trigger/python", "windmill-common/python"] no_auth = ["windmill-api-auth/no_auth", "windmill-store/no_auth", "windmill-api-users/no_auth"] quickjs = ["windmill-jseval/quickjs"] @@ -172,11 +172,6 @@ rustls = { workspace = true } aws-sigv4 = { workspace = true, optional = true } aws-sdk-config = { workspace = true, optional = true } -aws-config = { workspace = true, optional = true } -aws-credential-types = { workspace = true, optional = true } -aws-sdk-bedrock = { workspace = true, optional = true } -aws-sdk-bedrockruntime = { workspace = true, optional = true } -aws-smithy-types = { workspace = true, optional = true } async-trait.workspace = true eventsource-stream.workspace = true windmill-jseval.workspace = true diff --git a/backend/windmill-api/openapi-deref.json b/backend/windmill-api/openapi-deref.json index 5bd7f5c21e..a7950f38ef 100644 --- a/backend/windmill-api/openapi-deref.json +++ b/backend/windmill-api/openapi-deref.json @@ -1,7 +1,7 @@ { "openapi": "3.0.3", "info": { - "version": "1.689.0", + "version": "1.708.0", "title": "Windmill API", "contact": { "name": "Windmill Team", @@ -2529,6 +2529,70 @@ } } }, + "/settings/audit_logs_s3_status": { + "get": { + "summary": "get status of the audit-log object-store export cursor", + "operationId": "getAuditLogsS3Status", + "tags": [ + "setting" + ], + "responses": { + "200": { + "description": "current export status (null if the feature was never enabled)", + "content": { + "application/json": { + "schema": { + "nullable": true, + "type": "object", + "properties": { + "last_xmin": { + "type": "integer", + "format": "int64" + }, + "last_ts": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "bootstrapping": { + "type": "boolean" + }, + "last_exported_audit_ts": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "last_run_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "last_run_exported": { + "type": "integer", + "format": "int64" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "owner": { + "type": "string", + "nullable": true + } + }, + "required": [ + "last_xmin", + "bootstrapping", + "last_run_exported", + "updated_at" + ] + } + } + } + } + } + } + }, "/settings/send_stats": { "post": { "summary": "send stats", @@ -2678,6 +2742,82 @@ } } }, + "/settings/offline_license_status": { + "get": { + "summary": "get cap-usage status for the currently-loaded offline license", + "description": "Returns the live cap status (seats used vs cap, current CU vs cap) for\nthe offline license key currently in use. Returns `null` if no offline\nlicense is loaded. Super-admin only.\n", + "operationId": "getOfflineLicenseStatus", + "tags": [ + "setting" + ], + "responses": { + "200": { + "description": "cap status (or null when no offline license)", + "content": { + "application/json": { + "schema": { + "type": "object", + "nullable": true, + "properties": { + "seats_used": { + "type": "number", + "description": "Author-equivalent seats consumed (authors + 0.5 × operators)" + }, + "seats_cap": { + "type": "integer" + }, + "author_count": { + "type": "integer" + }, + "operator_count": { + "type": "integer" + }, + "current_cu": { + "type": "number", + "description": "Sum of CU rate across workers that pinged in the last 2 minutes." + }, + "cu_cap": { + "type": "number" + }, + "cu_over_cap": { + "type": "boolean" + } + } + } + } + } + } + } + } + }, + "/settings/instance_hash": { + "get": { + "summary": "per-instance binding hash for offline license issuance", + "description": "Returns the hash a superadmin shares with Windmill support when\nrequesting an offline license. Super-admin only.\n", + "operationId": "getInstanceHash", + "tags": [ + "setting" + ], + "responses": { + "200": { + "description": "instance hash", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "instance_hash": { + "type": "string", + "nullable": true + } + } + } + } + } + } + } + } + }, "/settings/customer_portal": { "post": { "summary": "create customer portal session", @@ -3733,6 +3873,136 @@ } } }, + "/github_app/ghes/discover": { + "get": { + "summary": "Discover GHES App installations", + "description": "Lists every installation the configured self-managed GitHub App can see,\nannotated with the workspaces in this Windmill instance the\ninstallation is currently assigned to. Super-admin only.\n", + "operationId": "discoverGhesInstallations", + "tags": [ + "Git Sync" + ], + "responses": { + "200": { + "description": "Discovered installations", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "installation_id", + "account_id", + "assigned_workspaces" + ], + "properties": { + "installation_id": { + "type": "integer", + "format": "int64" + }, + "account_id": { + "type": "string", + "description": "GitHub login of the installation's account (org or user)" + }, + "assigned_workspaces": { + "type": "array", + "items": { + "type": "object", + "required": [ + "workspace_id", + "provisioned_by_admin" + ], + "properties": { + "workspace_id": { + "type": "string" + }, + "provisioned_by_admin": { + "type": "boolean" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/github_app/ghes/assign": { + "post": { + "summary": "Assign GHES installation to a workspace", + "description": "Assigns a discovered GHES App installation to a workspace. The resulting\ninstallation is marked as admin-provisioned, so workspace admins cannot\nremove it. Super-admin only.\n", + "operationId": "assignGhesInstallation", + "tags": [ + "Git Sync" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "workspace_id", + "installation_id" + ], + "properties": { + "workspace_id": { + "type": "string" + }, + "installation_id": { + "type": "integer", + "format": "int64" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Installation assigned" + } + } + } + }, + "/github_app/ghes/assign/{workspace_id}/{installation_id}": { + "delete": { + "summary": "Unassign GHES installation from a workspace", + "description": "Removes an installation (admin-provisioned or otherwise) from a\nworkspace. Super-admin only. Does not affect the installation on the\nGitHub side.\n", + "operationId": "unassignGhesInstallation", + "tags": [ + "Git Sync" + ], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "installation_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Installation unassigned" + } + } + } + }, "/users/accept_invite": { "post": { "summary": "accept invite to workspace", @@ -3950,6 +4220,18 @@ "properties": { "username": { "type": "string" + }, + "is_admin": { + "type": "boolean", + "description": "Grant the service account workspace admin. Defaults to false. Cannot be combined with operator=true." + }, + "operator": { + "type": "boolean", + "description": "Make the service account an operator. Defaults to true for backward compatibility. Set to false to count as a developer (1 seat) instead of 0.5 seat." + }, + "add_to_deployers": { + "type": "boolean", + "description": "Add the service account to the workspace `wm_deployers` group on creation. Recommended when the account will be used as a CLI sync / CI deploy identity so it can deploy on behalf of other users." } }, "required": [ @@ -4622,9 +4904,72 @@ } } }, + "/w/{workspace}/workspaces/get_public_settings": { + "get": { + "summary": "get public settings", + "description": "Returns the subset of workspace settings safe to expose to any workspace member. The full settings struct is admin-only via `getSettings`.", + "operationId": "getPublicSettings", + "tags": [ + "workspace" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + } + ], + "responses": { + "200": { + "description": "status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "workspace_id": { + "type": "string" + }, + "slack_name": { + "type": "string" + }, + "slack_team_id": { + "type": "string" + }, + "teams_team_id": { + "type": "string" + }, + "teams_team_name": { + "type": "string" + }, + "teams_team_guid": { + "type": "string" + }, + "large_file_storage": { + "$ref": "#/components/schemas/LargeFileStorage" + }, + "datatable": { + "$ref": "#/components/schemas/DataTableSettings" + }, + "deploy_ui": { + "$ref": "#/components/schemas/WorkspaceDeployUISettings" + }, + "mute_critical_alerts": { + "type": "boolean" + } + }, + "required": [ + "workspace_id" + ] + } + } + } + } + } + } + }, "/w/{workspace}/workspaces/get_settings": { "get": { - "summary": "get settings", + "summary": "get settings (admin only)", + "description": "Returns the full workspace settings including admin-managed integration credentials. Admin-only — non-admin callers should use `getPublicSettings`.", "operationId": "getSettings", "tags": [ "workspace" @@ -5467,6 +5812,53 @@ } } }, + "/w/{workspace}/workspaces/connect_slack": { + "post": { + "summary": "connect slack (non-interactive; pre-minted bot token)", + "operationId": "connectSlack", + "tags": [ + "workspace" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + } + ], + "requestBody": { + "description": "connect slack with a pre-minted bot token", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "bot_token", + "team_id", + "team_name" + ], + "properties": { + "bot_token": { + "type": "string", + "description": "xoxb-... bot token obtained at api.slack.com/apps" + }, + "team_id": { + "type": "string" + }, + "team_name": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "status" + } + } + } + }, "/w/{workspace}/workspaces/run_slack_message_test_job": { "post": { "summary": "run a job that sends a message to Slack", @@ -6099,6 +6491,85 @@ } } }, + "/w/{workspace}/workspaces/list_datatable_tables": { + "get": { + "summary": "list tables of all connected Datatables", + "operationId": "listDataTableTables", + "tags": [ + "workspace" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + } + ], + "responses": { + "200": { + "description": "table metadata of all datatables", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DataTableTables" + } + } + } + } + } + } + } + }, + "/w/{workspace}/workspaces/get_datatable_table_schema": { + "get": { + "summary": "get one Datatable table schema", + "operationId": "getDataTableTableSchema", + "tags": [ + "workspace" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "datatable_name", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "schema_name", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "table_name", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "schema of one datatable table", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DataTableTableSchema" + } + } + } + } + } + } + }, "/w/{workspace}/workspaces/edit_ducklake_config": { "post": { "summary": "edit ducklake settings", @@ -7357,6 +7828,57 @@ } } }, + "/users/tokens/update_scopes/{token_prefix}": { + "post": { + "summary": "update scopes of an existing token (owner only)", + "operationId": "updateTokenScopes", + "tags": [ + "user" + ], + "parameters": [ + { + "name": "token_prefix", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "new scopes (null or omitted = full access)", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "scopes": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "scopes updated", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/users/tokens/list": { "get": { "summary": "list token", @@ -8696,6 +9218,96 @@ } } }, + "/w/{workspace}/workspaces/list_ws_specific": { + "get": { + "summary": "list all workspace-specific items", + "operationId": "listWsSpecific", + "tags": [ + "workspace" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + } + ], + "responses": { + "200": { + "description": "list of workspace-specific items", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "item_kind": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "item_kind", + "path" + ] + } + } + } + } + } + } + } + }, + "/w/{workspace}/workspaces/list_ws_specific_versions": { + "get": { + "summary": "list workspace ids that have a version of the given item", + "operationId": "listWsSpecificVersions", + "tags": [ + "workspace" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "kind", + "in": "query", + "required": true, + "schema": { + "type": "string", + "enum": [ + "resource", + "variable" + ] + } + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "list of workspace ids that have a version of the item", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, "/w/{workspace}/workspaces/public_app_rate_limit": { "post": { "summary": "Set public app rate limit for this workspace", @@ -8888,6 +9500,48 @@ } } }, + "/oauth/connect_slack_instance": { + "post": { + "summary": "connect slack instance (non-interactive; pre-minted bot token)", + "operationId": "connectSlackInstance", + "tags": [ + "oauth" + ], + "requestBody": { + "description": "connect slack at the instance level with a pre-minted bot token", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "bot_token", + "team_id", + "team_name" + ], + "properties": { + "bot_token": { + "type": "string", + "description": "xoxb-... bot token obtained at api.slack.com/apps" + }, + "team_id": { + "type": "string" + }, + "team_name": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "status" + } + } + } + }, "/oauth/connect_callback/{client_name}": { "post": { "summary": "connect callback", @@ -9248,6 +9902,10 @@ }, "saml": { "type": "string" + }, + "auto_login": { + "type": "string", + "description": "provider type to auto-redirect to on login (oauth key or \"saml\")" } }, "required": [ @@ -11485,6 +12143,7 @@ "/w/{workspace}/scripts/create": { "post": { "summary": "create script", + "description": "Creates a new script when the path does not already exist.\nCreates a new version of an existing script when called with the same path and the current `parent_hash`.\n", "operationId": "createScript", "x-mcp-tool": true, "x-mcp-instructions": "To create a script, specify the path (e.g., 'f/my_folder/my_script'), the content (source code), and the language. For TypeScript, use 'bun' unless deno-specific APIs are needed.", @@ -14481,6 +15140,11 @@ "properties": { "draft": { "$ref": "#/components/schemas/Flow" + }, + "draft_created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check." } } } @@ -14843,13 +15507,13 @@ } }, { - "name": "after_id", - "description": "id to fetch only the messages after that id", + "name": "after_seq", + "description": "Message sequence cursor to fetch only the messages after that cursor", "in": "query", "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int64" } } ], @@ -14881,6 +15545,14 @@ "parameters": [ { "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "force", + "description": "bypass the server-side cache and re-query the DB, refreshing the\ncache. Used right after a deploy so the new path appears immediately.\n", + "in": "query", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -14982,6 +15654,198 @@ } } }, + "/w/{workspace}/shared_ui/get": { + "get": { + "summary": "get the workspace shared UI folder (full content)", + "operationId": "getSharedUi", + "tags": [ + "workspace" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + } + ], + "responses": { + "200": { + "description": "shared UI content", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "files", + "version", + "edited_at", + "edited_by" + ], + "properties": { + "files": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "version": { + "type": "integer", + "format": "int64" + }, + "edited_at": { + "type": "string", + "format": "date-time" + }, + "edited_by": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/w/{workspace}/shared_ui/list": { + "get": { + "summary": "list paths/sizes of the workspace shared UI folder", + "operationId": "listSharedUi", + "tags": [ + "workspace" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + } + ], + "responses": { + "200": { + "description": "shared UI listing", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "paths", + "sizes", + "version", + "edited_at", + "edited_by" + ], + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "sizes": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } + }, + "version": { + "type": "integer", + "format": "int64" + }, + "edited_at": { + "type": "string", + "format": "date-time" + }, + "edited_by": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/w/{workspace}/shared_ui/version": { + "get": { + "summary": "get the current version of the workspace shared UI folder", + "operationId": "getSharedUiVersion", + "tags": [ + "workspace" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + } + ], + "responses": { + "200": { + "description": "shared UI version", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + } + } + }, + "/w/{workspace}/shared_ui": { + "put": { + "summary": "replace the workspace shared UI folder (admin only)", + "operationId": "updateSharedUi", + "tags": [ + "workspace" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "files" + ], + "properties": { + "files": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "updated", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/w/{workspace}/apps/get_data/v/{secretWithExtension}": { "get": { "summary": "get raw app data by", @@ -16044,6 +16908,9 @@ }, "cache_ttl": { "type": "integer" + }, + "tag": { + "type": "string" } }, "required": [ @@ -16066,9 +16933,26 @@ "type": "string" } }, + "force_viewer_sensitive_inputs": { + "type": "array", + "items": { + "type": "string" + } + }, + "force_viewer_delete_after_secs": { + "type": "integer" + }, "run_query_params": { "type": "object", "description": "Runnable query parameters" + }, + "temp_script_refs": { + "type": "object", + "nullable": true, + "description": "Map of relative-import script path -> temp storage hash. Only honored for inline-script (raw_code) execution so app dev resolves those imports from not-yet-deployed local content.", + "additionalProperties": { + "type": "string" + } } }, "required": [ @@ -16572,15 +17456,35 @@ "properties": { "step_id": { "type": "string", - "description": "step id to restart the flow from" + "description": "top-level step id to restart the flow from (or the outermost container when restarting at a nested step)" }, "branch_or_iteration_n": { "type": "integer", - "description": "for branchall or loop, the iteration at which the flow should restart (optional)" + "description": "for branchall or loop at the top level, the iteration at which the flow should restart (optional)" }, "flow_version": { "type": "integer", "description": "specific flow version to use for restart (optional, uses current version if not specified)" + }, + "nested_path": { + "type": "array", + "description": "path of additional steps to descend into AFTER `step_id`. Each entry represents one level of nesting inside the spawned child of the previous level's container (BranchOne / sequential ForLoop iteration / Subflow). When non-empty, the actual restart point is the LAST entry's step_id.", + "items": { + "type": "object", + "required": [ + "step_id" + ], + "properties": { + "step_id": { + "type": "string", + "description": "step id at this nesting level" + }, + "branch_or_iteration_n": { + "type": "integer", + "description": "for ForLoop containers, the iteration to restart at (0-based; iterations 0..n-1 are preserved)" + } + } + } } } } @@ -18433,6 +19337,14 @@ "type": "boolean" } }, + { + "name": "excludes_entrypoint_override", + "description": "exclude jobs that were started with a `_ENTRYPOINT_OVERRIDE` arg (e.g. dynamic-select helper runs and preprocessor previews)", + "in": "query", + "schema": { + "type": "boolean" + } + }, { "name": "broad_filter", "description": "broad search across multiple fields (case-insensitive substring match on path, tag, schedule path, trigger kind, label)", @@ -18564,6 +19476,14 @@ "schema": { "type": "boolean" } + }, + { + "name": "approval_token", + "in": "query", + "description": "Approval token granting read access to the job when not logged in. The token must be the one issued for this job's flow (i.e. the flow id used when generating the approval URL).", + "schema": { + "type": "string" + } } ], "responses": { @@ -20473,6 +21393,10 @@ "properties": { "enabled": { "type": "boolean" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a schedule in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -21209,6 +22133,10 @@ "properties": { "mode": { "$ref": "#/components/schemas/TriggerMode" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a trigger in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -21488,6 +22416,10 @@ "properties": { "mode": { "$ref": "#/components/schemas/TriggerMode" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a trigger in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -21819,6 +22751,10 @@ "properties": { "mode": { "$ref": "#/components/schemas/TriggerMode" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a trigger in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -22216,6 +23152,10 @@ "properties": { "mode": { "$ref": "#/components/schemas/TriggerMode" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a trigger in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -22540,6 +23480,10 @@ "properties": { "mode": { "$ref": "#/components/schemas/TriggerMode" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a trigger in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -23789,6 +24733,10 @@ "properties": { "mode": { "$ref": "#/components/schemas/TriggerMode" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a trigger in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -24113,6 +25061,10 @@ "properties": { "mode": { "$ref": "#/components/schemas/TriggerMode" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a trigger in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -24539,6 +25491,10 @@ "properties": { "mode": { "$ref": "#/components/schemas/TriggerMode" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a trigger in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -25459,6 +26415,10 @@ "properties": { "mode": { "$ref": "#/components/schemas/TriggerMode" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a trigger in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -25833,6 +26793,10 @@ "properties": { "mode": { "$ref": "#/components/schemas/TriggerMode" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a trigger in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -29364,6 +30328,15 @@ "schema": { "type": "string" } + }, + { + "name": "marker_file", + "description": "If provided, the folder is only considered to exist when this exact\nsentinel file is present under file_key. Lets callers distinguish a\nfully populated folder from a partial upload.\n", + "in": "query", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -30850,6 +31823,14 @@ "is_alive": { "type": "boolean" }, + "state": { + "type": "string", + "enum": [ + "running", + "stale", + "never_started" + ] + }, "last_locked_at": { "type": "string", "format": "date-time", @@ -30880,6 +31861,14 @@ "is_alive": { "type": "boolean" }, + "state": { + "type": "string", + "enum": [ + "running", + "stale", + "never_started" + ] + }, "last_locked_at": { "type": "string", "format": "date-time", @@ -33082,6 +34071,14 @@ } ], "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/schemas-InputTransform" + } + ], + "description": "Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n" } }, "required": [ @@ -33103,6 +34100,11 @@ "aiagent" ] }, + "omit_output_from_conversation": { + "type": "boolean", + "default": false, + "description": "If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled." + }, "parallel": { "type": "boolean", "description": "If true, the agent can execute multiple tool calls in parallel" @@ -33921,10 +34923,18 @@ "type": "string", "description": "KV v2 secrets engine mount path (e.g., windmill)" }, + "kv_secret_path_prefix": { + "type": "string", + "description": "Optional path prefix inserted between the KV data/metadata segment and the workspace id (e.g., \"apps/windmill\"). When set, secrets are stored at `/data///`, allowing a Vault policy scoped to exactly `/data//*`." + }, "jwt_role": { "type": "string", "description": "Vault JWT auth role name for Windmill (optional, if not provided token auth is used)" }, + "jwt_mount_path": { + "type": "string", + "description": "Mount path for the JWT auth method in Vault (optional, defaults to \"jwt\"). Set this when the JWT auth method is mounted at a non-default path, e.g. via `vault auth enable -path= jwt`." + }, "namespace": { "type": "string", "description": "Vault Enterprise namespace (optional)" @@ -34120,7 +35130,8 @@ "conversation_id", "message_type", "content", - "created_at" + "created_at", + "created_seq" ], "properties": { "id": { @@ -34158,6 +35169,11 @@ "format": "date-time", "description": "When the message was created" }, + "created_seq": { + "type": "integer", + "format": "int64", + "description": "Monotonic cursor assigned when the message is inserted" + }, "step_name": { "type": "string", "description": "The step name that produced that message" @@ -34841,6 +35857,11 @@ "draft": { "$ref": "#/components/schemas/NewScript" }, + "draft_created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check." + }, "hash": { "type": "string" } @@ -36128,12 +37149,19 @@ }, "email": { "type": "string" + }, + "workspace_id": { + "type": "string" + }, + "read_only": { + "type": "boolean" } }, "required": [ "token_prefix", "created_at", - "last_used_at" + "last_used_at", + "read_only" ] }, "ExternalJwtToken": { @@ -36199,6 +37227,10 @@ }, "workspace_id": { "type": "string" + }, + "read_only": { + "type": "boolean", + "description": "If true, the token is restricted to read-only HTTP methods\n(GET/HEAD/OPTIONS). Mutating endpoints and job-run actions are\nrejected with 403, regardless of the scopes attached.\n" } } }, @@ -36274,6 +37306,16 @@ "items": { "type": "string" } + }, + "ws_specific": { + "type": "boolean" + }, + "edited_at": { + "type": "string", + "format": "date-time" + }, + "edited_by": { + "type": "string" } }, "required": [ @@ -36343,6 +37385,9 @@ "items": { "type": "string" } + }, + "ws_specific": { + "type": "boolean" } }, "required": [ @@ -36376,6 +37421,9 @@ "items": { "type": "string" } + }, + "ws_specific": { + "type": "boolean" } } }, @@ -36808,6 +37856,14 @@ "additionalProperties": { "$ref": "#/components/schemas/ScriptModule" } + }, + "temp_script_refs": { + "type": "object", + "nullable": true, + "description": "Map of relative-import script path -> temp storage hash so the preview job resolves those imports from not-yet-deployed local content instead of the deployed script", + "additionalProperties": { + "type": "string" + } } }, "required": [ @@ -36899,6 +37955,9 @@ "items": { "type": "string" } + }, + "ws_specific": { + "type": "boolean" } }, "required": [ @@ -36928,6 +37987,9 @@ "items": { "type": "string" } + }, + "ws_specific": { + "type": "boolean" } } }, @@ -36968,6 +38030,9 @@ "items": { "type": "string" } + }, + "ws_specific": { + "type": "boolean" } }, "required": [ @@ -37028,6 +38093,9 @@ "items": { "type": "string" } + }, + "ws_specific": { + "type": "boolean" } }, "required": [ @@ -37086,7 +38154,7 @@ "properties": { "path": { "type": "string", - "description": "The unique path identifier for this schedule" + "description": "The unique Windmill path for this schedule. Must be of the form `u//` or `f//`." }, "edited_by": { "type": "string", @@ -37296,7 +38364,7 @@ "properties": { "path": { "type": "string", - "description": "The unique path identifier for this schedule" + "description": "The unique Windmill path for this schedule. Must be of the form `u//` or `f//`." }, "schedule": { "type": "string", @@ -37589,7 +38657,7 @@ "properties": { "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -37921,7 +38989,7 @@ "properties": { "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -38049,7 +39117,7 @@ "properties": { "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -38354,7 +39422,7 @@ "properties": { "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -38469,7 +39537,7 @@ }, "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -38768,7 +39836,7 @@ }, "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -38852,7 +39920,7 @@ }, "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -39032,7 +40100,7 @@ }, "path": { "type": "string", - "description": "The unique path identifier for this trigger." + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -39261,7 +40329,8 @@ } }, "path": { - "type": "string" + "type": "string", + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string" @@ -39497,7 +40566,7 @@ }, "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -39571,7 +40640,7 @@ }, "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -39791,7 +40860,7 @@ }, "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -39860,7 +40929,7 @@ }, "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -40018,7 +41087,7 @@ "properties": { "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -40181,7 +41250,7 @@ }, "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -40298,7 +41367,7 @@ "properties": { "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -40402,7 +41471,7 @@ }, "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -41070,7 +42139,8 @@ "type": "string", "enum": [ "password", - "github" + "github", + "service_account" ] }, "super_admin": { @@ -41094,6 +42164,10 @@ "operator_only": { "type": "boolean" }, + "is_workspace_admin": { + "type": "boolean", + "description": "Populated only for service accounts. True if the service account has workspace admin in its (single) workspace." + }, "first_time_user": { "type": "boolean" }, @@ -41101,11 +42175,15 @@ "type": "string", "enum": [ "manual", - "instance_group" + "instance_group", + "service_account" ] }, "disabled": { "type": "boolean" + }, + "workspace_id": { + "type": "string" } }, "required": [ @@ -41276,6 +42354,14 @@ }, "restarted_from": { "$ref": "#/components/schemas/RestartedFrom" + }, + "temp_script_refs": { + "type": "object", + "nullable": true, + "description": "Map of relative-import script path -> temp storage hash, propagated to each flow step so inline-script relative imports resolve from not-yet-deployed local content instead of the deployed script", + "additionalProperties": { + "type": "string" + } } }, "required": [ @@ -41295,10 +42381,31 @@ "type": "string" }, "branch_or_iteration_n": { - "type": "integer" + "type": "integer", + "description": "0-based iteration index for ForLoop / branch index for BranchAll. Iterations 0..n-1 are preserved; iteration n is restarted." }, "flow_version": { "type": "integer" + }, + "branch_chosen": { + "description": "For BranchOne nested restart — the branch that was originally chosen, used to lock branch evaluation.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "default", + "branch" + ] + }, + "branch": { + "type": "integer" + } + } + }, + "nested": { + "$ref": "#/components/schemas/RestartedFrom", + "description": "When set, the worker spawns the child for `step_id` as a `RestartedFlow` against `nested.flow_job_id` instead of fresh-launching it." } } }, @@ -41593,7 +42700,12 @@ "draft_only": { "type": "boolean" }, - "draft": {} + "draft": {}, + "draft_created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check." + } } } ] @@ -41901,6 +43013,59 @@ } } }, + "DataTableTables": { + "type": "object", + "required": [ + "datatable_name", + "schemas" + ], + "properties": { + "datatable_name": { + "type": "string" + }, + "schemas": { + "type": "object", + "description": "Hierarchical metadata: schema_name -> table_names", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "error": { + "type": "string" + } + } + }, + "DataTableTableSchema": { + "type": "object", + "required": [ + "datatable_name", + "schema_name", + "table_name", + "columns" + ], + "properties": { + "datatable_name": { + "type": "string" + }, + "schema_name": { + "type": "string" + }, + "table_name": { + "type": "string" + }, + "columns": { + "type": "object", + "description": "Columns in this table: column_name -> compact_type", + "additionalProperties": { + "type": "string", + "description": "Compact type: 'type[?][=default]' where ? means nullable" + } + } + } + }, "DynamicInputData": { "type": "object", "properties": { @@ -42667,7 +43832,19 @@ "raw_app", "resource", "variable", - "resource_type" + "resource_type", + "folder", + "schedule", + "http_trigger", + "websocket_trigger", + "kafka_trigger", + "nats_trigger", + "postgres_trigger", + "mqtt_trigger", + "sqs_trigger", + "gcp_trigger", + "azure_trigger", + "email_trigger" ], "description": "Type of the item" }, @@ -42710,6 +43887,8 @@ "variables_changed", "resource_types_changed", "folders_changed", + "schedules_changed", + "triggers_changed", "conflicts" ], "properties": { @@ -42753,6 +43932,14 @@ "type": "integer", "description": "Number of folders with differences" }, + "schedules_changed": { + "type": "integer", + "description": "Number of schedules with differences" + }, + "triggers_changed": { + "type": "integer", + "description": "Number of triggers with differences (sum across all trigger kinds)" + }, "conflicts": { "type": "integer", "description": "Number of items that are both ahead and behind (conflicts)" @@ -42860,6 +44047,15 @@ "error": { "type": "string", "description": "Error message if token retrieval failed" + }, + "github_base_url": { + "type": "string", + "nullable": true, + "description": "Set for self-managed (GHES) installs. Cloud installs omit this field." + }, + "provisioned_by_admin": { + "type": "boolean", + "description": "True when the installation was assigned by the instance super-admin from instance settings. Workspace admins cannot remove these." } }, "required": [ @@ -44714,6 +45910,14 @@ } ], "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/schemas-InputTransform" + } + ], + "description": "Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n" } }, "required": [ @@ -44735,6 +45939,11 @@ "aiagent" ] }, + "omit_output_from_conversation": { + "type": "boolean", + "default": false, + "description": "If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled." + }, "parallel": { "type": "boolean", "description": "If true, the agent can execute multiple tool calls in parallel" diff --git a/backend/windmill-api/openapi-deref.yaml b/backend/windmill-api/openapi-deref.yaml index 367b570b29..ed3f0d0358 100644 --- a/backend/windmill-api/openapi-deref.yaml +++ b/backend/windmill-api/openapi-deref.yaml @@ -1,6 +1,6 @@ openapi: 3.0.3 info: - version: 1.689.0 + version: 1.708.0 title: Windmill API contact: name: Windmill Team @@ -146,18 +146,18 @@ paths: checks: type: object description: Detailed health checks - required: &ref_346 + required: &ref_347 - database - readiness - properties: &ref_347 + properties: &ref_348 database: type: object description: Database health status - required: &ref_348 + required: &ref_349 - healthy - latency_ms - pool - properties: &ref_349 + properties: &ref_350 healthy: type: boolean description: Whether the database is reachable @@ -168,11 +168,11 @@ paths: pool: type: object description: Database connection pool statistics - required: &ref_350 + required: &ref_351 - size - idle - max_connections - properties: &ref_351 + properties: &ref_352 size: type: integer description: Current number of connections in the pool @@ -186,13 +186,13 @@ paths: description: Workers health status nullable: true type: object - required: &ref_352 + required: &ref_353 - healthy - active_count - worker_groups - min_version - versions - properties: &ref_353 + properties: &ref_354 healthy: type: boolean description: Whether any workers are active @@ -219,10 +219,10 @@ paths: description: Job queue status nullable: true type: object - required: &ref_354 + required: &ref_355 - pending_jobs - running_jobs - properties: &ref_355 + properties: &ref_356 pending_jobs: type: integer format: int64 @@ -234,9 +234,9 @@ paths: readiness: type: object description: Server readiness status - required: &ref_356 + required: &ref_357 - healthy - properties: &ref_357 + properties: &ref_358 healthy: type: boolean description: Whether the server is ready to accept requests @@ -488,24 +488,24 @@ paths: - name: before description: filter on started before (inclusive) timestamp in: query - schema: &ref_298 + schema: &ref_299 type: string format: date-time - name: after description: filter on created after (exclusive) timestamp in: query - schema: &ref_299 + schema: &ref_300 type: string format: date-time - name: username description: filter on exact username of user in: query - schema: &ref_307 + schema: &ref_308 type: string - name: operation description: filter on exact or prefix name of operation in: query - schema: &ref_308 + schema: &ref_309 type: string - name: operations in: query @@ -520,12 +520,12 @@ paths: - name: resource description: filter on exact or prefix name of resource in: query - schema: &ref_309 + schema: &ref_310 type: string - name: action_kind description: filter on type of operation in: query - schema: &ref_310 + schema: &ref_311 type: string enum: - Create @@ -562,12 +562,12 @@ paths: application/json: schema: type: object - properties: &ref_396 + properties: &ref_397 email: type: string password: type: string - required: &ref_397 + required: &ref_398 - email - password responses: @@ -757,7 +757,7 @@ paths: nullable: true allOf: - type: object - properties: &ref_393 + properties: &ref_394 source: type: string enum: @@ -775,7 +775,7 @@ paths: description: >- The instance group name (when source is 'instance_group') - required: &ref_394 + required: &ref_395 - source is_service_account: type: boolean @@ -813,7 +813,7 @@ paths: application/json: schema: type: object - properties: &ref_398 + properties: &ref_399 is_admin: type: boolean operator: @@ -1187,7 +1187,7 @@ paths: type: array items: type: object - properties: &ref_412 + properties: &ref_413 jwt_hash: type: integer format: int64 @@ -1210,7 +1210,7 @@ paths: last_used_at: type: string format: date-time - required: &ref_413 + required: &ref_414 - jwt_hash - email - username @@ -1342,7 +1342,7 @@ paths: type: array items: type: object - properties: &ref_399 + properties: &ref_400 label: type: string scopes: @@ -1351,7 +1351,7 @@ paths: type: string expiration: type: string - required: &ref_400 + required: &ref_401 - label - scopes description: Tokens owned by this user (will be deleted) @@ -1395,7 +1395,7 @@ paths: application/json: schema: type: object - properties: &ref_401 + properties: &ref_402 reassign_to: type: string description: 'Target for reassignment: ''u/{username}'' or ''f/{folder}''' @@ -1409,7 +1409,7 @@ paths: type: boolean default: true description: Whether to also remove the user from the workspace - required: &ref_402 + required: &ref_403 - reassign_to responses: '200': @@ -1428,7 +1428,7 @@ paths: on success. summary: type: object - properties: &ref_403 + properties: &ref_404 scripts_reassigned: type: integer flows_reassigned: @@ -1445,7 +1445,7 @@ paths: type: integer drafts_deleted: type: integer - required: &ref_404 + required: &ref_405 - scripts_reassigned - flows_reassigned - apps_reassigned @@ -1475,12 +1475,12 @@ paths: application/json: schema: type: object - properties: &ref_405 + properties: &ref_406 workspaces: type: array items: type: object - properties: &ref_407 + properties: &ref_408 workspace_id: type: string username: @@ -1489,11 +1489,11 @@ paths: type: object properties: *ref_12 required: *ref_13 - required: &ref_408 + required: &ref_409 - workspace_id - username - preview - required: &ref_406 + required: &ref_407 - workspaces /users/offboard/{email}: post: @@ -1515,12 +1515,12 @@ paths: application/json: schema: type: object - properties: &ref_409 + properties: &ref_410 reassignments: type: object additionalProperties: type: object - properties: &ref_410 + properties: &ref_411 reassign_to: type: string description: 'Target: ''u/{username}'' or ''f/{folder}''' @@ -1529,7 +1529,7 @@ paths: description: >- Required when reassign_to is a folder. Username to use as permissioned_as. - required: &ref_411 + required: &ref_412 - reassign_to description: Map of workspace_id to reassignment config delete_user: @@ -1589,7 +1589,7 @@ paths: application/json: schema: type: array - items: &ref_558 + items: &ref_562 type: object properties: workspace_id: @@ -1621,6 +1621,18 @@ paths: error: type: string description: Error message if token retrieval failed + github_base_url: + type: string + nullable: true + description: >- + Set for self-managed (GHES) installs. Cloud installs + omit this field. + provisioned_by_admin: + type: boolean + description: >- + True when the installation was assigned by the instance + super-admin from instance settings. Workspace admins + cannot remove these. required: - installation_id - account_id @@ -1687,7 +1699,7 @@ paths: application/json: schema: type: object - properties: &ref_501 + properties: &ref_502 email: type: string workspaces: @@ -1762,7 +1774,7 @@ paths: - username - color - disabled - required: &ref_502 + required: &ref_503 - email - workspaces /w/{workspace}/workspaces/get_as_superadmin: @@ -1824,7 +1836,7 @@ paths: application/json: schema: type: object - properties: &ref_503 + properties: &ref_504 id: type: string name: @@ -1833,7 +1845,7 @@ paths: type: string color: type: string - required: &ref_504 + required: &ref_505 - id - name responses: @@ -2009,7 +2021,7 @@ paths: properties: &ref_24 logs: type: object - properties: &ref_469 + properties: &ref_470 super_admin: type: string enum: &ref_21 @@ -2552,6 +2564,52 @@ paths: - orphans_scanned - orphans_deleted - errors + /settings/audit_logs_s3_status: + get: + summary: get status of the audit-log object-store export cursor + operationId: getAuditLogsS3Status + tags: + - setting + responses: + '200': + description: current export status (null if the feature was never enabled) + content: + application/json: + schema: + nullable: true + type: object + properties: + last_xmin: + type: integer + format: int64 + last_ts: + type: string + format: date-time + nullable: true + bootstrapping: + type: boolean + last_exported_audit_ts: + type: string + format: date-time + nullable: true + last_run_at: + type: string + format: date-time + nullable: true + last_run_exported: + type: integer + format: int64 + updated_at: + type: string + format: date-time + owner: + type: string + nullable: true + required: + - last_xmin + - bootstrapping + - last_run_exported + - updated_at /settings/send_stats: post: summary: send stats @@ -2649,6 +2707,65 @@ paths: text/plain: schema: type: string + /settings/offline_license_status: + get: + summary: get cap-usage status for the currently-loaded offline license + description: | + Returns the live cap status (seats used vs cap, current CU vs cap) for + the offline license key currently in use. Returns `null` if no offline + license is loaded. Super-admin only. + operationId: getOfflineLicenseStatus + tags: + - setting + responses: + '200': + description: cap status (or null when no offline license) + content: + application/json: + schema: + type: object + nullable: true + properties: + seats_used: + type: number + description: >- + Author-equivalent seats consumed (authors + 0.5 × + operators) + seats_cap: + type: integer + author_count: + type: integer + operator_count: + type: integer + current_cu: + type: number + description: >- + Sum of CU rate across workers that pinged in the last 2 + minutes. + cu_cap: + type: number + cu_over_cap: + type: boolean + /settings/instance_hash: + get: + summary: per-instance binding hash for offline license issuance + description: | + Returns the hash a superadmin shares with Windmill support when + requesting an offline license. Super-admin only. + operationId: getInstanceHash + tags: + - setting + responses: + '200': + description: instance hash + content: + application/json: + schema: + type: object + properties: + instance_hash: + type: string + nullable: true /settings/customer_portal: post: summary: create customer portal session @@ -2703,11 +2820,11 @@ paths: type: array items: type: object - properties: &ref_541 + properties: &ref_545 name: type: string value: {} - required: &ref_542 + required: &ref_546 - name - value /settings/instance_config: @@ -2805,9 +2922,9 @@ paths: application/json: schema: type: object - required: &ref_369 + required: &ref_370 - keys - properties: &ref_370 + properties: &ref_371 keys: type: array items: @@ -2839,11 +2956,26 @@ paths: mount_path: type: string description: KV v2 secrets engine mount path (e.g., windmill) + kv_secret_path_prefix: + type: string + description: >- + Optional path prefix inserted between the KV data/metadata + segment and the workspace id (e.g., "apps/windmill"). When + set, secrets are stored at + `/data///`, allowing a + Vault policy scoped to exactly `/data//*`. jwt_role: type: string description: >- Vault JWT auth role name for Windmill (optional, if not provided token auth is used) + jwt_mount_path: + type: string + description: >- + Mount path for the JWT auth method in Vault (optional, + defaults to "jwt"). Set this when the JWT auth method is + mounted at a non-default path, e.g. via `vault auth enable + -path= jwt`. namespace: type: string description: Vault Enterprise namespace (optional) @@ -2909,11 +3041,11 @@ paths: type: array items: type: object - required: &ref_367 + required: &ref_368 - workspace_id - path - error - properties: &ref_368 + properties: &ref_369 workspace_id: type: string description: Workspace ID where the secret is located @@ -2979,10 +3111,10 @@ paths: type: string description: >- Azure AD client secret. Optional — when omitted, the - integration falls back to Azure Workload Identity Federation, - exchanging the Kubernetes-projected service-account JWT at - AZURE_FEDERATED_TOKEN_FILE for an access token (no long-lived - secret stored). + integration falls back to Azure Workload Identity + Federation, exchanging the Kubernetes-projected + service-account JWT at AZURE_FEDERATED_TOKEN_FILE for an + access token (no long-lived secret stored). token: type: string description: >- @@ -3281,6 +3413,7 @@ paths: enum: - password - github + - service_account super_admin: type: boolean devops: @@ -3295,6 +3428,11 @@ paths: type: string operator_only: type: boolean + is_workspace_admin: + type: boolean + description: >- + Populated only for service accounts. True if the service + account has workspace admin in its (single) workspace. first_time_user: type: boolean role_source: @@ -3302,8 +3440,11 @@ paths: enum: - manual - instance_group + - service_account disabled: type: boolean + workspace_id: + type: string required: &ref_40 - email - login_type @@ -3572,6 +3713,101 @@ paths: - base_url - app_slug - client_id + /github_app/ghes/discover: + get: + summary: Discover GHES App installations + description: | + Lists every installation the configured self-managed GitHub App can see, + annotated with the workspaces in this Windmill instance the + installation is currently assigned to. Super-admin only. + operationId: discoverGhesInstallations + tags: + - Git Sync + responses: + '200': + description: Discovered installations + content: + application/json: + schema: + type: array + items: + type: object + required: + - installation_id + - account_id + - assigned_workspaces + properties: + installation_id: + type: integer + format: int64 + account_id: + type: string + description: GitHub login of the installation's account (org or user) + assigned_workspaces: + type: array + items: + type: object + required: + - workspace_id + - provisioned_by_admin + properties: + workspace_id: + type: string + provisioned_by_admin: + type: boolean + /github_app/ghes/assign: + post: + summary: Assign GHES installation to a workspace + description: | + Assigns a discovered GHES App installation to a workspace. The resulting + installation is marked as admin-provisioned, so workspace admins cannot + remove it. Super-admin only. + operationId: assignGhesInstallation + tags: + - Git Sync + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - workspace_id + - installation_id + properties: + workspace_id: + type: string + installation_id: + type: integer + format: int64 + responses: + '200': + description: Installation assigned + /github_app/ghes/assign/{workspace_id}/{installation_id}: + delete: + summary: Unassign GHES installation from a workspace + description: | + Removes an installation (admin-provisioned or otherwise) from a + workspace. Super-admin only. Does not affect the installation on the + GitHub side. + operationId: unassignGhesInstallation + tags: + - Git Sync + parameters: + - name: workspace_id + in: path + required: true + schema: + type: string + - name: installation_id + in: path + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: Installation unassigned /users/accept_invite: post: summary: accept invite to workspace @@ -3721,6 +3957,24 @@ paths: properties: username: type: string + is_admin: + type: boolean + description: >- + Grant the service account workspace admin. Defaults to + false. Cannot be combined with operator=true. + operator: + type: boolean + description: >- + Make the service account an operator. Defaults to true for + backward compatibility. Set to false to count as a developer + (1 seat) instead of 0.5 seat. + add_to_deployers: + type: boolean + description: >- + Add the service account to the workspace `wm_deployers` + group on creation. Recommended when the account will be used + as a CLI sync / CI deploy identity so it can deploy on + behalf of other users. required: - username responses: @@ -4086,13 +4340,13 @@ paths: application/json: schema: type: object - required: &ref_550 + required: &ref_554 - all_ahead_items_visible - all_behind_items_visible - skipped_comparison - diffs - summary - properties: &ref_551 + properties: &ref_555 all_ahead_items_visible: type: boolean description: >- @@ -4113,7 +4367,7 @@ paths: description: List of differences found between workspaces items: type: object - required: &ref_552 + required: &ref_556 - kind - path - ahead @@ -4121,7 +4375,7 @@ paths: - has_changes - exists_in_source - exists_in_fork - properties: &ref_553 + properties: &ref_557 kind: type: string enum: @@ -4132,6 +4386,18 @@ paths: - resource - variable - resource_type + - folder + - schedule + - http_trigger + - websocket_trigger + - kafka_trigger + - nats_trigger + - postgres_trigger + - mqtt_trigger + - sqs_trigger + - gcp_trigger + - azure_trigger + - email_trigger description: Type of the item path: type: string @@ -4154,7 +4420,7 @@ paths: summary: description: Summary statistics of the comparison type: object - required: &ref_554 + required: &ref_558 - total_diffs - total_ahead - total_behind @@ -4165,8 +4431,10 @@ paths: - variables_changed - resource_types_changed - folders_changed + - schedules_changed + - triggers_changed - conflicts - properties: &ref_555 + properties: &ref_559 total_diffs: type: integer description: Total number of items with differences @@ -4197,6 +4465,14 @@ paths: folders_changed: type: integer description: Number of folders with differences + schedules_changed: + type: integer + description: Number of schedules with differences + triggers_changed: + type: integer + description: >- + Number of triggers with differences (sum across all + trigger kinds) conflicts: type: integer description: >- @@ -4298,9 +4574,162 @@ paths: type: object properties: *ref_41 required: *ref_42 + /w/{workspace}/workspaces/get_public_settings: + get: + summary: get public settings + description: >- + Returns the subset of workspace settings safe to expose to any workspace + member. The full settings struct is admin-only via `getSettings`. + operationId: getPublicSettings + tags: + - workspace + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + responses: + '200': + description: status + content: + application/json: + schema: + type: object + properties: + workspace_id: + type: string + slack_name: + type: string + slack_team_id: + type: string + teams_team_id: + type: string + teams_team_name: + type: string + teams_team_guid: + type: string + large_file_storage: + type: object + properties: &ref_45 + type: + type: string + enum: + - S3Storage + - AzureBlobStorage + - AzureWorkloadIdentity + - S3AwsOidc + - GoogleCloudStorage + s3_resource_path: + type: string + azure_blob_resource_path: + type: string + gcs_resource_path: + type: string + public_resource: + type: boolean + advanced_permissions: + type: array + items: + type: object + properties: &ref_531 + pattern: + type: string + allow: + type: string + required: &ref_532 + - pattern + - allow + secondary_storage: + type: object + additionalProperties: + type: object + properties: + type: + type: string + enum: + - S3Storage + - AzureBlobStorage + - AzureWorkloadIdentity + - S3AwsOidc + - GoogleCloudStorage + s3_resource_path: + type: string + azure_blob_resource_path: + type: string + gcs_resource_path: + type: string + public_resource: + type: boolean + datatable: + type: object + required: &ref_46 + - datatables + properties: &ref_47 + datatables: + type: object + additionalProperties: + type: object + required: + - database + properties: + database: + type: object + properties: + resource_type: + type: string + enum: + - postgresql + - instance + resource_path: + type: string + required: + - resource_type + forked_from: + type: object + description: Fork origin info with schema snapshot + properties: + schema: + type: object + description: Schema snapshot at fork time + additionalProperties: true + deploy_ui: + type: object + properties: &ref_49 + include_path: + type: array + items: + type: string + include_type: + type: array + items: + type: string + enum: &ref_48 + - script + - flow + - app + - folder + - resource + - variable + - secret + - resourcetype + - schedule + - user + - group + - trigger + - settings + - key + - workspacedependencies + mute_critical_alerts: + type: boolean + required: + - workspace_id /w/{workspace}/workspaces/get_settings: get: - summary: get settings + summary: get settings (admin only) + description: >- + Returns the full workspace settings including admin-managed integration + credentials. Admin-only — non-admin callers should use + `getPublicSettings`. operationId: getSettings tags: - workspace @@ -4340,7 +4769,7 @@ paths: auto_invite: type: object description: Configuration for auto-inviting users to the workspace - properties: &ref_358 + properties: &ref_359 enabled: type: boolean default: false @@ -4376,19 +4805,19 @@ paths: type: string ai_config: type: object - properties: &ref_46 + properties: &ref_50 providers: type: object additionalProperties: type: object - properties: &ref_377 + properties: &ref_378 resource_path: type: string models: type: array items: type: string - required: &ref_378 + required: &ref_379 - resource_path - models default_model: @@ -4398,7 +4827,7 @@ paths: type: string provider: type: string - enum: &ref_47 + enum: &ref_51 - openai - azure_openai - anthropic @@ -4430,7 +4859,7 @@ paths: error_handler: type: object description: Configuration for the workspace error handler - properties: &ref_359 + properties: &ref_360 path: type: string description: Path to the error handler script or flow @@ -4447,7 +4876,7 @@ paths: success_handler: type: object description: Configuration for the workspace success handler - properties: &ref_360 + properties: &ref_361 path: type: string description: Path to the success handler script or flow @@ -4457,61 +4886,12 @@ paths: additionalProperties: true large_file_storage: type: object - properties: &ref_50 - type: - type: string - enum: - - S3Storage - - AzureBlobStorage - - AzureWorkloadIdentity - - S3AwsOidc - - GoogleCloudStorage - s3_resource_path: - type: string - azure_blob_resource_path: - type: string - gcs_resource_path: - type: string - public_resource: - type: boolean - advanced_permissions: - type: array - items: - type: object - properties: &ref_527 - pattern: - type: string - allow: - type: string - required: &ref_528 - - pattern - - allow - secondary_storage: - type: object - additionalProperties: - type: object - properties: - type: - type: string - enum: - - S3Storage - - AzureBlobStorage - - AzureWorkloadIdentity - - S3AwsOidc - - GoogleCloudStorage - s3_resource_path: - type: string - azure_blob_resource_path: - type: string - gcs_resource_path: - type: string - public_resource: - type: boolean + properties: *ref_45 ducklake: type: object - required: &ref_51 + required: &ref_54 - ducklakes - properties: &ref_52 + properties: &ref_55 ducklakes: type: object additionalProperties: @@ -4546,44 +4926,16 @@ paths: type: string datatable: type: object - required: &ref_53 - - datatables - properties: &ref_54 - datatables: - type: object - additionalProperties: - type: object - required: - - database - properties: - database: - type: object - properties: - resource_type: - type: string - enum: - - postgresql - - instance - resource_path: - type: string - required: - - resource_type - forked_from: - type: object - description: Fork origin info with schema snapshot - properties: - schema: - type: object - description: Schema snapshot at fork time - additionalProperties: true + required: *ref_46 + properties: *ref_47 git_sync: type: object - properties: &ref_55 + properties: &ref_56 repositories: type: array items: type: object - properties: &ref_56 + properties: &ref_57 script_path: type: string git_repo_resource_path: @@ -4605,22 +4957,7 @@ paths: type: array items: type: string - enum: &ref_45 - - script - - flow - - app - - folder - - resource - - variable - - secret - - resourcetype - - schedule - - user - - group - - trigger - - settings - - key - - workspacedependencies + enum: *ref_48 exclude_path: type: array items: @@ -4633,21 +4970,12 @@ paths: type: array items: type: string - enum: *ref_45 - required: &ref_57 + enum: *ref_48 + required: &ref_58 - git_repo_resource_path deploy_ui: type: object - properties: &ref_58 - include_path: - type: array - items: - type: string - include_type: - type: array - items: - type: string - enum: *ref_45 + properties: *ref_49 default_app: type: string default_scripts: @@ -4852,7 +5180,7 @@ paths: type: array items: type: object - properties: &ref_506 + properties: &ref_507 importer_path: type: string importer_kind: @@ -4866,7 +5194,7 @@ paths: items: type: string nullable: true - required: &ref_507 + required: &ref_508 - importer_path - importer_kind /w/{workspace}/workspaces/get_imports/{importer_path}: @@ -4924,13 +5252,13 @@ paths: type: array items: type: object - properties: &ref_508 + properties: &ref_509 imported_path: type: string count: type: integer format: int64 - required: &ref_509 + required: &ref_510 - imported_path - count /w/{workspace}/workspaces/get_dependency_map: @@ -4953,7 +5281,7 @@ paths: type: array items: type: object - properties: &ref_505 + properties: &ref_506 workspace_id: type: string nullable: true @@ -5220,6 +5548,39 @@ paths: text/plain: schema: type: string + /w/{workspace}/workspaces/connect_slack: + post: + summary: connect slack (non-interactive; pre-minted bot token) + operationId: connectSlack + tags: + - workspace + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + requestBody: + description: connect slack with a pre-minted bot token + required: true + content: + application/json: + schema: + type: object + required: + - bot_token + - team_id + - team_name + properties: + bot_token: + type: string + description: xoxb-... bot token obtained at api.slack.com/apps + team_id: + type: string + team_name: + type: string + responses: + '200': + description: status /w/{workspace}/workspaces/run_slack_message_test_job: post: summary: run a job that sends a message to Slack @@ -5429,7 +5790,7 @@ paths: application/json: schema: type: object - properties: *ref_46 + properties: *ref_50 responses: '200': description: status @@ -5440,27 +5801,27 @@ paths: properties: effective_ai_config: type: object - properties: *ref_46 + properties: *ref_50 has_instance_ai_config: type: boolean uses_instance_ai_config: type: boolean instance_ai_summary: type: object - properties: &ref_48 + properties: &ref_52 providers: type: array items: type: object - properties: &ref_379 + properties: &ref_380 provider: type: string - enum: *ref_47 + enum: *ref_51 models: type: array items: type: string - required: &ref_380 + required: &ref_381 - provider - models default_model: @@ -5471,7 +5832,7 @@ paths: type: object properties: *ref_43 required: *ref_44 - required: &ref_49 + required: &ref_53 - providers required: - effective_ai_config @@ -5502,8 +5863,8 @@ paths: type: boolean instance_ai_summary: type: object - properties: *ref_48 - required: *ref_49 + properties: *ref_52 + required: *ref_53 required: - has_instance_ai_config - uses_instance_ai_config @@ -5525,7 +5886,7 @@ paths: application/json: schema: type: object - properties: *ref_46 + properties: *ref_50 /w/{workspace}/workspaces/edit_error_handler: post: summary: edit error handler @@ -5547,10 +5908,10 @@ paths: Request body for editing the workspace error handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: &ref_361 + oneOf: &ref_362 - type: object description: New grouped format for editing error handler - properties: &ref_362 + properties: &ref_363 path: type: string description: Path to the error handler script or flow @@ -5568,7 +5929,7 @@ paths: description: >- Legacy flat format for editing error handler (deprecated, use new format) - properties: &ref_363 + properties: &ref_364 error_handler: type: string description: Path to the error handler script or flow @@ -5607,10 +5968,10 @@ paths: Request body for editing the workspace success handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: &ref_364 + oneOf: &ref_365 - type: object description: New grouped format for editing success handler - properties: &ref_365 + properties: &ref_366 path: type: string description: Path to the success handler script or flow @@ -5622,7 +5983,7 @@ paths: description: >- Legacy flat format for editing success handler (deprecated, use new format) - properties: &ref_366 + properties: &ref_367 success_handler: type: string description: Path to the success handler script or flow @@ -5658,7 +6019,7 @@ paths: properties: large_file_storage: type: object - properties: *ref_50 + properties: *ref_45 responses: '200': description: status @@ -5764,6 +6125,92 @@ paths: nullable error: type: string + /w/{workspace}/workspaces/list_datatable_tables: + get: + summary: list tables of all connected Datatables + operationId: listDataTableTables + tags: + - workspace + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + responses: + '200': + description: table metadata of all datatables + content: + application/json: + schema: + type: array + items: + type: object + required: &ref_525 + - datatable_name + - schemas + properties: &ref_526 + datatable_name: + type: string + schemas: + type: object + description: 'Hierarchical metadata: schema_name -> table_names' + additionalProperties: + type: array + items: + type: string + error: + type: string + /w/{workspace}/workspaces/get_datatable_table_schema: + get: + summary: get one Datatable table schema + operationId: getDataTableTableSchema + tags: + - workspace + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + - name: datatable_name + in: query + required: true + schema: + type: string + - name: schema_name + in: query + required: true + schema: + type: string + - name: table_name + in: query + required: true + schema: + type: string + responses: + '200': + description: schema of one datatable table + content: + application/json: + schema: + type: object + required: &ref_527 + - datatable_name + - schema_name + - table_name + - columns + properties: &ref_528 + datatable_name: + type: string + schema_name: + type: string + table_name: + type: string + columns: + type: object + description: 'Columns in this table: column_name -> compact_type' + additionalProperties: + type: string + description: 'Compact type: ''type[?][=default]'' where ? means nullable' /w/{workspace}/workspaces/edit_ducklake_config: post: summary: edit ducklake settings @@ -5787,8 +6234,8 @@ paths: properties: settings: type: object - required: *ref_51 - properties: *ref_52 + required: *ref_54 + properties: *ref_55 responses: '200': description: status @@ -5818,8 +6265,8 @@ paths: properties: settings: type: object - required: *ref_53 - properties: *ref_54 + required: *ref_46 + properties: *ref_47 responses: '200': description: status @@ -6113,7 +6560,7 @@ paths: properties: git_sync_settings: type: object - properties: *ref_55 + properties: *ref_56 responses: '200': description: status @@ -6144,8 +6591,8 @@ paths: description: The resource path of the git repository to update repository: type: object - properties: *ref_56 - required: *ref_57 + properties: *ref_57 + required: *ref_58 required: - git_repo_resource_path - repository @@ -6206,7 +6653,7 @@ paths: properties: deploy_ui_settings: type: object - properties: *ref_58 + properties: *ref_49 responses: '200': description: status @@ -6512,7 +6959,7 @@ paths: type: array items: type: object - properties: &ref_395 + properties: &ref_396 email: type: string executions: @@ -6615,7 +7062,7 @@ paths: application/json: schema: type: object - properties: &ref_414 + properties: &ref_415 label: type: string expiration: @@ -6627,6 +7074,15 @@ paths: type: string workspace_id: type: string + read_only: + type: boolean + description: > + If true, the token is restricted to read-only HTTP methods + + (GET/HEAD/OPTIONS). Mutating endpoints and job-run actions + are + + rejected with 403, regardless of the scopes attached. responses: '201': description: token created @@ -6647,7 +7103,7 @@ paths: application/json: schema: type: object - properties: &ref_415 + properties: &ref_416 label: type: string expiration: @@ -6657,7 +7113,7 @@ paths: type: string workspace_id: type: string - required: &ref_416 + required: &ref_417 - impersonate_email responses: '201': @@ -6685,6 +7141,38 @@ paths: text/plain: schema: type: string + /users/tokens/update_scopes/{token_prefix}: + post: + summary: update scopes of an existing token (owner only) + operationId: updateTokenScopes + tags: + - user + parameters: + - name: token_prefix + in: path + required: true + schema: + type: string + requestBody: + description: new scopes (null or omitted = full access) + required: true + content: + application/json: + schema: + type: object + properties: + scopes: + type: array + items: + type: string + nullable: true + responses: + '200': + description: scopes updated + content: + text/plain: + schema: + type: string /users/tokens/list: get: summary: list token @@ -6733,10 +7221,15 @@ paths: type: string email: type: string + workspace_id: + type: string + read_only: + type: boolean required: &ref_103 - token_prefix - created_at - last_used_at + - read_only /w/{workspace}/oidc/token/{audience}: post: summary: get OIDC token (ee only) @@ -6788,7 +7281,7 @@ paths: application/json: schema: type: object - properties: &ref_419 + properties: &ref_420 path: type: string description: The path to the variable @@ -6815,7 +7308,9 @@ paths: type: array items: type: string - required: &ref_420 + ws_specific: + type: boolean + required: &ref_421 - path - value - is_secret @@ -6937,7 +7432,7 @@ paths: application/json: schema: type: object - properties: &ref_421 + properties: &ref_422 path: type: string description: The path to the variable @@ -6954,6 +7449,8 @@ paths: type: array items: type: string + ws_specific: + type: boolean responses: '200': description: variable updated @@ -7032,6 +7529,13 @@ paths: type: array items: type: string + ws_specific: + type: boolean + edited_at: + type: string + format: date-time + edited_by: + type: string required: &ref_62 - workspace_id - path @@ -7173,7 +7677,7 @@ paths: type: array items: type: object - properties: &ref_417 + properties: &ref_418 name: type: string value: @@ -7182,7 +7686,7 @@ paths: type: string is_custom: type: boolean - required: &ref_418 + required: &ref_419 - name - value - description @@ -7369,12 +7873,12 @@ paths: description: >- A workspace protection rule defining restrictions and bypass permissions - required: &ref_561 + required: &ref_565 - name - rules - bypass_groups - bypass_users - properties: &ref_562 + properties: &ref_566 name: type: string description: Unique name for the protection rule @@ -7386,7 +7890,7 @@ paths: description: Configuration of protection restrictions items: &ref_64 type: string - enum: &ref_563 + enum: &ref_567 - DisableDirectDeployment - DisableWorkspaceForking - RestrictDeployToDeployers @@ -7546,11 +8050,11 @@ paths: type: array items: type: object - required: &ref_564 + required: &ref_568 - username - email - is_admin - properties: &ref_565 + properties: &ref_569 username: type: string email: @@ -7605,10 +8109,10 @@ paths: type: array items: type: object - required: &ref_566 + required: &ref_570 - username - email - properties: &ref_567 + properties: &ref_571 username: type: string email: @@ -7915,6 +8419,67 @@ paths: type: integer required: - pruned + /w/{workspace}/workspaces/list_ws_specific: + get: + summary: list all workspace-specific items + operationId: listWsSpecific + tags: + - workspace + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + responses: + '200': + description: list of workspace-specific items + content: + application/json: + schema: + type: array + items: + type: object + properties: + item_kind: + type: string + path: + type: string + required: + - item_kind + - path + /w/{workspace}/workspaces/list_ws_specific_versions: + get: + summary: list workspace ids that have a version of the given item + operationId: listWsSpecificVersions + tags: + - workspace + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + - name: kind + in: query + required: true + schema: + type: string + enum: + - resource + - variable + - name: path + in: query + required: true + schema: + type: string + responses: + '200': + description: list of workspace ids that have a version of the item + content: + application/json: + schema: + type: array + items: + type: string /w/{workspace}/workspaces/public_app_rate_limit: post: summary: Set public app rate limit for this workspace @@ -8050,6 +8615,34 @@ paths: text/plain: schema: type: string + /oauth/connect_slack_instance: + post: + summary: connect slack instance (non-interactive; pre-minted bot token) + operationId: connectSlackInstance + tags: + - oauth + requestBody: + description: connect slack at the instance level with a pre-minted bot token + required: true + content: + application/json: + schema: + type: object + required: + - bot_token + - team_id + - team_name + properties: + bot_token: + type: string + description: xoxb-... bot token obtained at api.slack.com/apps + team_id: + type: string + team_name: + type: string + responses: + '200': + description: status /oauth/connect_callback/{client_name}: post: summary: connect callback @@ -8332,6 +8925,11 @@ paths: - type saml: type: string + auto_login: + type: string + description: >- + provider type to auto-redirect to on login (oauth key or + "saml") required: - oauth /oauth/list_connects: @@ -8437,7 +9035,7 @@ paths: application/json: schema: type: object - properties: &ref_426 + properties: &ref_427 path: type: string description: The path to the resource @@ -8452,7 +9050,9 @@ paths: type: array items: type: string - required: &ref_427 + ws_specific: + type: boolean + required: &ref_428 - path - value - resource_type @@ -8543,7 +9143,7 @@ paths: application/json: schema: type: object - properties: &ref_428 + properties: &ref_429 path: type: string description: The path to the resource @@ -8558,6 +9158,8 @@ paths: type: array items: type: string + ws_specific: + type: boolean responses: '200': description: resource updated @@ -8619,7 +9221,7 @@ paths: application/json: schema: type: object - properties: &ref_429 + properties: &ref_430 workspace_id: type: string path: @@ -8644,7 +9246,9 @@ paths: type: array items: type: string - required: &ref_430 + ws_specific: + type: boolean + required: &ref_431 - path - resource_type - is_oauth @@ -8827,7 +9431,7 @@ paths: type: array items: type: object - properties: &ref_431 + properties: &ref_432 workspace_id: type: string path: @@ -8862,7 +9466,9 @@ paths: type: array items: type: string - required: &ref_432 + ws_specific: + type: boolean + required: &ref_433 - path - resource_type - is_oauth @@ -8943,7 +9549,7 @@ paths: - name: name in: path required: true - schema: &ref_272 + schema: &ref_273 type: string responses: '200': @@ -9076,7 +9682,7 @@ paths: application/json: schema: type: object - properties: &ref_433 + properties: &ref_434 schema: {} description: type: string @@ -9465,7 +10071,7 @@ paths: description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: &ref_606 + properties: &ref_610 modules: type: array description: >- @@ -9497,7 +10103,7 @@ paths: in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms - properties: &ref_322 + properties: &ref_323 input_transforms: type: object description: >- @@ -9675,7 +10281,7 @@ paths: - r - w - rw - required: &ref_323 + required: &ref_324 - type - content - language @@ -9685,7 +10291,7 @@ paths: Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code - properties: &ref_324 + properties: &ref_325 input_transforms: type: object description: >- @@ -9725,7 +10331,7 @@ paths: description: >- If true, this script is a trigger that can start the flow - required: &ref_325 + required: &ref_326 - type - path - input_transforms @@ -9734,7 +10340,7 @@ paths: Reference to an existing flow by path. Use this to call another flow as a subflow - properties: &ref_326 + properties: &ref_327 input_transforms: type: object description: >- @@ -9759,7 +10365,7 @@ paths: type: string enum: - flow - required: &ref_327 + required: &ref_328 - type - path - input_transforms @@ -9772,7 +10378,7 @@ paths: 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations - properties: &ref_328 + properties: &ref_329 modules: type: array description: >- @@ -9821,7 +10427,7 @@ paths: discriminator: *ref_81 squash: type: boolean - required: &ref_329 + required: &ref_330 - modules - iterator - skip_failures @@ -9833,7 +10439,7 @@ paths: condition after each iteration. Use stop_after_if on modules to control loop termination - properties: &ref_330 + properties: &ref_331 modules: type: array description: >- @@ -9871,7 +10477,7 @@ paths: discriminator: *ref_81 squash: type: boolean - required: &ref_331 + required: &ref_332 - modules - skip_failures - type @@ -9883,7 +10489,7 @@ paths: one with a true expression runs. If no branches match, the default branch executes - properties: &ref_332 + properties: &ref_333 branches: type: array description: >- @@ -9935,7 +10541,7 @@ paths: type: string enum: - branchone - required: &ref_333 + required: &ref_334 - branches - default - type @@ -9946,7 +10552,7 @@ paths: BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently - properties: &ref_334 + properties: &ref_335 branches: type: array description: >- @@ -9987,7 +10593,7 @@ paths: If true, all branches execute concurrently. If false, they execute sequentially - required: &ref_335 + required: &ref_336 - branches - type - type: object @@ -9995,7 +10601,7 @@ paths: Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder - properties: &ref_336 + properties: &ref_337 type: type: string enum: @@ -10005,7 +10611,7 @@ paths: description: >- If true, marks this as a flow identity (special handling) - required: &ref_337 + required: &ref_338 - type - type: object description: >- @@ -10013,7 +10619,7 @@ paths: accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task - properties: &ref_338 + properties: &ref_339 input_transforms: type: object description: >- @@ -10025,22 +10631,22 @@ paths: Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined - oneOf: &ref_340 + oneOf: &ref_341 - type: object description: >- Static provider configuration passed directly to the AI agent - properties: &ref_591 + properties: &ref_595 value: type: object description: >- Complete AI provider configuration with resource reference and model selection - properties: &ref_589 + properties: &ref_593 kind: type: string description: Supported AI provider types - enum: &ref_315 + enum: &ref_316 - openai - azure_openai - anthropic @@ -10063,7 +10669,7 @@ paths: description: >- Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro') - required: &ref_590 + required: &ref_594 - kind - resource - model @@ -10071,7 +10677,7 @@ paths: type: string enum: - static - required: &ref_592 + required: &ref_596 - type - value - type: object @@ -10091,7 +10697,7 @@ paths: satisfy the parameter. properties: *ref_86 required: *ref_87 - discriminator: &ref_341 + discriminator: &ref_342 propertyName: type mapping: static: >- @@ -10161,27 +10767,27 @@ paths: Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined - oneOf: &ref_342 + oneOf: &ref_343 - type: object description: >- Static memory configuration passed directly to the AI agent - properties: &ref_597 + properties: &ref_601 value: description: Conversation memory configuration - oneOf: &ref_595 + oneOf: &ref_599 - type: object description: No conversation memory/context - properties: &ref_316 + properties: &ref_317 kind: type: string enum: - 'off' - required: &ref_317 + required: &ref_318 - kind - type: object description: Automatic context management - properties: &ref_318 + properties: &ref_319 kind: type: string enum: @@ -10196,11 +10802,11 @@ paths: description: >- Identifier for persistent memory across agent invocations - required: &ref_319 + required: &ref_320 - kind - type: object description: Explicit message history - properties: &ref_320 + properties: &ref_321 kind: type: string enum: @@ -10210,7 +10816,7 @@ paths: items: type: object description: A single message in conversation history - properties: &ref_593 + properties: &ref_597 role: type: string enum: @@ -10219,13 +10825,13 @@ paths: - system content: type: string - required: &ref_594 + required: &ref_598 - role - content - required: &ref_321 + required: &ref_322 - kind - messages - discriminator: &ref_596 + discriminator: &ref_600 propertyName: kind mapping: 'off': '#/components/schemas/MemoryOff' @@ -10235,7 +10841,7 @@ paths: type: string enum: - static - required: &ref_598 + required: &ref_602 - type - value - type: object @@ -10255,7 +10861,7 @@ paths: satisfy the parameter. properties: *ref_86 required: *ref_87 - discriminator: &ref_343 + discriminator: &ref_344 propertyName: type mapping: static: >- @@ -10338,6 +10944,20 @@ paths: - 0.7 = balanced (common default) - 1.0+ = more creative/random + max_iterations: + allOf: + - 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_80 + discriminator: *ref_81 + description: > + Number. Limits how many times the agent + can loop through reasoning and tool use. + + Range: 1-1000. required: - provider - user_message @@ -10354,7 +10974,7 @@ paths: A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool - properties: &ref_344 + properties: &ref_345 id: type: string description: >- @@ -10372,12 +10992,12 @@ paths: The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference - oneOf: &ref_604 + oneOf: &ref_608 - description: >- A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module - allOf: &ref_599 + allOf: &ref_603 - type: object properties: tool_type: @@ -10410,7 +11030,7 @@ paths: Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers - properties: &ref_600 + properties: &ref_604 tool_type: type: string enum: @@ -10434,7 +11054,7 @@ paths: MCP server items: type: string - required: &ref_601 + required: &ref_605 - tool_type - resource_path - type: object @@ -10442,32 +11062,40 @@ paths: A tool implemented as a websearch tool. The AI can call this like any other websearch tool - properties: &ref_602 + properties: &ref_606 tool_type: type: string enum: - websearch - required: &ref_603 + required: &ref_607 - tool_type - discriminator: &ref_605 + discriminator: &ref_609 propertyName: tool_type mapping: flowmodule: '#/components/schemas/FlowModuleTool' mcp: '#/components/schemas/McpToolValue' websearch: '#/components/schemas/WebsearchToolValue' - required: &ref_345 + required: &ref_346 - id - value type: type: string enum: - aiagent + omit_output_from_conversation: + type: boolean + default: false + description: >- + If true, this AI agent step does not + persist its assistant or tool messages + to the flow conversation when chat mode + is enabled. parallel: type: boolean description: >- If true, the agent can execute multiple tool calls in parallel - required: &ref_339 + required: &ref_340 - tools - type - input_transforms @@ -10631,7 +11259,7 @@ paths: Retry configuration for failed module executions type: object - properties: &ref_314 + properties: &ref_315 constant: type: object description: >- @@ -10672,14 +11300,14 @@ paths: description: >- Conditional retry based on error or result - properties: &ref_194 + properties: &ref_195 expr: type: string description: >- JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables - required: &ref_195 + required: &ref_196 - expr debouncing: description: >- @@ -10921,7 +11549,7 @@ paths: required: &ref_148 - start_id - end_id - required: &ref_607 + required: &ref_611 - modules schema: type: object @@ -11822,6 +12450,11 @@ paths: /w/{workspace}/scripts/create: post: summary: create script + description: > + Creates a new script when the path does not already exist. + + Creates a new version of an existing script when called with the same + path and the current `parent_hash`. operationId: createScript x-mcp-tool: true x-mcp-instructions: >- @@ -11955,7 +12588,7 @@ paths: type: string kind: type: string - enum: &ref_300 + enum: &ref_301 - s3object - resource - ducklake @@ -12119,7 +12752,7 @@ paths: application/json: schema: type: object - properties: &ref_384 + properties: &ref_385 workspace_id: type: string language: @@ -12131,7 +12764,7 @@ paths: type: string content: type: string - required: &ref_385 + required: &ref_386 - workspace_id - language - content @@ -12545,7 +13178,7 @@ paths: content: application/json: schema: - allOf: &ref_386 + allOf: &ref_387 - type: object properties: *ref_104 required: *ref_105 @@ -12555,6 +13188,13 @@ paths: type: object properties: *ref_104 required: *ref_105 + draft_created_at: + type: string + format: date-time + description: >- + Timestamp at which the most recent DB draft was + created. Used by the frontend's UserDraft staleness + check. hash: type: string required: @@ -12768,7 +13408,7 @@ paths: - name: token in: path required: true - schema: &ref_304 + schema: &ref_305 type: string - name: path in: path @@ -14346,7 +14986,7 @@ paths: properties: *ref_120 required: *ref_121 - type: object - properties: &ref_511 + properties: &ref_512 workspace_id: type: string path: @@ -14360,7 +15000,7 @@ paths: type: boolean extra_perms: type: object - additionalProperties: &ref_510 + additionalProperties: &ref_511 type: boolean starred: type: boolean @@ -14385,7 +15025,7 @@ paths: items: type: string default: [] - required: &ref_512 + required: &ref_513 - path - edited_by - edited_at @@ -14719,6 +15359,13 @@ paths: properties: draft: allOf: *ref_124 + draft_created_at: + type: string + format: date-time + description: >- + Timestamp at which the most recent DB draft was + created. Used by the frontend's UserDraft staleness + check. /w/{workspace}/flows/exists/{path}: get: summary: exists flow by path @@ -14960,14 +15607,14 @@ paths: type: array items: type: object - required: &ref_371 + required: &ref_372 - id - workspace_id - flow_path - created_at - updated_at - created_by - properties: &ref_372 + properties: &ref_373 id: type: string format: uuid @@ -15044,13 +15691,13 @@ paths: schema: type: string format: uuid - - name: after_id - description: id to fetch only the messages after that id + - name: after_seq + description: Message sequence cursor to fetch only the messages after that cursor in: query required: false schema: - type: string - format: uuid + type: integer + format: int64 responses: '200': description: conversation messages @@ -15060,13 +15707,14 @@ paths: type: array items: type: object - required: &ref_373 + required: &ref_374 - id - conversation_id - message_type - content - created_at - properties: &ref_374 + - created_seq + properties: &ref_375 id: type: string format: uuid @@ -15095,6 +15743,10 @@ paths: type: string format: date-time description: When the message was created + created_seq: + type: integer + format: int64 + description: Monotonic cursor assigned when the message is inserted step_name: type: string description: The step name that produced that message @@ -15123,6 +15775,15 @@ paths: in: path required: true schema: *ref_4 + - name: force + description: > + bypass the server-side cache and re-query the DB, refreshing the + + cache. Used right after a deploy so the new path appears + immediately. + in: query + schema: + type: boolean responses: '200': description: deduplicated path list, sorted lexicographically @@ -15229,6 +15890,139 @@ paths: - extra_perms - version - edited_at + /w/{workspace}/shared_ui/get: + get: + summary: get the workspace shared UI folder (full content) + operationId: getSharedUi + tags: + - workspace + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + responses: + '200': + description: shared UI content + content: + application/json: + schema: + type: object + required: + - files + - version + - edited_at + - edited_by + properties: + files: + type: object + additionalProperties: + type: string + version: + type: integer + format: int64 + edited_at: + type: string + format: date-time + edited_by: + type: string + /w/{workspace}/shared_ui/list: + get: + summary: list paths/sizes of the workspace shared UI folder + operationId: listSharedUi + tags: + - workspace + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + responses: + '200': + description: shared UI listing + content: + application/json: + schema: + type: object + required: + - paths + - sizes + - version + - edited_at + - edited_by + properties: + paths: + type: array + items: + type: string + sizes: + type: object + additionalProperties: + type: integer + format: int64 + version: + type: integer + format: int64 + edited_at: + type: string + format: date-time + edited_by: + type: string + /w/{workspace}/shared_ui/version: + get: + summary: get the current version of the workspace shared UI folder + operationId: getSharedUiVersion + tags: + - workspace + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + responses: + '200': + description: shared UI version + content: + application/json: + schema: + type: object + required: + - version + properties: + version: + type: integer + format: int64 + /w/{workspace}/shared_ui: + put: + summary: replace the workspace shared UI folder (admin only) + operationId: updateSharedUi + tags: + - workspace + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - files + properties: + files: + type: object + additionalProperties: + type: string + responses: + '200': + description: updated + content: + text/plain: + schema: + type: string /w/{workspace}/apps/get_data/v/{secretWithExtension}: get: summary: get raw app data by @@ -15629,6 +16423,13 @@ paths: draft_only: type: boolean draft: {} + draft_created_at: + type: string + format: date-time + description: >- + Timestamp at which the most recent DB draft was + created. Used by the frontend's UserDraft staleness + check. /w/{workspace}/apps/history/p/{path}: get: summary: get app history by path @@ -15730,7 +16531,7 @@ paths: - name: version in: path required: true - schema: &ref_305 + schema: &ref_306 type: integer requestBody: description: App deployment message @@ -16118,6 +16919,8 @@ paths: type: string cache_ttl: type: integer + tag: + type: string required: - content - language @@ -16131,9 +16934,25 @@ paths: type: array items: type: string + force_viewer_sensitive_inputs: + type: array + items: + type: string + force_viewer_delete_after_secs: + type: integer run_query_params: type: object description: Runnable query parameters + temp_script_refs: + type: object + nullable: true + description: >- + Map of relative-import script path -> temp storage hash. + Only honored for inline-script (raw_code) execution so app + dev resolves those imports from not-yet-deployed local + content. + additionalProperties: + type: string required: - args - component @@ -16526,7 +17345,7 @@ paths: - name: id in: path required: true - schema: &ref_171 + schema: &ref_172 type: string format: uuid - name: scheduled_for @@ -16584,17 +17403,40 @@ paths: properties: step_id: type: string - description: step id to restart the flow from + description: >- + top-level step id to restart the flow from (or the outermost + container when restarting at a nested step) branch_or_iteration_n: type: integer description: >- - for branchall or loop, the iteration at which the flow - should restart (optional) + for branchall or loop at the top level, the iteration at + which the flow should restart (optional) flow_version: type: integer description: >- specific flow version to use for restart (optional, uses current version if not specified) + nested_path: + type: array + description: >- + path of additional steps to descend into AFTER `step_id`. + Each entry represents one level of nesting inside the + spawned child of the previous level's container (BranchOne / + sequential ForLoop iteration / Subflow). When non-empty, the + actual restart point is the LAST entry's step_id. + items: + type: object + required: + - step_id + properties: + step_id: + type: string + description: step id at this nesting level + branch_or_iteration_n: + type: integer + description: >- + for ForLoop containers, the iteration to restart at + (0-based; iterations 0..n-1 are preserved) responses: '201': description: job created @@ -16766,6 +17608,15 @@ paths: description: An additional module file associated with a script properties: *ref_95 required: *ref_96 + temp_script_refs: + type: object + nullable: true + description: >- + Map of relative-import script path -> temp storage hash so + the preview job resolves those imports from not-yet-deployed + local content instead of the deployed script + additionalProperties: + type: string required: &ref_142 - args responses: @@ -16794,7 +17645,7 @@ paths: application/json: schema: type: object - properties: &ref_422 + properties: &ref_423 content: type: string description: The code to run @@ -16805,7 +17656,7 @@ paths: language: type: string enum: *ref_94 - required: &ref_423 + required: &ref_424 - content - args - language @@ -16940,12 +17791,12 @@ paths: application/json: schema: type: object - properties: &ref_424 + properties: &ref_425 args: type: object description: The arguments to pass to the script or flow additionalProperties: true - required: &ref_425 + required: &ref_426 - args responses: '201': @@ -17248,7 +18099,7 @@ paths: application/json: schema: type: object - properties: &ref_151 + properties: &ref_152 value: type: object description: >- @@ -17266,7 +18117,7 @@ paths: type: string restarted_from: type: object - properties: &ref_513 + properties: &ref_151 flow_job_id: type: string format: uuid @@ -17274,9 +18125,43 @@ paths: type: string branch_or_iteration_n: type: integer + description: >- + 0-based iteration index for ForLoop / branch index for + BranchAll. Iterations 0..n-1 are preserved; iteration n + is restarted. flow_version: type: integer - required: &ref_152 + branch_chosen: + description: >- + For BranchOne nested restart — the branch that was + originally chosen, used to lock branch evaluation. + type: object + properties: + type: + type: string + enum: + - default + - branch + branch: + type: integer + nested: + description: >- + When set, the worker spawns the child for `step_id` as a + `RestartedFlow` against `nested.flow_job_id` instead of + fresh-launching it. + type: object + properties: *ref_151 + temp_script_refs: + type: object + nullable: true + description: >- + Map of relative-import script path -> temp storage hash, + propagated to each flow step so inline-script relative + imports resolve from not-yet-deployed local content instead + of the deployed script + additionalProperties: + type: string + required: &ref_153 - value - content - args @@ -17312,8 +18197,8 @@ paths: application/json: schema: type: object - properties: *ref_151 - required: *ref_152 + properties: *ref_152 + required: *ref_153 responses: '200': description: job result @@ -17338,7 +18223,7 @@ paths: application/json: schema: type: object - properties: &ref_525 + properties: &ref_529 entrypoint_function: type: string description: Name of the function to execute for dynamic select @@ -17359,7 +18244,7 @@ paths: description: Path to the deployed script or flow runnable_kind: type: string - enum: &ref_199 + enum: &ref_200 - script - flow required: @@ -17381,7 +18266,7 @@ paths: required: - source - code - required: &ref_526 + required: &ref_530 - entrypoint_function - runnable_ref responses: @@ -17427,7 +18312,7 @@ paths: (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: &ref_155 + schema: &ref_156 type: string - name: script_path_exact description: >- @@ -17435,7 +18320,7 @@ paths: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: &ref_156 + schema: &ref_157 type: string - name: script_path_start description: >- @@ -17443,12 +18328,12 @@ paths: 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: &ref_157 + schema: &ref_158 type: string - name: schedule_path description: mask to filter by schedule path in: query - schema: &ref_158 + schema: &ref_159 type: string - name: trigger_path description: >- @@ -17456,7 +18341,7 @@ paths: 'f/trigger1,f/trigger2') and negation by prefixing all values with '!' (e.g. '!f/trigger1,!f/trigger2') in: query - schema: &ref_306 + schema: &ref_307 type: string - name: trigger_kind description: >- @@ -17465,34 +18350,34 @@ paths: (e.g. '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: &ref_187 + schema: &ref_188 type: string - name: script_hash description: mask to filter exact matching path in: query - schema: &ref_159 + schema: &ref_160 type: string - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: &ref_160 + schema: &ref_161 type: string format: date-time - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: &ref_161 + schema: &ref_162 type: string format: date-time - name: success description: filter on successful jobs in: query - schema: &ref_169 + schema: &ref_170 type: boolean - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: &ref_163 + schema: &ref_164 type: boolean - name: job_kinds description: >- @@ -17500,36 +18385,36 @@ paths: ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: &ref_164 + schema: &ref_165 type: string - name: suspended description: filter on suspended jobs in: query - schema: &ref_165 + schema: &ref_166 type: boolean - name: running description: filter on running jobs in: query - schema: &ref_162 + schema: &ref_163 type: boolean - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: &ref_166 + schema: &ref_167 type: string - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: &ref_168 + schema: &ref_169 type: string - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: &ref_170 + schema: &ref_171 type: boolean - name: tag description: >- @@ -17537,7 +18422,7 @@ paths: 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: &ref_167 + schema: &ref_168 type: string - name: page description: which page to return (start at 1, default 1) @@ -17568,7 +18453,7 @@ paths: type: array items: type: object - properties: &ref_190 + properties: &ref_191 workspace_id: type: string id: @@ -17643,14 +18528,14 @@ paths: by extension its DT_TOKEN. flow_status: type: object - properties: &ref_174 + properties: &ref_175 step: type: integer modules: type: array items: type: object - properties: &ref_153 + properties: &ref_154 type: type: string enum: @@ -17804,20 +18689,20 @@ paths: type: array items: type: boolean - required: &ref_154 + required: &ref_155 - type user_states: additionalProperties: true preprocessor_module: allOf: - type: object - properties: *ref_153 - required: *ref_154 + properties: *ref_154 + required: *ref_155 failure_module: allOf: - type: object - properties: *ref_153 - required: *ref_154 + properties: *ref_154 + required: *ref_155 - type: object properties: parent_module: @@ -17832,13 +18717,13 @@ paths: items: type: string format: uuid - required: &ref_175 + required: &ref_176 - step - modules - failure_module workflow_as_code_status: type: object - properties: &ref_176 + properties: &ref_177 scheduled_for: type: string format: date-time @@ -17881,7 +18766,7 @@ paths: type: boolean worker: type: string - required: &ref_191 + required: &ref_192 - id - running - canceled @@ -18004,7 +18889,7 @@ paths: (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: &ref_173 + schema: &ref_174 type: string - name: worker description: >- @@ -18012,7 +18897,7 @@ paths: (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_155 + schema: *ref_156 - name: parent_job description: >- The parent job that is at the origin and responsible for the @@ -18025,104 +18910,104 @@ paths: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_156 + schema: *ref_157 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_157 + schema: *ref_158 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_158 + schema: *ref_159 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_159 + schema: *ref_160 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_160 + schema: *ref_161 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_161 + schema: *ref_162 - name: created_before description: filter on created before (inclusive) timestamp in: query - schema: &ref_181 + schema: &ref_182 type: string format: date-time - name: created_after description: filter on created after (exclusive) timestamp in: query - schema: &ref_182 + schema: &ref_183 type: string format: date-time - name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: &ref_183 + schema: &ref_184 type: string format: date-time - name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: &ref_184 + schema: &ref_185 type: string format: date-time - name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: &ref_185 + schema: &ref_186 type: string format: date-time - name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: &ref_186 + schema: &ref_187 type: string format: date-time - name: running description: filter on running jobs in: query - schema: *ref_162 + schema: *ref_163 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_163 + schema: *ref_164 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_164 + schema: *ref_165 - name: suspended description: filter on suspended jobs in: query - schema: *ref_165 + schema: *ref_166 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_166 + schema: *ref_167 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_167 + schema: *ref_168 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_168 + schema: *ref_169 - name: page description: which page to return (start at 1, default 1) in: query @@ -18206,76 +19091,76 @@ paths: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_156 + schema: *ref_157 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_157 + schema: *ref_158 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_158 + schema: *ref_159 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_159 + schema: *ref_160 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_160 + schema: *ref_161 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_161 + schema: *ref_162 - name: success description: filter on successful jobs in: query - schema: *ref_169 + schema: *ref_170 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_163 + schema: *ref_164 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_164 + schema: *ref_165 - name: suspended description: filter on suspended jobs in: query - schema: *ref_165 + schema: *ref_166 - name: running description: filter on running jobs in: query - schema: *ref_162 + schema: *ref_163 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_166 + schema: *ref_167 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_168 + schema: *ref_169 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_170 + schema: *ref_171 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_167 + schema: *ref_168 - name: page description: which page to return (start at 1, default 1) in: query @@ -18361,7 +19246,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: list of OTEL Span objects (compatible with OpenTelemetry Span proto) @@ -18389,7 +19274,7 @@ paths: schema: description: job trigger kind (schedule, http, websocket...) type: string - enum: &ref_172 + enum: &ref_173 - webhook - default_email - email @@ -18455,7 +19340,7 @@ paths: schema: description: job trigger kind (schedule, http, websocket...) type: string - enum: *ref_172 + enum: *ref_173 - name: trigger_path description: The path of the trigger (can contain forward slashes) in: path @@ -18516,14 +19401,14 @@ paths: (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_173 + schema: *ref_174 - name: worker description: >- filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_155 + schema: *ref_156 - name: parent_job description: >- The parent job that is at the origin and responsible for the @@ -18536,64 +19421,64 @@ paths: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_156 + schema: *ref_157 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_157 + schema: *ref_158 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_158 + schema: *ref_159 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_159 + schema: *ref_160 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_160 + schema: *ref_161 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_161 + schema: *ref_162 - name: success description: filter on successful jobs in: query - schema: *ref_169 + schema: *ref_170 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_164 + schema: *ref_165 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_166 + schema: *ref_167 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_168 + schema: *ref_169 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_170 + schema: *ref_171 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_167 + schema: *ref_168 - name: page description: which page to return (start at 1, default 1) in: query @@ -18631,7 +19516,7 @@ paths: type: array items: type: object - properties: &ref_188 + properties: &ref_189 workspace_id: type: string id: @@ -18708,11 +19593,11 @@ paths: by extension its DT_TOKEN. flow_status: type: object - properties: *ref_174 - required: *ref_175 + properties: *ref_175 + required: *ref_176 workflow_as_code_status: type: object - properties: *ref_176 + properties: *ref_177 raw_flow: type: object description: >- @@ -18749,7 +19634,7 @@ paths: type: boolean worker: type: string - required: &ref_189 + required: &ref_190 - id - created_by - duration_ms @@ -18793,7 +19678,7 @@ paths: items: type: object description: Completed job with full data for export/import operations - properties: &ref_177 + properties: &ref_178 id: type: string format: uuid @@ -18931,7 +19816,7 @@ paths: status: type: string description: Actual job status from database - required: &ref_178 + required: &ref_179 - id - created_by - created_at @@ -18959,8 +19844,8 @@ paths: items: type: object description: Completed job with full data for export/import operations - properties: *ref_177 - required: *ref_178 + properties: *ref_178 + required: *ref_179 responses: '200': description: Successfully imported completed jobs @@ -18997,7 +19882,7 @@ paths: items: type: object description: Queued job with full data for export/import operations - properties: &ref_179 + properties: &ref_180 id: type: string format: uuid @@ -19128,7 +20013,7 @@ paths: suspend_until: type: string format: date-time - required: &ref_180 + required: &ref_181 - id - created_by - created_at @@ -19156,8 +20041,8 @@ paths: items: type: object description: Queued job with full data for export/import operations - properties: *ref_179 - required: *ref_180 + properties: *ref_180 + required: *ref_181 responses: '200': description: Successfully imported queued jobs @@ -19219,14 +20104,14 @@ paths: (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_173 + schema: *ref_174 - name: worker description: >- filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_155 + schema: *ref_156 - name: parent_job description: >- The parent job that is at the origin and responsible for the @@ -19239,96 +20124,96 @@ paths: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_156 + schema: *ref_157 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_157 + schema: *ref_158 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_158 + schema: *ref_159 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_159 + schema: *ref_160 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_160 + schema: *ref_161 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_161 + schema: *ref_162 - name: created_before description: filter on created before (inclusive) timestamp in: query - schema: *ref_181 + schema: *ref_182 - name: created_after description: filter on created after (exclusive) timestamp in: query - schema: *ref_182 + schema: *ref_183 - name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_183 + schema: *ref_184 - name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_184 + schema: *ref_185 - name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: *ref_185 + schema: *ref_186 - name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: *ref_186 + schema: *ref_187 - name: running description: filter on running jobs in: query - schema: *ref_162 + schema: *ref_163 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_163 + schema: *ref_164 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_164 + schema: *ref_165 - name: suspended description: filter on suspended jobs in: query - schema: *ref_165 + schema: *ref_166 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_166 + schema: *ref_167 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_167 + schema: *ref_168 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_168 + schema: *ref_169 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_170 + schema: *ref_171 - name: per_page description: number of items to return for a given page (default 30, max 100) in: query @@ -19340,7 +20225,7 @@ paths: (e.g. '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: *ref_187 + schema: *ref_188 - name: is_skipped description: is the job skipped in: query @@ -19373,6 +20258,13 @@ paths: in: query schema: type: boolean + - name: excludes_entrypoint_override + description: >- + exclude jobs that were started with a `_ENTRYPOINT_OVERRIDE` arg + (e.g. dynamic-select helper runs and preprocessor previews) + in: query + schema: + type: boolean - name: broad_filter description: >- broad search across multiple fields (case-insensitive substring @@ -19388,11 +20280,11 @@ paths: schema: type: array items: - oneOf: &ref_192 + oneOf: &ref_193 - allOf: - type: object - properties: *ref_188 - required: *ref_189 + properties: *ref_189 + required: *ref_190 - type: object properties: type: @@ -19401,15 +20293,15 @@ paths: - CompletedJob - allOf: - type: object - properties: *ref_190 - required: *ref_191 + properties: *ref_191 + required: *ref_192 - type: object properties: type: type: string enum: - QueuedJob - discriminator: &ref_193 + discriminator: &ref_194 propertyName: type /jobs/db_clock: get: @@ -19477,7 +20369,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: no_logs in: query schema: @@ -19486,14 +20378,22 @@ paths: in: query schema: type: boolean + - name: approval_token + in: query + description: >- + Approval token granting read access to the job when not logged in. + The token must be the one issued for this job's flow (i.e. the flow + id used when generating the approval URL). + schema: + type: string responses: '200': description: job details content: application/json: schema: - oneOf: *ref_192 - discriminator: *ref_193 + oneOf: *ref_193 + discriminator: *ref_194 /w/{workspace}/jobs_u/get_root_job_id/{id}: get: summary: get root job id @@ -19508,7 +20408,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: get root job id @@ -19531,7 +20431,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: remove_ansi_warnings in: query schema: @@ -19557,7 +20457,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: concatenated logs of all flow steps @@ -19579,7 +20479,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: completed job logs tail @@ -19601,7 +20501,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: job args @@ -19652,7 +20552,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: running in: query schema: @@ -19699,11 +20599,11 @@ paths: type: string flow_status: type: object - properties: *ref_174 - required: *ref_175 + properties: *ref_175 + required: *ref_176 workflow_as_code_status: type: object - properties: *ref_176 + properties: *ref_177 /w/{workspace}/jobs_u/getupdate_sse/{id}: get: summary: get job updates via server-sent events @@ -19718,7 +20618,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: running in: query schema: @@ -19791,7 +20691,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: flow debug info details @@ -19812,7 +20712,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: job details @@ -19820,8 +20720,8 @@ paths: application/json: schema: type: object - properties: *ref_188 - required: *ref_189 + properties: *ref_189 + required: *ref_190 /w/{workspace}/jobs_u/completed/get_result/{id}: get: summary: get completed job result @@ -19836,7 +20736,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: suspended_job in: query schema: @@ -19873,10 +20773,10 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: get_started in: query - schema: &ref_312 + schema: &ref_313 type: boolean responses: '200': @@ -19910,7 +20810,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: job timing details @@ -19943,7 +20843,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: job details @@ -19951,8 +20851,8 @@ paths: application/json: schema: type: object - properties: *ref_188 - required: *ref_189 + properties: *ref_189 + required: *ref_190 /w/{workspace}/jobs_u/queue/cancel/{id}: post: summary: cancel queued or running job @@ -19967,7 +20867,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 requestBody: description: reason required: true @@ -20031,7 +20931,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 requestBody: description: reason required: true @@ -20093,7 +20993,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: scheduled for timestamp @@ -20115,7 +21015,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: resume_id in: path required: true @@ -20146,7 +21046,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: resume_id in: path required: true @@ -20196,7 +21096,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: approver in: query schema: @@ -20257,7 +21157,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: approver in: query schema: @@ -20445,7 +21345,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -20488,7 +21388,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: resume_id in: path required: true @@ -20530,7 +21430,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: key in: path required: true @@ -20562,7 +21462,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: key in: path required: true @@ -20588,7 +21488,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 requestBody: required: true content: @@ -20616,7 +21516,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: resume_id in: path required: true @@ -20651,7 +21551,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: resume_id in: path required: true @@ -20693,7 +21593,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: resume_id in: path required: true @@ -20717,8 +21617,8 @@ paths: type: object properties: job: - oneOf: *ref_192 - discriminator: *ref_193 + oneOf: *ref_193 + discriminator: *ref_194 approvers: type: array items: @@ -20793,10 +21693,12 @@ paths: application/json: schema: type: object - properties: &ref_435 + properties: &ref_436 path: type: string - description: The unique path identifier for this schedule + description: >- + The unique Windmill path for this schedule. Must be of the + form `u//` or `f//`. schedule: type: string description: >- @@ -20884,7 +21786,7 @@ paths: nullable: true type: object description: Retry configuration for failed module executions - properties: &ref_196 + properties: &ref_197 constant: type: object description: Retry with constant delay between attempts @@ -20919,8 +21821,8 @@ paths: retry_if: type: object description: Conditional retry based on error or result - properties: *ref_194 - required: *ref_195 + properties: *ref_195 + required: *ref_196 no_flow_overlap: type: boolean description: >- @@ -20973,7 +21875,7 @@ paths: type: array items: type: string - required: &ref_436 + required: &ref_437 - path - schedule - timezone @@ -21017,7 +21919,7 @@ paths: application/json: schema: type: object - properties: &ref_437 + properties: &ref_438 schedule: type: string description: >- @@ -21092,7 +21994,7 @@ paths: nullable: true type: object description: Retry configuration for failed module executions - properties: *ref_196 + properties: *ref_197 no_flow_overlap: type: boolean description: >- @@ -21148,7 +22050,7 @@ paths: type: array items: type: string - required: &ref_438 + required: &ref_439 - schedule - timezone - args @@ -21184,6 +22086,11 @@ paths: properties: enabled: type: boolean + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + schedule in a fork whose parent has the same path enabled. required: - enabled responses: @@ -21239,10 +22146,12 @@ paths: application/json: schema: type: object - properties: &ref_197 + properties: &ref_198 path: type: string - description: The unique path identifier for this schedule + description: >- + The unique Windmill path for this schedule. Must be of the + form `u//` or `f//`. edited_by: type: string description: Username of the last person who edited this schedule @@ -21358,7 +22267,7 @@ paths: nullable: true type: object description: Retry configuration for failed module executions - properties: *ref_196 + properties: *ref_197 summary: type: string nullable: true @@ -21401,7 +22310,7 @@ paths: items: type: string default: [] - required: &ref_198 + required: &ref_199 - path - edited_by - edited_at @@ -21460,7 +22369,7 @@ paths: filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_166 + schema: *ref_167 - name: path description: filter by path (script path) in: query @@ -21513,8 +22422,8 @@ paths: type: array items: type: object - properties: *ref_197 - required: *ref_198 + properties: *ref_198 + required: *ref_199 /w/{workspace}/schedules/list_with_jobs: get: summary: list schedules with last 20 jobs @@ -21542,10 +22451,10 @@ paths: schema: type: array items: - allOf: &ref_434 + allOf: &ref_435 - type: object - properties: *ref_197 - required: *ref_198 + properties: *ref_198 + required: *ref_199 - type: object properties: jobs: @@ -21623,10 +22532,10 @@ paths: application/json: schema: type: object - properties: &ref_200 + properties: &ref_201 info: type: object - properties: &ref_444 + properties: &ref_445 title: type: string version: @@ -21655,28 +22564,28 @@ paths: type: string required: - name - required: &ref_445 + required: &ref_446 - title - version url: type: string openapi_spec_format: type: string - enum: &ref_439 + enum: &ref_440 - yaml - json http_route_filters: type: array items: type: object - properties: &ref_440 + properties: &ref_441 folder_regex: type: string path_regex: type: string route_path_regex: type: string - required: &ref_441 + required: &ref_442 - folder_regex - path_regex - route_path_regex @@ -21684,7 +22593,7 @@ paths: type: array items: type: object - properties: &ref_442 + properties: &ref_443 user_or_folder_regex: type: string enum: @@ -21697,8 +22606,8 @@ paths: type: string runnable_kind: type: string - enum: *ref_199 - required: &ref_443 + enum: *ref_200 + required: &ref_444 - user_or_folder_regex - user_or_folder_regex_value - path @@ -21727,7 +22636,7 @@ paths: application/json: schema: type: object - properties: *ref_200 + properties: *ref_201 responses: '200': description: Downloaded OpenAPI spec @@ -21756,10 +22665,13 @@ paths: type: array items: type: object - properties: &ref_201 + properties: &ref_202 path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: Path to the script or flow to execute when triggered @@ -21807,7 +22719,7 @@ paths: HTTP method (get, post, put, delete, patch) that triggers this endpoint type: string - enum: &ref_203 + enum: &ref_204 - get - post - put @@ -21829,7 +22741,7 @@ paths: 'async' returns job ID immediately, 'sync_sse' streams results via Server-Sent Events type: string - enum: &ref_204 + enum: &ref_205 - sync - async - sync_sse @@ -21839,7 +22751,7 @@ paths: 'windmill' (Windmill token), 'api_key', 'basic_http', 'custom_script', 'signature' type: string - enum: &ref_205 + enum: &ref_206 - none - windmill - api_key @@ -21857,7 +22769,7 @@ paths: mode: description: job trigger mode type: string - enum: &ref_206 + enum: &ref_207 - enabled - disabled - suspended @@ -21878,7 +22790,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -21894,7 +22806,7 @@ paths: type: array items: type: string - required: &ref_202 + required: &ref_203 - path - script_path - route_path @@ -21927,8 +22839,8 @@ paths: application/json: schema: type: object - properties: *ref_201 - required: *ref_202 + properties: *ref_202 + required: *ref_203 responses: '201': description: http trigger created @@ -21958,10 +22870,13 @@ paths: application/json: schema: type: object - properties: &ref_446 + properties: &ref_447 path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: Path to the script or flow to execute when triggered @@ -22015,7 +22930,7 @@ paths: HTTP method (get, post, put, delete, patch) that triggers this endpoint type: string - enum: *ref_203 + enum: *ref_204 is_async: type: boolean description: Deprecated, use request_type instead @@ -22025,14 +22940,14 @@ paths: 'async' returns job ID immediately, 'sync_sse' streams results via Server-Sent Events type: string - enum: *ref_204 + enum: *ref_205 authentication_method: description: >- How requests are authenticated - 'none' (public), 'windmill' (Windmill token), 'api_key', 'basic_http', 'custom_script', 'signature' type: string - enum: *ref_205 + enum: *ref_206 is_static_website: type: boolean description: >- @@ -22056,7 +22971,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -22072,7 +22987,7 @@ paths: type: array items: type: string - required: &ref_447 + required: &ref_448 - path - script_path - is_flow @@ -22130,12 +23045,16 @@ paths: content: application/json: schema: - allOf: &ref_207 + allOf: &ref_208 - type: object - properties: &ref_213 + properties: &ref_214 path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of + the form `u//` or `f//`. + This is the trigger object path, not the HTTP route + path. script_path: type: string description: Path to the script or flow to execute when triggered @@ -22167,13 +23086,13 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 labels: type: array items: type: string default: [] - required: &ref_214 + required: &ref_215 - path - script_path - permissioned_as @@ -22184,7 +23103,7 @@ paths: - is_flow - mode type: object - properties: &ref_208 + properties: &ref_209 route_path: type: string description: >- @@ -22213,7 +23132,7 @@ paths: HTTP method (get, post, put, delete, patch) that triggers this endpoint type: string - enum: *ref_203 + enum: *ref_204 authentication_resource_path: type: string nullable: true @@ -22235,14 +23154,14 @@ paths: 'async' returns job ID immediately, 'sync_sse' streams results via Server-Sent Events type: string - enum: *ref_204 + enum: *ref_205 authentication_method: description: >- How requests are authenticated - 'none' (public), 'windmill' (Windmill token), 'api_key', 'basic_http', 'custom_script', 'signature' type: string - enum: *ref_205 + enum: *ref_206 is_static_website: type: boolean description: >- @@ -22271,8 +23190,8 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 - required: &ref_209 + properties: *ref_197 + required: &ref_210 - route_path - request_type - authentication_method @@ -22327,10 +23246,10 @@ paths: schema: type: array items: - allOf: *ref_207 + allOf: *ref_208 type: object - properties: *ref_208 - required: *ref_209 + properties: *ref_209 + required: *ref_210 /w/{workspace}/http_triggers/exists/{path}: get: summary: does http trigger exists @@ -22376,7 +23295,7 @@ paths: type: string http_method: type: string - enum: *ref_203 + enum: *ref_204 trigger_path: type: string workspaced_route: @@ -22416,7 +23335,12 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -22444,10 +23368,13 @@ paths: application/json: schema: type: object - properties: &ref_448 + properties: &ref_449 path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -22466,7 +23393,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 filters: type: array description: >- @@ -22497,7 +23424,7 @@ paths: Messages to send immediately after connecting (can be raw strings or computed by runnables) items: - anyOf: &ref_210 + anyOf: &ref_211 - type: object properties: raw_message: @@ -22540,7 +23467,7 @@ paths: nullable: true description: Optional periodic heartbeat message configuration type: object - properties: &ref_211 + properties: &ref_212 interval_secs: type: integer minimum: 1 @@ -22557,7 +23484,7 @@ paths: Optional. Top-level JSON field to extract from incoming messages. The extracted value replaces {{state}} in the heartbeat message. - required: &ref_212 + required: &ref_213 - interval_secs - message error_handler_path: @@ -22570,7 +23497,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -22586,7 +23513,7 @@ paths: type: array items: type: string - required: &ref_449 + required: &ref_450 - path - script_path - url @@ -22623,7 +23550,7 @@ paths: application/json: schema: type: object - properties: &ref_450 + properties: &ref_451 url: type: string description: >- @@ -22631,7 +23558,10 @@ paths: computed by a runnable) path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -22672,7 +23602,7 @@ paths: Messages to send immediately after connecting (can be raw strings or computed by runnables) items: - anyOf: *ref_210 + anyOf: *ref_211 url_runnable_args: description: The arguments to pass to the script or flow nullable: true @@ -22690,8 +23620,8 @@ paths: nullable: true description: Optional periodic heartbeat message configuration type: object - properties: *ref_211 - required: *ref_212 + properties: *ref_212 + required: *ref_213 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -22702,7 +23632,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -22718,7 +23648,7 @@ paths: type: array items: type: string - required: &ref_451 + required: &ref_452 - path - script_path - url @@ -22776,12 +23706,12 @@ paths: content: application/json: schema: - allOf: &ref_215 + allOf: &ref_216 - type: object - properties: *ref_213 - required: *ref_214 + properties: *ref_214 + required: *ref_215 type: object - properties: &ref_216 + properties: &ref_217 url: type: string description: >- @@ -22829,7 +23759,7 @@ paths: Messages to send immediately after connecting (can be raw strings or computed by runnables) items: - anyOf: *ref_210 + anyOf: *ref_211 url_runnable_args: description: The arguments to pass to the script or flow nullable: true @@ -22847,8 +23777,8 @@ paths: nullable: true description: Optional periodic heartbeat message configuration type: object - properties: *ref_211 - required: *ref_212 + properties: *ref_212 + required: *ref_213 error_handler_path: type: string description: >- @@ -22861,8 +23791,8 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 - required: &ref_217 + properties: *ref_197 + required: &ref_218 - url - filters - can_return_message @@ -22913,10 +23843,10 @@ paths: schema: type: array items: - allOf: *ref_215 + allOf: *ref_216 type: object - properties: *ref_216 - required: *ref_217 + properties: *ref_217 + required: *ref_218 /w/{workspace}/websocket_triggers/exists/{path}: get: summary: does websocket trigger exists @@ -22965,7 +23895,12 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -23030,10 +23965,13 @@ paths: application/json: schema: type: object - properties: &ref_483 + properties: &ref_484 path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -23096,7 +24034,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -23107,7 +24045,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -23123,7 +24061,7 @@ paths: type: array items: type: string - required: &ref_484 + required: &ref_485 - path - script_path - is_flow @@ -23160,7 +24098,7 @@ paths: application/json: schema: type: object - properties: &ref_485 + properties: &ref_486 kafka_resource_path: type: string description: >- @@ -23212,7 +24150,10 @@ paths: commit offsets using the commit_offsets endpoint. path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -23233,7 +24174,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -23249,7 +24190,7 @@ paths: type: array items: type: string - required: &ref_486 + required: &ref_487 - path - script_path - kafka_resource_path @@ -23307,12 +24248,12 @@ paths: content: application/json: schema: - allOf: &ref_218 + allOf: &ref_219 - type: object - properties: *ref_213 - required: *ref_214 + properties: *ref_214 + required: *ref_215 type: object - properties: &ref_219 + properties: &ref_220 kafka_resource_path: type: string description: >- @@ -23387,8 +24328,8 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 - required: &ref_220 + properties: *ref_197 + required: &ref_221 - kafka_resource_path - group_id - topics @@ -23439,10 +24380,10 @@ paths: schema: type: array items: - allOf: *ref_218 + allOf: *ref_219 type: object - properties: *ref_219 - required: *ref_220 + properties: *ref_220 + required: *ref_221 /w/{workspace}/kafka_triggers/exists/{path}: get: summary: does kafka trigger exists @@ -23491,7 +24432,12 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -23605,10 +24551,13 @@ paths: application/json: schema: type: object - properties: &ref_487 + properties: &ref_488 path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -23645,7 +24594,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -23656,7 +24605,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -23672,7 +24621,7 @@ paths: type: array items: type: string - required: &ref_488 + required: &ref_489 - path - script_path - is_flow @@ -23708,7 +24657,7 @@ paths: application/json: schema: type: object - properties: &ref_489 + properties: &ref_490 nats_resource_path: type: string description: >- @@ -23734,7 +24683,10 @@ paths: description: Array of NATS subjects to subscribe to path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -23755,7 +24707,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -23771,7 +24723,7 @@ paths: type: array items: type: string - required: &ref_490 + required: &ref_491 - path - script_path - nats_resource_path @@ -23828,12 +24780,12 @@ paths: content: application/json: schema: - allOf: &ref_221 + allOf: &ref_222 - type: object - properties: *ref_213 - required: *ref_214 + properties: *ref_214 + required: *ref_215 type: object - properties: &ref_222 + properties: &ref_223 nats_resource_path: type: string description: >- @@ -23883,8 +24835,8 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 - required: &ref_223 + properties: *ref_197 + required: &ref_224 - nats_resource_path - use_jetstream - subjects @@ -23934,10 +24886,10 @@ paths: schema: type: array items: - allOf: *ref_221 + allOf: *ref_222 type: object - properties: *ref_222 - required: *ref_223 + properties: *ref_223 + required: *ref_224 /w/{workspace}/nats_triggers/exists/{path}: get: summary: does nats trigger exists @@ -23986,7 +24938,12 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -24044,7 +25001,7 @@ paths: application/json: schema: type: object - properties: &ref_470 + properties: &ref_471 queue_url: type: string description: The full URL of the AWS SQS queue to poll for messages @@ -24053,7 +25010,7 @@ paths: Authentication type - 'credentials' for access key/secret, 'oidc' for OpenID Connect type: string - enum: &ref_224 + enum: &ref_225 - oidc - credentials aws_resource_path: @@ -24071,7 +25028,10 @@ paths: message path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -24085,7 +25045,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -24096,7 +25056,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -24112,7 +25072,7 @@ paths: type: array items: type: string - required: &ref_471 + required: &ref_472 - queue_url - aws_resource_path - path @@ -24148,7 +25108,7 @@ paths: application/json: schema: type: object - properties: &ref_472 + properties: &ref_473 queue_url: type: string description: The full URL of the AWS SQS queue to poll for messages @@ -24157,7 +25117,7 @@ paths: Authentication type - 'credentials' for access key/secret, 'oidc' for OpenID Connect type: string - enum: *ref_224 + enum: *ref_225 aws_resource_path: type: string description: >- @@ -24173,7 +25133,10 @@ paths: message path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -24187,7 +25150,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -24198,7 +25161,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -24214,7 +25177,7 @@ paths: type: array items: type: string - required: &ref_473 + required: &ref_474 - queue_url - aws_resource_path - path @@ -24272,12 +25235,12 @@ paths: content: application/json: schema: - allOf: &ref_225 + allOf: &ref_226 - type: object - properties: *ref_213 - required: *ref_214 + properties: *ref_214 + required: *ref_215 type: object - properties: &ref_226 + properties: &ref_227 queue_url: type: string description: The full URL of the AWS SQS queue to poll for messages @@ -24286,7 +25249,7 @@ paths: Authentication type - 'credentials' for access key/secret, 'oidc' for OpenID Connect type: string - enum: *ref_224 + enum: *ref_225 aws_resource_path: type: string description: >- @@ -24324,8 +25287,8 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 - required: &ref_227 + properties: *ref_197 + required: &ref_228 - queue_url - aws_resource_path - aws_auth_resource_type @@ -24375,10 +25338,10 @@ paths: schema: type: array items: - allOf: *ref_225 + allOf: *ref_226 type: object - properties: *ref_226 - required: *ref_227 + properties: *ref_227 + required: *ref_228 /w/{workspace}/sqs_triggers/exists/{path}: get: summary: does sqs trigger exists @@ -24427,7 +25390,12 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -24487,17 +25455,17 @@ paths: type: array items: type: object - properties: &ref_572 + properties: &ref_576 service_name: type: string - enum: &ref_228 + enum: &ref_229 - nextcloud - google - github oauth_data: nullable: true type: object - properties: &ref_229 + properties: &ref_230 client_id: type: string description: The OAuth client ID for the workspace @@ -24512,7 +25480,7 @@ paths: type: string format: uri description: The OAuth redirect URI - required: &ref_230 + required: &ref_231 - client_id - client_secret - base_url @@ -24521,7 +25489,7 @@ paths: type: string nullable: true description: Path to the resource storing the OAuth token - required: &ref_573 + required: &ref_577 - service_name /w/{workspace}/native_triggers/integrations/{service_name}/exists: get: @@ -24539,7 +25507,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 responses: '200': description: integration exists @@ -24563,7 +25531,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 requestBody: description: new native trigger service required: true @@ -24571,8 +25539,8 @@ paths: application/json: schema: type: object - properties: *ref_229 - required: *ref_230 + properties: *ref_230 + required: *ref_231 responses: '201': description: native trigger service created @@ -24596,7 +25564,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 requestBody: description: redirect_uri required: true @@ -24604,10 +25572,10 @@ paths: application/json: schema: type: object - properties: &ref_231 + properties: &ref_232 redirect_uri: type: string - required: &ref_232 + required: &ref_233 - redirect_uri responses: '200': @@ -24632,7 +25600,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 responses: '200': description: whether instance sharing is available @@ -24656,7 +25624,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 requestBody: description: redirect_uri required: true @@ -24664,8 +25632,8 @@ paths: application/json: schema: type: object - properties: *ref_231 - required: *ref_232 + properties: *ref_232 + required: *ref_233 responses: '200': description: authorization URL using instance credentials @@ -24689,7 +25657,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 responses: '200': description: native trigger service deleted @@ -24713,7 +25681,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 requestBody: description: OAuth callback data required: true @@ -24762,7 +25730,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 requestBody: description: new native trigger configuration required: true @@ -24771,7 +25739,7 @@ paths: schema: type: object description: Data for creating or updating a native trigger - properties: &ref_233 + properties: &ref_234 script_path: type: string description: The path to the script or flow that will be triggered @@ -24788,7 +25756,7 @@ paths: type: string nullable: true description: Short summary to be displayed when listed - required: &ref_234 + required: &ref_235 - script_path - is_flow - service_config @@ -24800,13 +25768,13 @@ paths: schema: type: object description: Response returned when a native trigger is created - properties: &ref_575 + properties: &ref_579 external_id: type: string description: >- The external ID of the created trigger from the external service - required: &ref_576 + required: &ref_580 - external_id /w/{workspace}/native_triggers/{service_name}/update/{external_id}: post: @@ -24829,7 +25797,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 - name: external_id in: path required: true @@ -24844,8 +25812,8 @@ paths: schema: type: object description: Data for creating or updating a native trigger - properties: *ref_233 - required: *ref_234 + properties: *ref_234 + required: *ref_235 responses: '200': description: native trigger updated @@ -24874,7 +25842,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 - name: external_id in: path required: true @@ -24891,7 +25859,7 @@ paths: description: >- Full trigger response containing both Windmill data and external service data - properties: &ref_570 + properties: &ref_574 external_id: type: string description: The unique identifier from the external service @@ -24900,7 +25868,7 @@ paths: description: The workspace this trigger belongs to service_name: type: string - enum: *ref_228 + enum: *ref_229 script_path: type: string description: The path to the script or flow that will be triggered @@ -24927,7 +25895,7 @@ paths: type: object description: Configuration data from the external service additionalProperties: true - required: &ref_571 + required: &ref_575 - external_id - workspace_id - service_name @@ -24956,7 +25924,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 - name: external_id in: path required: true @@ -24987,7 +25955,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 - name: page description: which page to return (start at 1, default 1) in: query @@ -25022,7 +25990,7 @@ paths: items: type: object description: A native trigger stored in Windmill - properties: &ref_568 + properties: &ref_572 external_id: type: string description: The unique identifier from the external service @@ -25031,7 +25999,7 @@ paths: description: The workspace this trigger belongs to service_name: type: string - enum: *ref_228 + enum: *ref_229 script_path: type: string description: The path to the script or flow that will be triggered @@ -25054,7 +26022,7 @@ paths: type: string nullable: true description: Short summary to be displayed when listed - required: &ref_569 + required: &ref_573 - external_id - workspace_id - service_name @@ -25078,7 +26046,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 - name: external_id in: path required: true @@ -25108,7 +26076,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 responses: '200': description: sync completed successfully @@ -25133,7 +26101,7 @@ paths: type: array items: type: object - properties: &ref_577 + properties: &ref_581 id: type: string name: @@ -25144,7 +26112,7 @@ paths: type: string path: type: string - required: &ref_578 + required: &ref_582 - id - name - path @@ -25169,7 +26137,7 @@ paths: type: array items: type: object - properties: &ref_579 + properties: &ref_583 id: type: string summary: @@ -25177,7 +26145,7 @@ paths: primary: type: boolean default: false - required: &ref_580 + required: &ref_584 - id - summary /w/{workspace}/native_triggers/google/drive/files: @@ -25220,12 +26188,12 @@ paths: application/json: schema: type: object - properties: &ref_583 + properties: &ref_587 files: type: array items: type: object - properties: &ref_581 + properties: &ref_585 id: type: string name: @@ -25235,13 +26203,13 @@ paths: is_folder: type: boolean default: false - required: &ref_582 + required: &ref_586 - id - name - mime_type next_page_token: type: string - required: &ref_584 + required: &ref_588 - files /w/{workspace}/native_triggers/google/drive/shared_drives: get: @@ -25264,12 +26232,12 @@ paths: type: array items: type: object - properties: &ref_585 + properties: &ref_589 id: type: string name: type: string - required: &ref_586 + required: &ref_590 - id - name /w/{workspace}/native_triggers/github/repos: @@ -25293,7 +26261,7 @@ paths: type: array items: type: object - properties: &ref_587 + properties: &ref_591 full_name: type: string name: @@ -25302,7 +26270,7 @@ paths: type: string private: type: boolean - required: &ref_588 + required: &ref_592 - full_name - name - owner @@ -25319,7 +26287,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 - name: workspace_id in: path required: true @@ -25368,7 +26336,7 @@ paths: application/json: schema: type: object - properties: &ref_453 + properties: &ref_454 mqtt_resource_path: type: string description: >- @@ -25378,16 +26346,16 @@ paths: type: array items: type: object - properties: &ref_235 + properties: &ref_236 qos: type: string - enum: &ref_452 + enum: &ref_453 - qos0 - qos1 - qos2 topic: type: string - required: &ref_236 + required: &ref_237 - qos - topic description: >- @@ -25401,7 +26369,7 @@ paths: nullable: true description: MQTT v3 specific configuration (clean_session) type: object - properties: &ref_237 + properties: &ref_238 clean_session: type: boolean v5_config: @@ -25410,7 +26378,7 @@ paths: MQTT v5 specific configuration (clean_start, topic_alias_maximum, session_expiry_interval) type: object - properties: &ref_238 + properties: &ref_239 clean_start: type: boolean topic_alias_maximum: @@ -25421,12 +26389,15 @@ paths: nullable: true description: MQTT protocol version ('v3' or 'v5') type: string - enum: &ref_239 + enum: &ref_240 - v3 - v5 path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -25440,7 +26411,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -25451,7 +26422,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -25467,7 +26438,7 @@ paths: type: array items: type: string - required: &ref_454 + required: &ref_455 - path - script_path - is_flow @@ -25502,7 +26473,7 @@ paths: application/json: schema: type: object - properties: &ref_455 + properties: &ref_456 mqtt_resource_path: type: string description: >- @@ -25512,8 +26483,8 @@ paths: type: array items: type: object - properties: *ref_235 - required: *ref_236 + properties: *ref_236 + required: *ref_237 description: >- Array of MQTT topics to subscribe to, each with topic name and QoS level @@ -25525,22 +26496,25 @@ paths: nullable: true description: MQTT v3 specific configuration (clean_session) type: object - properties: *ref_237 + properties: *ref_238 v5_config: nullable: true description: >- MQTT v5 specific configuration (clean_start, topic_alias_maximum, session_expiry_interval) type: object - properties: *ref_238 + properties: *ref_239 client_version: nullable: true description: MQTT protocol version ('v3' or 'v5') type: string - enum: *ref_239 + enum: *ref_240 path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -25554,7 +26528,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -25565,7 +26539,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -25581,7 +26555,7 @@ paths: type: array items: type: string - required: &ref_456 + required: &ref_457 - path - script_path - is_flow @@ -25638,12 +26612,12 @@ paths: content: application/json: schema: - allOf: &ref_240 + allOf: &ref_241 - type: object - properties: *ref_213 - required: *ref_214 + properties: *ref_214 + required: *ref_215 type: object - properties: &ref_241 + properties: &ref_242 mqtt_resource_path: type: string description: >- @@ -25653,8 +26627,8 @@ paths: type: array items: type: object - properties: *ref_235 - required: *ref_236 + properties: *ref_236 + required: *ref_237 description: >- Array of MQTT topics to subscribe to, each with topic name and QoS level @@ -25662,14 +26636,14 @@ paths: nullable: true description: MQTT v3 specific configuration (clean_session) type: object - properties: *ref_237 + properties: *ref_238 v5_config: nullable: true description: >- MQTT v5 specific configuration (clean_start, topic_alias_maximum, session_expiry_interval) type: object - properties: *ref_238 + properties: *ref_239 client_id: type: string nullable: true @@ -25678,7 +26652,7 @@ paths: nullable: true description: MQTT protocol version ('v3' or 'v5') type: string - enum: *ref_239 + enum: *ref_240 server_id: type: string description: >- @@ -25703,8 +26677,8 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 - required: &ref_242 + properties: *ref_197 + required: &ref_243 - subscribe_topics - mqtt_resource_path /w/{workspace}/mqtt_triggers/list: @@ -25753,10 +26727,10 @@ paths: schema: type: array items: - allOf: *ref_240 + allOf: *ref_241 type: object - properties: *ref_241 - required: *ref_242 + properties: *ref_242 + required: *ref_243 /w/{workspace}/mqtt_triggers/exists/{path}: get: summary: does mqtt trigger exists @@ -25805,7 +26779,12 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -25864,7 +26843,7 @@ paths: schema: type: object description: Data for creating or updating a Google Cloud Pub/Sub trigger. - properties: &ref_243 + properties: &ref_244 gcp_resource_path: type: string description: >- @@ -25872,7 +26851,7 @@ paths: credentials for authentication. subscription_mode: type: string - enum: &ref_248 + enum: &ref_249 - existing - create_update description: >- @@ -25890,7 +26869,7 @@ paths: description: Base URL for push delivery endpoint. delivery_type: type: string - enum: &ref_245 + enum: &ref_246 - push - pull description: >- @@ -25901,7 +26880,7 @@ paths: nullable: true type: object description: Configuration for push delivery mode. - properties: &ref_246 + properties: &ref_247 audience: type: string description: >- @@ -25912,12 +26891,15 @@ paths: description: >- If true, push messages will include OIDC authentication tokens. - required: &ref_247 + required: &ref_248 - authenticate - base_endpoint path: type: string - description: The unique path identifier for this trigger. + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -25931,7 +26913,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 auto_acknowledge_msg: type: boolean description: >- @@ -25958,7 +26940,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -25974,7 +26956,7 @@ paths: type: array items: type: string - required: &ref_244 + required: &ref_245 - path - script_path - is_flow @@ -26011,8 +26993,8 @@ paths: schema: type: object description: Data for creating or updating a Google Cloud Pub/Sub trigger. - properties: *ref_243 - required: *ref_244 + properties: *ref_244 + required: *ref_245 responses: '200': description: gcp trigger updated @@ -26063,15 +27045,15 @@ paths: content: application/json: schema: - allOf: &ref_249 + allOf: &ref_250 - type: object - properties: *ref_213 - required: *ref_214 + properties: *ref_214 + required: *ref_215 type: object description: >- A Google Cloud Pub/Sub trigger that executes a script or flow when messages are received. - properties: &ref_250 + properties: &ref_251 gcp_resource_path: type: string description: >- @@ -26090,7 +27072,7 @@ paths: use). delivery_type: type: string - enum: *ref_245 + enum: *ref_246 description: >- Delivery mode for messages. 'push' for HTTP push delivery where messages are sent to a webhook endpoint, 'pull' for @@ -26099,11 +27081,11 @@ paths: nullable: true type: object description: Configuration for push delivery mode. - properties: *ref_246 - required: *ref_247 + properties: *ref_247 + required: *ref_248 subscription_mode: type: string - enum: *ref_248 + enum: *ref_249 description: >- The mode of subscription. 'existing' means using an existing GCP subscription, while 'create_update' involves @@ -26127,8 +27109,8 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 - required: &ref_251 + properties: *ref_197 + required: &ref_252 - gcp_resource_path - topic_id - subscription_id @@ -26180,13 +27162,13 @@ paths: schema: type: array items: - allOf: *ref_249 + allOf: *ref_250 type: object description: >- A Google Cloud Pub/Sub trigger that executes a script or flow when messages are received. - properties: *ref_250 - required: *ref_251 + properties: *ref_251 + required: *ref_252 /w/{workspace}/gcp_triggers/exists/{path}: get: summary: does gcp trigger exists @@ -26235,7 +27217,12 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -26297,10 +27284,10 @@ paths: application/json: schema: type: object - properties: &ref_459 + properties: &ref_460 subscription_id: type: string - required: &ref_460 + required: &ref_461 - subscription_id responses: '200': @@ -26355,10 +27342,10 @@ paths: application/json: schema: type: object - properties: &ref_457 + properties: &ref_458 topic_id: type: string - required: &ref_458 + required: &ref_459 - topic_id responses: '200': @@ -26387,12 +27374,12 @@ paths: schema: type: object description: Data for creating or updating an Azure Event Grid trigger. - properties: &ref_252 + properties: &ref_253 azure_resource_path: type: string azure_mode: type: string - enum: &ref_254 + enum: &ref_255 - basic_push - namespace_push - namespace_pull @@ -26413,6 +27400,10 @@ paths: type: string path: type: string + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string is_flow: @@ -26420,7 +27411,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 error_handler_path: type: string error_handler_args: @@ -26430,7 +27421,7 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string preserve_permissioned_as: @@ -26439,7 +27430,7 @@ paths: type: array items: type: string - required: &ref_253 + required: &ref_254 - path - script_path - is_flow @@ -26476,8 +27467,8 @@ paths: schema: type: object description: Data for creating or updating an Azure Event Grid trigger. - properties: *ref_252 - required: *ref_253 + properties: *ref_253 + required: *ref_254 responses: '200': description: azure trigger updated @@ -26528,20 +27519,20 @@ paths: content: application/json: schema: - allOf: &ref_255 + allOf: &ref_256 - type: object - properties: *ref_213 - required: *ref_214 + properties: *ref_214 + required: *ref_215 type: object description: >- An Azure Event Grid trigger that executes a script or flow when events arrive. - properties: &ref_256 + properties: &ref_257 azure_resource_path: type: string azure_mode: type: string - enum: *ref_254 + enum: *ref_255 description: Azure Event Grid trigger mode. scope_resource_id: type: string @@ -26575,8 +27566,8 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_196 - required: &ref_257 + properties: *ref_197 + required: &ref_258 - azure_resource_path - azure_mode - scope_resource_id @@ -26621,13 +27612,13 @@ paths: schema: type: array items: - allOf: *ref_255 + allOf: *ref_256 type: object description: >- An Azure Event Grid trigger that executes a script or flow when events arrive. - properties: *ref_256 - required: *ref_257 + properties: *ref_257 + required: *ref_258 /w/{workspace}/azure_triggers/exists/{path}: get: summary: check whether an azure trigger exists @@ -26675,7 +27666,12 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -26702,10 +27698,10 @@ paths: application/json: schema: type: object - properties: &ref_463 + properties: &ref_464 azure_resource_path: type: string - required: &ref_464 + required: &ref_465 - azure_resource_path responses: '200': @@ -26735,10 +27731,10 @@ paths: application/json: schema: type: object - properties: &ref_465 + properties: &ref_466 scope_resource_id: type: string - required: &ref_466 + required: &ref_467 - scope_resource_id responses: '200': @@ -26770,12 +27766,12 @@ paths: application/json: schema: type: object - properties: &ref_467 + properties: &ref_468 scope_resource_id: type: string topic_name: type: string - required: &ref_468 + required: &ref_469 - scope_resource_id - topic_name responses: @@ -26808,10 +27804,10 @@ paths: application/json: schema: type: object - properties: &ref_461 + properties: &ref_462 azure_mode: type: string - enum: *ref_254 + enum: *ref_255 description: Azure Event Grid trigger mode. scope_resource_id: type: string @@ -26820,7 +27816,7 @@ paths: nullable: true subscription_name: type: string - required: &ref_462 + required: &ref_463 - azure_mode - scope_resource_id - subscription_name @@ -26856,7 +27852,7 @@ paths: items: type: object description: An ARM resource the service principal can see. - properties: &ref_258 + properties: &ref_259 id: type: string name: @@ -26865,7 +27861,7 @@ paths: type: string type: type: string - required: &ref_259 + required: &ref_260 - id - name - type @@ -26896,8 +27892,8 @@ paths: items: type: object description: An ARM resource the service principal can see. - properties: *ref_258 - required: *ref_259 + properties: *ref_259 + required: *ref_260 /w/{workspace}/postgres_triggers/postgres/version/{path}: get: summary: get postgres version @@ -26960,19 +27956,19 @@ paths: application/json: schema: type: object - properties: &ref_477 + properties: &ref_478 postgres_resource_path: type: string relations: type: array items: type: object - properties: &ref_261 + properties: &ref_262 schema_name: type: string table_to_track: type: array - items: &ref_475 + items: &ref_476 type: object properties: table_name: @@ -26985,14 +27981,14 @@ paths: type: string required: - table_name - required: &ref_262 + required: &ref_263 - schema_name - table_to_track language: type: string - enum: &ref_476 + enum: &ref_477 - Typescript - required: &ref_478 + required: &ref_479 - postgres_resource_path - relations - language @@ -27017,7 +28013,7 @@ paths: - name: id in: path required: true - schema: &ref_303 + schema: &ref_304 type: string responses: '200': @@ -27050,7 +28046,7 @@ paths: type: array items: type: object - properties: &ref_474 + properties: &ref_475 slot_name: type: string active: @@ -27077,7 +28073,7 @@ paths: application/json: schema: type: object - properties: &ref_260 + properties: &ref_261 name: type: string responses: @@ -27109,7 +28105,7 @@ paths: application/json: schema: type: object - properties: *ref_260 + properties: *ref_261 responses: '200': description: postgres replication slot deleted @@ -27160,7 +28156,7 @@ paths: in: path required: true description: The name of the publication - schema: &ref_263 + schema: &ref_264 type: string responses: '200': @@ -27169,18 +28165,18 @@ paths: application/json: schema: type: object - properties: &ref_264 + properties: &ref_265 table_to_track: type: array items: type: object - properties: *ref_261 - required: *ref_262 + properties: *ref_262 + required: *ref_263 transaction_to_track: type: array items: type: string - required: &ref_265 + required: &ref_266 - transaction_to_track /w/{workspace}/postgres_triggers/publication/create/{publication}/{path}: post: @@ -27201,7 +28197,7 @@ paths: in: path required: true description: The name of the publication - schema: *ref_263 + schema: *ref_264 requestBody: description: new publication for postgres required: true @@ -27209,8 +28205,8 @@ paths: application/json: schema: type: object - properties: *ref_264 - required: *ref_265 + properties: *ref_265 + required: *ref_266 responses: '201': description: publication created @@ -27237,7 +28233,7 @@ paths: in: path required: true description: The name of the publication - schema: *ref_263 + schema: *ref_264 requestBody: description: update publication for postgres required: true @@ -27245,8 +28241,8 @@ paths: application/json: schema: type: object - properties: *ref_264 - required: *ref_265 + properties: *ref_265 + required: *ref_266 responses: '201': description: publication updated @@ -27273,7 +28269,7 @@ paths: in: path required: true description: The name of the publication - schema: *ref_263 + schema: *ref_264 responses: '200': description: postgres publication deleted @@ -27299,7 +28295,7 @@ paths: application/json: schema: type: object - properties: &ref_479 + properties: &ref_480 replication_slot_name: type: string description: Name of the PostgreSQL logical replication slot to use @@ -27310,7 +28306,10 @@ paths: change data capture path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -27324,7 +28323,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 postgres_resource_path: type: string description: >- @@ -27335,8 +28334,8 @@ paths: Configuration for creating/managing the publication (tables, operations) type: object - properties: *ref_264 - required: *ref_265 + properties: *ref_265 + required: *ref_266 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -27347,7 +28346,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -27363,7 +28362,7 @@ paths: type: array items: type: string - required: &ref_480 + required: &ref_481 - path - script_path - is_flow @@ -27398,7 +28397,7 @@ paths: application/json: schema: type: object - properties: &ref_481 + properties: &ref_482 replication_slot_name: type: string description: Name of the PostgreSQL logical replication slot to use @@ -27409,7 +28408,10 @@ paths: change data capture path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -27423,7 +28425,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 postgres_resource_path: type: string description: >- @@ -27434,8 +28436,8 @@ paths: Configuration for creating/managing the publication (tables, operations) type: object - properties: *ref_264 - required: *ref_265 + properties: *ref_265 + required: *ref_266 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -27446,7 +28448,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -27462,7 +28464,7 @@ paths: type: array items: type: string - required: &ref_482 + required: &ref_483 - path - script_path - is_flow @@ -27520,12 +28522,12 @@ paths: content: application/json: schema: - allOf: &ref_266 + allOf: &ref_267 - type: object - properties: *ref_213 - required: *ref_214 + properties: *ref_214 + required: *ref_215 type: object - properties: &ref_267 + properties: &ref_268 postgres_resource_path: type: string description: >- @@ -27563,8 +28565,8 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 - required: &ref_268 + properties: *ref_197 + required: &ref_269 - postgres_resource_path - replication_slot_name - publication_name @@ -27614,10 +28616,10 @@ paths: schema: type: array items: - allOf: *ref_266 + allOf: *ref_267 type: object - properties: *ref_267 - required: *ref_268 + properties: *ref_268 + required: *ref_269 /w/{workspace}/postgres_triggers/exists/{path}: get: summary: does postgres trigger exists @@ -27666,7 +28668,12 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -27724,7 +28731,7 @@ paths: application/json: schema: type: object - properties: &ref_491 + properties: &ref_492 path: type: string script_path: @@ -27744,11 +28751,11 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_196 + properties: *ref_197 mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 permissioned_as: type: string description: >- @@ -27764,7 +28771,7 @@ paths: type: array items: type: string - required: &ref_492 + required: &ref_493 - path - script_path - local_part @@ -27798,7 +28805,7 @@ paths: application/json: schema: type: object - properties: &ref_493 + properties: &ref_494 path: type: string script_path: @@ -27818,7 +28825,7 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -27834,7 +28841,7 @@ paths: type: array items: type: string - required: &ref_494 + required: &ref_495 - path - script_path - is_flow @@ -27888,12 +28895,12 @@ paths: content: application/json: schema: - allOf: &ref_269 + allOf: &ref_270 - type: object - properties: *ref_213 - required: *ref_214 + properties: *ref_214 + required: *ref_215 type: object - properties: &ref_270 + properties: &ref_271 local_part: type: string workspaced_local_part: @@ -27907,8 +28914,8 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_196 - required: &ref_271 + properties: *ref_197 + required: &ref_272 - local_part /w/{workspace}/email_triggers/list: get: @@ -27956,10 +28963,10 @@ paths: schema: type: array items: - allOf: *ref_269 + allOf: *ref_270 type: object - properties: *ref_270 - required: *ref_271 + properties: *ref_271 + required: *ref_272 /w/{workspace}/email_triggers/exists/{path}: get: summary: does email trigger exists @@ -28041,7 +29048,12 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -28066,9 +29078,9 @@ paths: type: array items: type: object - required: &ref_495 + required: &ref_496 - name - properties: &ref_496 + properties: &ref_497 name: type: string summary: @@ -28098,9 +29110,9 @@ paths: type: array items: type: object - required: &ref_273 + required: &ref_274 - name - properties: &ref_274 + properties: &ref_275 name: type: string summary: @@ -28119,14 +29131,14 @@ paths: type: array items: type: object - properties: &ref_497 + properties: &ref_498 workspace_id: type: string workspace_name: type: string role: type: string - required: &ref_498 + required: &ref_499 - name /groups/get/{name}: get: @@ -28138,7 +29150,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 responses: '200': description: instance group @@ -28146,8 +29158,8 @@ paths: application/json: schema: type: object - required: *ref_273 - properties: *ref_274 + required: *ref_274 + properties: *ref_275 /groups/create: post: summary: create instance group @@ -28185,7 +29197,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 requestBody: description: update instance group required: true @@ -28221,7 +29233,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 responses: '200': description: instance group deleted @@ -28239,7 +29251,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 requestBody: description: user to add to instance group required: true @@ -28269,7 +29281,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 requestBody: description: user to remove from instance group required: true @@ -28304,7 +29316,7 @@ paths: type: array items: type: object - properties: &ref_275 + properties: &ref_276 name: type: string summary: @@ -28325,7 +29337,7 @@ paths: enum: - superadmin - devops - required: &ref_276 + required: &ref_277 - name /groups/overwrite: post: @@ -28342,8 +29354,8 @@ paths: type: array items: type: object - properties: *ref_275 - required: *ref_276 + properties: *ref_276 + required: *ref_277 responses: '200': description: success message @@ -28379,7 +29391,7 @@ paths: type: array items: type: object - properties: &ref_277 + properties: &ref_278 name: type: string summary: @@ -28392,7 +29404,7 @@ paths: type: object additionalProperties: type: boolean - required: &ref_278 + required: &ref_279 - name /w/{workspace}/groups/listnames: get: @@ -28465,7 +29477,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 requestBody: description: updated group required: true @@ -28497,7 +29509,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 responses: '200': description: group deleted @@ -28519,7 +29531,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 responses: '200': description: group @@ -28527,8 +29539,8 @@ paths: application/json: schema: type: object - properties: *ref_277 - required: *ref_278 + properties: *ref_278 + required: *ref_279 /w/{workspace}/groups/adduser/{name}: post: summary: add user to group @@ -28543,7 +29555,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 requestBody: description: added user to group required: true @@ -28575,7 +29587,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 requestBody: description: added user to group required: true @@ -28607,7 +29619,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 - name: page description: which page to return (start at 1, default 1) in: query @@ -28666,7 +29678,7 @@ paths: type: array items: type: object - properties: &ref_280 + properties: &ref_281 name: type: string owners: @@ -28692,7 +29704,7 @@ paths: (relative to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: &ref_279 + items: &ref_280 type: object required: - path_glob @@ -28713,7 +29725,7 @@ paths: permissioned as. Must be `u/`, `g/`, or an email that exists in this workspace. - required: &ref_281 + required: &ref_282 - name - owners - extra_perms @@ -28780,7 +29792,7 @@ paths: to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: *ref_279 + items: *ref_280 required: - name responses: @@ -28804,7 +29816,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 requestBody: description: update folder required: true @@ -28830,7 +29842,7 @@ paths: to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: *ref_279 + items: *ref_280 responses: '200': description: folder updated @@ -28852,7 +29864,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 responses: '200': description: folder deleted @@ -28874,7 +29886,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 responses: '200': description: folder @@ -28882,8 +29894,8 @@ paths: application/json: schema: type: object - properties: *ref_280 - required: *ref_281 + properties: *ref_281 + required: *ref_282 /w/{workspace}/folders/exists/{name}: get: summary: exists folder @@ -28898,7 +29910,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 responses: '200': description: folder exists @@ -28920,7 +29932,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 responses: '200': description: folder @@ -28962,7 +29974,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 requestBody: description: owner user to folder required: true @@ -28996,7 +30008,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 requestBody: description: added owner to folder required: true @@ -29032,7 +30044,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 - name: page description: which page to return (start at 1, default 1) in: query @@ -29096,7 +30108,7 @@ paths: type: array items: type: object - properties: &ref_499 + properties: &ref_500 worker: type: string worker_instance: @@ -29142,7 +30154,7 @@ paths: type: string native_mode: type: boolean - required: &ref_500 + required: &ref_501 - worker - worker_instance - ping_at @@ -29276,7 +30288,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 responses: '200': description: a config @@ -29285,12 +30297,12 @@ paths: schema: type: object nullable: true - properties: &ref_383 + properties: &ref_384 alerts: type: array items: type: object - properties: &ref_381 + properties: &ref_382 name: type: string tags_to_monitor: @@ -29303,7 +30315,7 @@ paths: type: integer alert_time_threshold_seconds: type: integer - required: &ref_382 + required: &ref_383 - name - tags_to_monitor - jobs_num_threshold @@ -29319,7 +30331,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 requestBody: description: worker group required: true @@ -29342,7 +30354,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 responses: '200': description: Delete config @@ -29365,12 +30377,12 @@ paths: type: array items: type: object - properties: &ref_543 + properties: &ref_547 name: type: string config: type: object - required: &ref_544 + required: &ref_548 - name /configs/list_autoscaling_events/{worker_group}: get: @@ -29401,7 +30413,7 @@ paths: type: array items: type: object - properties: &ref_547 + properties: &ref_551 id: type: integer format: int64 @@ -29834,7 +30846,7 @@ paths: properties: trigger_kind: type: string - enum: &ref_282 + enum: &ref_283 - webhook - http - websocket @@ -29880,7 +30892,7 @@ paths: required: true schema: type: string - enum: *ref_282 + enum: *ref_283 - name: runnable_kind in: path required: true @@ -29920,17 +30932,17 @@ paths: type: array items: type: object - properties: &ref_548 + properties: &ref_552 trigger_config: {} trigger_kind: type: string - enum: *ref_282 + enum: *ref_283 error: type: string last_server_ping: type: string format: date-time - required: &ref_549 + required: &ref_553 - trigger_kind /w/{workspace}/capture/list/{runnable_kind}/{path}: get: @@ -29955,7 +30967,7 @@ paths: in: query schema: type: string - enum: *ref_282 + enum: *ref_283 - name: page description: which page to return (start at 1, default 1) in: query @@ -29973,10 +30985,10 @@ paths: type: array items: type: object - properties: &ref_283 + properties: &ref_284 trigger_kind: type: string - enum: *ref_282 + enum: *ref_283 main_args: {} preprocessor_args: {} id: @@ -29984,7 +30996,7 @@ paths: created_at: type: string format: date-time - required: &ref_284 + required: &ref_285 - trigger_kind - main_args - preprocessor_args @@ -30049,8 +31061,8 @@ paths: application/json: schema: type: object - properties: *ref_283 - required: *ref_284 + properties: *ref_284 + required: *ref_285 delete: summary: delete a capture operationId: deleteCapture @@ -30142,13 +31154,13 @@ paths: schema: *ref_4 - name: runnable_id in: query - schema: &ref_285 + schema: &ref_286 type: string - name: runnable_type in: query - schema: &ref_286 + schema: &ref_287 type: string - enum: &ref_391 + enum: &ref_392 - ScriptHash - ScriptPath - FlowPath @@ -30165,7 +31177,7 @@ paths: filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_166 + schema: *ref_167 - name: include_preview in: query schema: @@ -30179,7 +31191,7 @@ paths: type: array items: type: object - properties: &ref_287 + properties: &ref_288 id: type: string name: @@ -30193,7 +31205,7 @@ paths: type: boolean success: type: boolean - required: &ref_288 + required: &ref_289 - id - name - args @@ -30243,10 +31255,10 @@ paths: schema: *ref_4 - name: runnable_id in: query - schema: *ref_285 + schema: *ref_286 - name: runnable_type in: query - schema: *ref_286 + schema: *ref_287 - name: page description: which page to return (start at 1, default 1) in: query @@ -30264,8 +31276,8 @@ paths: type: array items: type: object - properties: *ref_287 - required: *ref_288 + properties: *ref_288 + required: *ref_289 /w/{workspace}/inputs/create: post: summary: Create an Input for future use in a script or flow @@ -30279,10 +31291,10 @@ paths: schema: *ref_4 - name: runnable_id in: query - schema: *ref_285 + schema: *ref_286 - name: runnable_type in: query - schema: *ref_286 + schema: *ref_287 requestBody: description: Input required: true @@ -30290,12 +31302,12 @@ paths: application/json: schema: type: object - properties: &ref_387 + properties: &ref_388 name: type: string args: type: object - required: &ref_388 + required: &ref_389 - name - args - created_by @@ -30325,14 +31337,14 @@ paths: application/json: schema: type: object - properties: &ref_389 + properties: &ref_390 id: type: string name: type: string is_public: type: boolean - required: &ref_390 + required: &ref_391 - id - name - is_public @@ -30358,7 +31370,7 @@ paths: - name: input in: path required: true - schema: &ref_311 + schema: &ref_312 type: string responses: '200': @@ -30391,7 +31403,7 @@ paths: properties: s3_resource: type: object - properties: &ref_289 + properties: &ref_290 bucket: type: string region: @@ -30406,7 +31418,7 @@ paths: type: string pathStyle: type: boolean - required: &ref_290 + required: &ref_291 - bucket - region - endPoint @@ -30484,8 +31496,8 @@ paths: properties: s3_resource: type: object - properties: *ref_289 - required: *ref_290 + properties: *ref_290 + required: *ref_291 responses: '200': description: Connection settings @@ -30506,10 +31518,10 @@ paths: type: boolean client_kwargs: type: object - properties: &ref_291 + properties: &ref_292 region_name: type: string - required: &ref_292 + required: &ref_293 - region_name required: - endpoint_url @@ -30564,8 +31576,8 @@ paths: type: boolean client_kwargs: type: object - properties: *ref_291 - required: *ref_292 + properties: *ref_292 + required: *ref_293 required: - endpoint_url - use_ssl @@ -30623,8 +31635,8 @@ paths: application/json: schema: type: object - properties: *ref_289 - required: *ref_290 + properties: *ref_290 + required: *ref_291 /w/{workspace}/job_helpers/test_connection: get: summary: Test connection to the workspace object storage @@ -30688,10 +31700,10 @@ paths: type: array items: type: object - properties: &ref_293 + properties: &ref_294 s3: type: string - required: &ref_294 + required: &ref_295 - s3 restricted_access: type: boolean @@ -30724,7 +31736,7 @@ paths: application/json: schema: type: object - properties: &ref_297 + properties: &ref_298 mime_type: type: string size_in_bytes: @@ -30788,7 +31800,7 @@ paths: application/json: schema: type: object - properties: &ref_295 + properties: &ref_296 msg: type: string content: @@ -30800,7 +31812,7 @@ paths: - Csv - Parquet - Unknown - required: &ref_296 + required: &ref_297 - content_type /w/{workspace}/job_helpers/list_git_repo_files: get: @@ -30848,8 +31860,8 @@ paths: type: array items: type: object - properties: *ref_293 - required: *ref_294 + properties: *ref_294 + required: *ref_295 restricted_access: type: boolean required: @@ -30908,8 +31920,8 @@ paths: application/json: schema: type: object - properties: *ref_295 - required: *ref_296 + properties: *ref_296 + required: *ref_297 /w/{workspace}/job_helpers/load_git_repo_file_metadata: get: summary: >- @@ -30940,7 +31952,7 @@ paths: application/json: schema: type: object - properties: *ref_297 + properties: *ref_298 /w/{workspace}/job_helpers/check_s3_folder_exists: get: summary: Check if S3 path exists and is a folder @@ -30961,7 +31973,7 @@ paths: schema: type: string - name: marker_file - description: >- + description: | If provided, the folder is only considered to exist when this exact sentinel file is present under file_key. Lets callers distinguish a fully populated folder from a partial upload. @@ -31391,7 +32403,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 requestBody: description: parameters for statistics retrieval required: true @@ -31420,46 +32432,46 @@ paths: type: array items: type: object - properties: &ref_529 + properties: &ref_533 id: type: string name: type: string - required: &ref_530 + required: &ref_534 - id scalar_metrics: type: array items: type: object - properties: &ref_531 + properties: &ref_535 metric_id: type: string value: type: number - required: &ref_532 + required: &ref_536 - id - value timeseries_metrics: type: array items: type: object - properties: &ref_533 + properties: &ref_537 metric_id: type: string values: type: array items: type: object - properties: &ref_535 + properties: &ref_539 timestamp: type: string format: date-time value: type: number - required: &ref_536 + required: &ref_540 - timestamp - value - required: &ref_534 + required: &ref_538 - id - values /w/{workspace}/job_metrics/set_progress/{id}: @@ -31476,7 +32488,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 requestBody: description: parameters for statistics retrieval required: true @@ -31510,7 +32522,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: job progress between 0 and 99 @@ -31528,11 +32540,11 @@ paths: - name: before description: filter on started before (inclusive) timestamp in: query - schema: *ref_298 + schema: *ref_299 - name: after description: filter on created after (exclusive) timestamp in: query - schema: *ref_299 + schema: *ref_300 - name: with_error in: query required: false @@ -31604,12 +32616,12 @@ paths: type: array items: type: object - properties: &ref_537 + properties: &ref_541 concurrency_key: type: string total_running: type: number - required: &ref_538 + required: &ref_542 - concurrency_key - total_running /concurrency_groups/prune/{concurrency_id}: @@ -31622,7 +32634,7 @@ paths: - name: concurrency_id in: path required: true - schema: &ref_313 + schema: &ref_314 type: string responses: '200': @@ -31642,7 +32654,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: concurrency key for given job @@ -31685,7 +32697,7 @@ paths: (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_173 + schema: *ref_174 - name: parent_job description: >- The parent job that is at the origin and responsible for the @@ -31698,84 +32710,84 @@ paths: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_156 + schema: *ref_157 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_157 + schema: *ref_158 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_158 + schema: *ref_159 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_159 + schema: *ref_160 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_160 + schema: *ref_161 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_161 + schema: *ref_162 - name: running description: filter on running jobs in: query - schema: *ref_162 + schema: *ref_163 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_163 + schema: *ref_164 - name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_183 + schema: *ref_184 - name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_184 + schema: *ref_185 - name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: *ref_185 + schema: *ref_186 - name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: *ref_186 + schema: *ref_187 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_164 + schema: *ref_165 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_166 + schema: *ref_167 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_167 + schema: *ref_168 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_168 + schema: *ref_169 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_170 + schema: *ref_171 - name: page description: which page to return (start at 1, default 1) in: query @@ -31791,7 +32803,7 @@ paths: (e.g. '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: *ref_187 + schema: *ref_188 - name: is_skipped description: is the job skipped in: query @@ -31831,17 +32843,17 @@ paths: application/json: schema: type: object - properties: &ref_539 + properties: &ref_543 jobs: type: array items: - oneOf: *ref_192 - discriminator: *ref_193 + oneOf: *ref_193 + discriminator: *ref_194 obscured_jobs: type: array items: type: object - properties: &ref_392 + properties: &ref_393 typ: type: string started_at: @@ -31854,7 +32866,7 @@ paths: Obscured jobs omitted for security because of too specific filtering type: boolean - required: &ref_540 + required: &ref_544 - jobs - obscured_jobs /srch/w/{workspace}/index/search/job: @@ -31898,7 +32910,7 @@ paths: type: array items: type: object - properties: &ref_545 + properties: &ref_549 dancer: type: string hit_count: @@ -31977,7 +32989,7 @@ paths: type: array items: type: object - properties: &ref_546 + properties: &ref_550 dancer: type: string /srch/index/search/count_service_logs: @@ -32115,6 +33127,12 @@ paths: properties: is_alive: type: boolean + state: + type: string + enum: + - running + - stale + - never_started last_locked_at: type: string format: date-time @@ -32136,6 +33154,12 @@ paths: properties: is_alive: type: boolean + state: + type: string + enum: + - running + - stale + - never_started last_locked_at: type: string format: date-time @@ -32240,7 +33264,7 @@ paths: type: string kind: type: string - enum: *ref_300 + enum: *ref_301 usages: type: array items: @@ -32253,13 +33277,13 @@ paths: type: string kind: type: string - enum: &ref_302 + enum: &ref_303 - script - flow - job access_type: type: string - enum: &ref_301 + enum: &ref_302 - r - w - rw @@ -32269,7 +33293,7 @@ paths: description: The columns used (for tables) additionalProperties: type: string - enum: *ref_301 + enum: *ref_302 nullable: true created_at: type: string @@ -32342,7 +33366,7 @@ paths: type: string kind: type: string - enum: *ref_302 + enum: *ref_303 responses: '200': description: all assets used by the given usage paths, in the same order @@ -32362,10 +33386,10 @@ paths: type: string kind: type: string - enum: *ref_300 + enum: *ref_301 access_type: type: string - enum: *ref_301 + enum: *ref_302 nullable: true /w/{workspace}/assets/list_favorites: get: @@ -32413,13 +33437,13 @@ paths: type: array items: type: object - required: &ref_559 + required: &ref_563 - name - size_bytes - file_count - created_at - created_by - properties: &ref_560 + properties: &ref_564 name: type: string size_bytes: @@ -32534,13 +33558,13 @@ paths: type: array items: type: object - required: &ref_375 + required: &ref_376 - name - description - instructions - path - method - properties: &ref_376 + properties: &ref_377 name: type: string description: The tool name/operation ID @@ -32677,7 +33701,7 @@ components: name: id in: path required: true - schema: *ref_303 + schema: *ref_304 Key: name: key in: path @@ -32693,7 +33717,7 @@ components: in: path required: true description: The name of the publication - schema: *ref_263 + schema: *ref_264 VersionId: name: version in: path @@ -32704,7 +33728,7 @@ components: name: token in: path required: true - schema: *ref_304 + schema: *ref_305 AccountId: name: id in: path @@ -32729,7 +33753,7 @@ components: name: id in: path required: true - schema: *ref_171 + schema: *ref_172 Path: name: path in: path @@ -32749,12 +33773,12 @@ components: name: version in: path required: true - schema: *ref_305 + schema: *ref_306 Name: name: name in: path required: true - schema: *ref_272 + schema: *ref_273 Page: name: page description: which page to return (start at 1, default 1) @@ -32773,7 +33797,7 @@ components: '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: *ref_187 + schema: *ref_188 OrderDesc: name: order_desc description: order by desc order (default true) @@ -32794,7 +33818,7 @@ components: 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_173 + schema: *ref_174 Worker: name: worker description: >- @@ -32802,7 +33826,7 @@ components: 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_155 + schema: *ref_156 ParentJob: name: parent_job description: >- @@ -32868,12 +33892,12 @@ components: 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_157 + schema: *ref_158 SchedulePath: name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_158 + schema: *ref_159 TriggerPath: name: trigger_path description: >- @@ -32881,7 +33905,7 @@ components: 'f/trigger1,f/trigger2') and negation by prefixing all values with '!' (e.g. '!f/trigger1,!f/trigger2') in: query - schema: *ref_306 + schema: *ref_307 ScriptExactPath: name: script_path_exact description: >- @@ -32889,87 +33913,87 @@ components: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_156 + schema: *ref_157 ScriptExactHash: name: script_hash description: mask to filter exact matching path in: query - schema: *ref_159 + schema: *ref_160 CreatedBefore: name: created_before description: filter on created before (inclusive) timestamp in: query - schema: *ref_181 + schema: *ref_182 CreatedAfter: name: created_after description: filter on created after (exclusive) timestamp in: query - schema: *ref_182 + schema: *ref_183 StartedBefore: name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_160 + schema: *ref_161 StartedAfter: name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_161 + schema: *ref_162 Before: name: before description: filter on started before (inclusive) timestamp in: query - schema: *ref_298 + schema: *ref_299 CompletedBefore: name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_183 + schema: *ref_184 CompletedAfter: name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_184 + schema: *ref_185 CreatedAfterQueue: name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: *ref_186 + schema: *ref_187 CreatedBeforeQueue: name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: *ref_185 + schema: *ref_186 Success: name: success description: filter on successful jobs in: query - schema: *ref_169 + schema: *ref_170 ScheduledForBeforeNow: name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_163 + schema: *ref_164 Suspended: name: suspended description: filter on suspended jobs in: query - schema: *ref_165 + schema: *ref_166 Running: name: running description: filter on running jobs in: query - schema: *ref_162 + schema: *ref_163 AllowWildcards: name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_170 + schema: *ref_171 ArgsFilter: name: args description: filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_166 + schema: *ref_167 Tag: name: tag description: >- @@ -32977,37 +34001,37 @@ components: 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_167 + schema: *ref_168 ResultFilter: name: result description: filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_168 + schema: *ref_169 After: name: after description: filter on created after (exclusive) timestamp in: query - schema: *ref_299 + schema: *ref_300 Username: name: username description: filter on exact username of user in: query - schema: *ref_307 + schema: *ref_308 Operation: name: operation description: filter on exact or prefix name of operation in: query - schema: *ref_308 + schema: *ref_309 ResourceName: name: resource description: filter on exact or prefix name of resource in: query - schema: *ref_309 + schema: *ref_310 ActionKind: name: action_kind description: filter on type of operation in: query - schema: *ref_310 + schema: *ref_311 JobKinds: name: job_kinds description: >- @@ -33015,29 +34039,29 @@ components: 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_164 + schema: *ref_165 RunnableId: name: runnable_id in: query - schema: *ref_285 + schema: *ref_286 RunnableTypeQuery: name: runnable_type in: query - schema: *ref_286 + schema: *ref_287 InputId: name: input in: path required: true - schema: *ref_311 + schema: *ref_312 GetStarted: name: get_started in: query - schema: *ref_312 + schema: *ref_313 ConcurrencyId: name: concurrency_id in: path required: true - schema: *ref_313 + schema: *ref_314 RunnableKind: name: runnable_kind in: path @@ -33061,7 +34085,7 @@ components: Retry: type: object description: Retry configuration for failed module executions - properties: *ref_196 + properties: *ref_197 StopAfterIf: type: object description: Early termination condition for a module @@ -33204,7 +34228,7 @@ components: retry: description: Retry configuration for failed module executions type: object - properties: *ref_314 + properties: *ref_315 debouncing: description: Debounce configuration for this step (EE only) type: object @@ -33295,7 +34319,7 @@ components: kind: type: string description: Supported AI provider types - enum: *ref_315 + enum: *ref_316 resource: type: string description: >- @@ -33315,16 +34339,16 @@ components: oneOf: - type: object description: No conversation memory/context - properties: *ref_316 - required: *ref_317 + properties: *ref_317 + required: *ref_318 - type: object description: Automatic context management - properties: *ref_318 - required: *ref_319 + properties: *ref_319 + required: *ref_320 - type: object description: Explicit message history - properties: *ref_320 - required: *ref_321 + properties: *ref_321 + required: *ref_322 discriminator: propertyName: kind mapping: @@ -33341,62 +34365,62 @@ components: Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms - properties: *ref_322 - required: *ref_323 + properties: *ref_323 + required: *ref_324 - type: object description: >- Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code - properties: *ref_324 - required: *ref_325 + properties: *ref_325 + required: *ref_326 - type: object description: >- Reference to an existing flow by path. Use this to call another flow as a subflow - properties: *ref_326 - required: *ref_327 + properties: *ref_327 + required: *ref_328 - 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: *ref_328 - required: *ref_329 + properties: *ref_329 + required: *ref_330 - type: object description: >- Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination - properties: *ref_330 - required: *ref_331 + properties: *ref_331 + required: *ref_332 - 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: *ref_332 - required: *ref_333 + properties: *ref_333 + required: *ref_334 - 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: *ref_334 - required: *ref_335 + properties: *ref_335 + required: *ref_336 - type: object description: >- Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder - properties: *ref_336 - required: *ref_337 + properties: *ref_337 + required: *ref_338 - 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: *ref_338 - required: *ref_339 + properties: *ref_339 + required: *ref_340 discriminator: propertyName: type mapping: @@ -33802,8 +34826,8 @@ components: description: >- Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined - oneOf: *ref_340 - discriminator: *ref_341 + oneOf: *ref_341 + discriminator: *ref_342 output_type: allOf: - description: >- @@ -33856,8 +34880,8 @@ components: description: >- Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined - oneOf: *ref_342 - discriminator: *ref_343 + oneOf: *ref_343 + discriminator: *ref_344 output_schema: allOf: - description: >- @@ -33919,6 +34943,19 @@ components: - 0.0 = deterministic, focused responses - 0.7 = balanced (common default) - 1.0+ = more creative/random + max_iterations: + allOf: + - 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_80 + discriminator: *ref_81 + description: > + Number. Limits how many times the agent can loop through + reasoning and tool use. + + Range: 1-1000. required: - provider - user_message @@ -33933,12 +34970,18 @@ components: description: >- A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool - properties: *ref_344 - required: *ref_345 + properties: *ref_345 + required: *ref_346 type: type: string enum: - aiagent + omit_output_from_conversation: + type: boolean + default: false + description: >- + If true, this AI agent step does not persist its assistant or tool + messages to the flow conversation when chat mode is enabled. parallel: type: boolean description: If true, the agent can execute multiple tool calls in parallel @@ -33963,8 +35006,8 @@ components: - type FlowStatus: type: object - properties: *ref_174 - required: *ref_175 + properties: *ref_175 + required: *ref_176 FlowStatusModule: type: object properties: @@ -34201,75 +35244,75 @@ components: HealthChecks: type: object description: Detailed health checks - required: *ref_346 - properties: *ref_347 + required: *ref_347 + properties: *ref_348 DatabaseHealth: type: object description: Database health status - required: *ref_348 - properties: *ref_349 + required: *ref_349 + properties: *ref_350 PoolStats: type: object description: Database connection pool statistics - required: *ref_350 - properties: *ref_351 + required: *ref_351 + properties: *ref_352 WorkersHealth: type: object description: Workers health status - required: *ref_352 - properties: *ref_353 + required: *ref_353 + properties: *ref_354 QueueHealth: type: object description: Job queue status - required: *ref_354 - properties: *ref_355 + required: *ref_355 + properties: *ref_356 ReadinessHealth: type: object description: Server readiness status - required: *ref_356 - properties: *ref_357 + required: *ref_357 + properties: *ref_358 AutoInviteConfig: type: object description: Configuration for auto-inviting users to the workspace - properties: *ref_358 + properties: *ref_359 ErrorHandlerConfig: type: object description: Configuration for the workspace error handler - properties: *ref_359 + properties: *ref_360 SuccessHandlerConfig: type: object description: Configuration for the workspace success handler - properties: *ref_360 + properties: *ref_361 EditErrorHandler: description: >- Request body for editing the workspace error handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: *ref_361 + oneOf: *ref_362 EditErrorHandlerNew: type: object description: New grouped format for editing error handler - properties: *ref_362 + properties: *ref_363 EditErrorHandlerLegacy: type: object description: >- Legacy flat format for editing error handler (deprecated, use new format) - properties: *ref_363 + properties: *ref_364 EditSuccessHandler: description: >- Request body for editing the workspace success handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: *ref_364 + oneOf: *ref_365 EditSuccessHandlerNew: type: object description: New grouped format for editing success handler - properties: *ref_365 + properties: *ref_366 EditSuccessHandlerLegacy: type: object description: >- Legacy flat format for editing success handler (deprecated, use new format) - properties: *ref_366 + properties: *ref_367 VaultSettings: type: object required: *ref_27 @@ -34284,69 +35327,69 @@ components: properties: *ref_34 SecretMigrationFailure: type: object - required: *ref_367 - properties: *ref_368 + required: *ref_368 + properties: *ref_369 SecretMigrationReport: type: object required: *ref_29 properties: *ref_30 JwksResponse: type: object - required: *ref_369 - properties: *ref_370 + required: *ref_370 + properties: *ref_371 FlowConversation: type: object - required: *ref_371 - properties: *ref_372 + required: *ref_372 + properties: *ref_373 FlowConversationMessage: type: object - required: *ref_373 - properties: *ref_374 + required: *ref_374 + properties: *ref_375 EndpointTool: type: object - required: *ref_375 - properties: *ref_376 + required: *ref_376 + properties: *ref_377 AIProvider: type: string - enum: *ref_47 + enum: *ref_51 GitSyncObjectType: type: string - enum: *ref_45 + enum: *ref_48 AIProviderModel: type: object properties: *ref_43 required: *ref_44 AIProviderConfig: type: object - properties: *ref_377 - required: *ref_378 + properties: *ref_378 + required: *ref_379 AIConfig: type: object - properties: *ref_46 + properties: *ref_50 InstanceAIProviderSummary: type: object - properties: *ref_379 - required: *ref_380 + properties: *ref_380 + required: *ref_381 InstanceAISummary: type: object - properties: *ref_48 - required: *ref_49 + properties: *ref_52 + required: *ref_53 Alert: type: object - properties: *ref_381 - required: *ref_382 + properties: *ref_382 + required: *ref_383 Configs: type: object nullable: true - properties: *ref_383 + properties: *ref_384 WorkspaceDependencies: type: object properties: *ref_97 required: *ref_98 NewWorkspaceDependencies: type: object - properties: *ref_384 - required: *ref_385 + properties: *ref_385 + required: *ref_386 Script: type: object properties: *ref_99 @@ -34356,7 +35399,7 @@ components: properties: *ref_104 required: *ref_105 NewScriptWithDraft: - allOf: *ref_386 + allOf: *ref_387 ScriptHistory: type: object properties: *ref_106 @@ -34367,65 +35410,65 @@ components: additionalProperties: true Input: type: object - properties: *ref_287 - required: *ref_288 + properties: *ref_288 + required: *ref_289 CreateInput: type: object - properties: *ref_387 - required: *ref_388 + properties: *ref_388 + required: *ref_389 UpdateInput: type: object - properties: *ref_389 - required: *ref_390 + properties: *ref_390 + required: *ref_391 RunnableType: type: string - enum: *ref_391 + enum: *ref_392 QueuedJob: type: object - properties: *ref_190 - required: *ref_191 + properties: *ref_191 + required: *ref_192 CompletedJob: type: object - properties: *ref_188 - required: *ref_189 + properties: *ref_189 + required: *ref_190 ExportableCompletedJob: type: object description: Completed job with full data for export/import operations - properties: *ref_177 - required: *ref_178 + properties: *ref_178 + required: *ref_179 ExportableQueuedJob: type: object description: Queued job with full data for export/import operations - properties: *ref_179 - required: *ref_180 + properties: *ref_180 + required: *ref_181 ObscuredJob: type: object - properties: *ref_392 + properties: *ref_393 Job: - oneOf: *ref_192 - discriminator: *ref_193 + oneOf: *ref_193 + discriminator: *ref_194 User: type: object properties: *ref_35 required: *ref_36 UserSource: type: object - properties: *ref_393 - required: *ref_394 + properties: *ref_394 + required: *ref_395 UserUsage: type: object - properties: *ref_395 + properties: *ref_396 Login: type: object - properties: *ref_396 - required: *ref_397 + properties: *ref_397 + required: *ref_398 PasswordResetResponse: type: object properties: *ref_7 required: *ref_8 EditWorkspaceUser: type: object - properties: *ref_398 + properties: *ref_399 OffboardAffectedPaths: type: object properties: *ref_11 @@ -34435,64 +35478,64 @@ components: required: *ref_13 OffboardTokenInfo: type: object - properties: *ref_399 - required: *ref_400 + properties: *ref_400 + required: *ref_401 OffboardRequest: type: object - properties: *ref_401 - required: *ref_402 + properties: *ref_402 + required: *ref_403 OffboardResponse: type: object properties: *ref_14 OffboardSummary: type: object - properties: *ref_403 - required: *ref_404 + properties: *ref_404 + required: *ref_405 GlobalOffboardPreview: type: object - properties: *ref_405 - required: *ref_406 + properties: *ref_406 + required: *ref_407 WorkspaceOffboardPreview: type: object - properties: *ref_407 - required: *ref_408 + properties: *ref_408 + required: *ref_409 GlobalOffboardRequest: type: object - properties: *ref_409 + properties: *ref_410 WorkspaceReassignment: type: object - properties: *ref_410 - required: *ref_411 + properties: *ref_411 + required: *ref_412 TruncatedToken: type: object properties: *ref_102 required: *ref_103 ExternalJwtToken: type: object - properties: *ref_412 - required: *ref_413 + properties: *ref_413 + required: *ref_414 NewToken: type: object - properties: *ref_414 + properties: *ref_415 NewTokenImpersonate: type: object - properties: *ref_415 - required: *ref_416 + properties: *ref_416 + required: *ref_417 ListableVariable: type: object properties: *ref_61 required: *ref_62 ContextualVariable: type: object - properties: *ref_417 - required: *ref_418 + properties: *ref_418 + required: *ref_419 CreateVariable: type: object - properties: *ref_419 - required: *ref_420 + properties: *ref_420 + required: *ref_421 EditVariable: type: object - properties: *ref_421 + properties: *ref_422 AuditLog: type: object properties: *ref_5 @@ -34641,51 +35684,51 @@ components: required: *ref_142 PreviewInline: type: object - properties: *ref_422 - required: *ref_423 + properties: *ref_423 + required: *ref_424 InlineScriptArgs: type: object properties: *ref_140 WorkflowTask: type: object - properties: *ref_424 - required: *ref_425 + properties: *ref_425 + required: *ref_426 WorkflowStatusRecord: type: object additionalProperties: type: object - properties: *ref_176 + properties: *ref_177 WorkflowStatus: type: object - properties: *ref_176 + properties: *ref_177 CreateResource: type: object - properties: *ref_426 - required: *ref_427 + properties: *ref_427 + required: *ref_428 EditResource: type: object - properties: *ref_428 + properties: *ref_429 Resource: type: object - properties: *ref_429 - required: *ref_430 + properties: *ref_430 + required: *ref_431 ListableResource: type: object - properties: *ref_431 - required: *ref_432 + properties: *ref_432 + required: *ref_433 ResourceType: type: object properties: *ref_77 required: *ref_78 EditResourceType: type: object - properties: *ref_433 + properties: *ref_434 Schedule: type: object - properties: *ref_197 - required: *ref_198 + properties: *ref_198 + required: *ref_199 ScheduleWJobs: - allOf: *ref_434 + allOf: *ref_435 ErrorHandler: type: string enum: @@ -34695,121 +35738,121 @@ components: - email NewSchedule: type: object - properties: *ref_435 - required: *ref_436 + properties: *ref_436 + required: *ref_437 EditSchedule: type: object - properties: *ref_437 - required: *ref_438 + properties: *ref_438 + required: *ref_439 JobTriggerKind: description: job trigger kind (schedule, http, websocket...) type: string - enum: *ref_172 + enum: *ref_173 TriggerMode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 TriggerExtraProperty: type: object - properties: *ref_213 - required: *ref_214 + properties: *ref_214 + required: *ref_215 AuthenticationMethod: type: string - enum: *ref_205 + enum: *ref_206 RunnableKind: type: string - enum: *ref_199 + enum: *ref_200 OpenapiSpecFormat: type: string - enum: *ref_439 + enum: *ref_440 OpenapiHttpRouteFilters: type: object - properties: *ref_440 - required: *ref_441 + properties: *ref_441 + required: *ref_442 WebhookFilters: type: object - properties: *ref_442 - required: *ref_443 + properties: *ref_443 + required: *ref_444 OpenapiV3Info: type: object - properties: *ref_444 - required: *ref_445 + properties: *ref_445 + required: *ref_446 GenerateOpenapiSpec: type: object - properties: *ref_200 + properties: *ref_201 HttpMethod: type: string - enum: *ref_203 + enum: *ref_204 HttpRequestType: type: string - enum: *ref_204 + enum: *ref_205 HttpTrigger: - allOf: *ref_207 + allOf: *ref_208 type: object - properties: *ref_208 - required: *ref_209 + properties: *ref_209 + required: *ref_210 NewHttpTrigger: type: object - properties: *ref_201 - required: *ref_202 + properties: *ref_202 + required: *ref_203 EditHttpTrigger: type: object - properties: *ref_446 - required: *ref_447 + properties: *ref_447 + required: *ref_448 TriggersCount: type: object properties: *ref_125 WebsocketHeartbeat: type: object - properties: *ref_211 - required: *ref_212 + properties: *ref_212 + required: *ref_213 WebsocketTrigger: - allOf: *ref_215 + allOf: *ref_216 type: object - properties: *ref_216 - required: *ref_217 + properties: *ref_217 + required: *ref_218 NewWebsocketTrigger: type: object - properties: *ref_448 - required: *ref_449 + properties: *ref_449 + required: *ref_450 EditWebsocketTrigger: type: object - properties: *ref_450 - required: *ref_451 + properties: *ref_451 + required: *ref_452 WebsocketTriggerInitialMessage: - anyOf: *ref_210 + anyOf: *ref_211 MqttQoS: type: string - enum: *ref_452 + enum: *ref_453 MqttV3Config: type: object - properties: *ref_237 + properties: *ref_238 MqttV5Config: type: object - properties: *ref_238 + properties: *ref_239 MqttSubscribeTopic: type: object - properties: *ref_235 - required: *ref_236 + properties: *ref_236 + required: *ref_237 MqttClientVersion: type: string - enum: *ref_239 + enum: *ref_240 MqttTrigger: - allOf: *ref_240 + allOf: *ref_241 type: object - properties: *ref_241 - required: *ref_242 + properties: *ref_242 + required: *ref_243 NewMqttTrigger: type: object - properties: *ref_453 - required: *ref_454 + properties: *ref_454 + required: *ref_455 EditMqttTrigger: type: object - properties: *ref_455 - required: *ref_456 + properties: *ref_456 + required: *ref_457 DeliveryType: type: string - enum: *ref_245 + enum: *ref_246 description: >- Delivery mode for messages. 'push' for HTTP push delivery where messages are sent to a webhook endpoint, 'pull' for polling where the trigger @@ -34817,19 +35860,19 @@ components: PushConfig: type: object description: Configuration for push delivery mode. - properties: *ref_246 - required: *ref_247 + properties: *ref_247 + required: *ref_248 GcpTrigger: - allOf: *ref_249 + allOf: *ref_250 type: object description: >- A Google Cloud Pub/Sub trigger that executes a script or flow when messages are received. - properties: *ref_250 - required: *ref_251 + properties: *ref_251 + required: *ref_252 SubscriptionMode: type: string - enum: *ref_248 + enum: *ref_249 description: >- The mode of subscription. 'existing' means using an existing GCP subscription, while 'create_update' involves creating or updating a new @@ -34837,68 +35880,68 @@ components: GcpTriggerData: type: object description: Data for creating or updating a Google Cloud Pub/Sub trigger. - properties: *ref_243 - required: *ref_244 + properties: *ref_244 + required: *ref_245 GetAllTopicSubscription: type: object - properties: *ref_457 - required: *ref_458 + properties: *ref_458 + required: *ref_459 DeleteGcpSubscription: type: object - properties: *ref_459 - required: *ref_460 + properties: *ref_460 + required: *ref_461 AzureMode: type: string - enum: *ref_254 + enum: *ref_255 description: Azure Event Grid trigger mode. AzureArmResource: type: object description: An ARM resource the service principal can see. - properties: *ref_258 - required: *ref_259 + properties: *ref_259 + required: *ref_260 AzureDeleteSubscription: type: object - properties: *ref_461 - required: *ref_462 + properties: *ref_462 + required: *ref_463 AzureTrigger: - allOf: *ref_255 + allOf: *ref_256 type: object description: >- An Azure Event Grid trigger that executes a script or flow when events arrive. - properties: *ref_256 - required: *ref_257 + properties: *ref_257 + required: *ref_258 AzureTriggerData: type: object description: Data for creating or updating an Azure Event Grid trigger. - properties: *ref_252 - required: *ref_253 + properties: *ref_253 + required: *ref_254 TestAzureConnection: type: object - properties: *ref_463 - required: *ref_464 + properties: *ref_464 + required: *ref_465 AzureListTopics: type: object - properties: *ref_465 - required: *ref_466 + properties: *ref_466 + required: *ref_467 AzureListSubscriptions: type: object - properties: *ref_467 - required: *ref_468 + properties: *ref_468 + required: *ref_469 AwsAuthResourceType: type: string - enum: *ref_224 + enum: *ref_225 SqsTrigger: - allOf: *ref_225 + allOf: *ref_226 type: object - properties: *ref_226 - required: *ref_227 + properties: *ref_227 + required: *ref_228 LoggedWizardStatus: type: string enum: *ref_21 CustomInstanceDbLogs: type: object - properties: *ref_469 + properties: *ref_470 CustomInstanceDbTag: type: string enum: *ref_22 @@ -34908,108 +35951,108 @@ components: properties: *ref_24 NewSqsTrigger: type: object - properties: *ref_470 - required: *ref_471 + properties: *ref_471 + required: *ref_472 EditSqsTrigger: type: object - properties: *ref_472 - required: *ref_473 + properties: *ref_473 + required: *ref_474 Slot: type: object - properties: *ref_260 + properties: *ref_261 SlotList: type: object - properties: *ref_474 + properties: *ref_475 PublicationData: type: object - properties: *ref_264 - required: *ref_265 + properties: *ref_265 + required: *ref_266 TableToTrack: type: array - items: *ref_475 + items: *ref_476 Relations: type: object - properties: *ref_261 - required: *ref_262 + properties: *ref_262 + required: *ref_263 Language: type: string - enum: *ref_476 + enum: *ref_477 TemplateScript: type: object - properties: *ref_477 - required: *ref_478 + properties: *ref_478 + required: *ref_479 PostgresTrigger: - allOf: *ref_266 + allOf: *ref_267 type: object - properties: *ref_267 - required: *ref_268 + properties: *ref_268 + required: *ref_269 NewPostgresTrigger: type: object - properties: *ref_479 - required: *ref_480 + properties: *ref_480 + required: *ref_481 EditPostgresTrigger: type: object - properties: *ref_481 - required: *ref_482 + properties: *ref_482 + required: *ref_483 KafkaTrigger: - allOf: *ref_218 + allOf: *ref_219 type: object - properties: *ref_219 - required: *ref_220 + properties: *ref_220 + required: *ref_221 NewKafkaTrigger: type: object - properties: *ref_483 - required: *ref_484 + properties: *ref_484 + required: *ref_485 EditKafkaTrigger: type: object - properties: *ref_485 - required: *ref_486 + properties: *ref_486 + required: *ref_487 NatsTrigger: - allOf: *ref_221 + allOf: *ref_222 type: object - properties: *ref_222 - required: *ref_223 + properties: *ref_223 + required: *ref_224 NewNatsTrigger: type: object - properties: *ref_487 - required: *ref_488 + properties: *ref_488 + required: *ref_489 EditNatsTrigger: type: object - properties: *ref_489 - required: *ref_490 + properties: *ref_490 + required: *ref_491 EmailTrigger: - allOf: *ref_269 + allOf: *ref_270 type: object - properties: *ref_270 - required: *ref_271 + properties: *ref_271 + required: *ref_272 NewEmailTrigger: type: object - properties: *ref_491 - required: *ref_492 + properties: *ref_492 + required: *ref_493 EditEmailTrigger: type: object - properties: *ref_493 - required: *ref_494 + properties: *ref_494 + required: *ref_495 Group: type: object - properties: *ref_277 - required: *ref_278 + properties: *ref_278 + required: *ref_279 InstanceGroup: type: object - required: *ref_495 - properties: *ref_496 + required: *ref_496 + properties: *ref_497 InstanceGroupWithWorkspaces: type: object - required: *ref_273 - properties: *ref_274 + required: *ref_274 + properties: *ref_275 WorkspaceInfo: type: object - properties: *ref_497 - required: *ref_498 + properties: *ref_498 + required: *ref_499 Folder: type: object - properties: *ref_280 - required: *ref_281 + properties: *ref_281 + required: *ref_282 FolderDefaultPermissionedAs: description: > Ordered list of rules applied at create-time when admins or @@ -35017,19 +36060,19 @@ components: `path_glob` matches the item path (relative to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: *ref_279 + items: *ref_280 WorkerPing: type: object - properties: *ref_499 - required: *ref_500 + properties: *ref_500 + required: *ref_501 UserWorkspaceList: type: object - properties: *ref_501 - required: *ref_502 + properties: *ref_502 + required: *ref_503 CreateWorkspace: type: object - properties: *ref_503 - required: *ref_504 + properties: *ref_504 + required: *ref_505 CreateWorkspaceFork: type: object properties: *ref_19 @@ -35040,15 +36083,15 @@ components: required: *ref_16 DependencyMap: type: object - properties: *ref_505 + properties: *ref_506 DependencyDependent: type: object - properties: *ref_506 - required: *ref_507 + properties: *ref_507 + required: *ref_508 DependentsAmount: type: object - properties: *ref_508 - required: *ref_509 + properties: *ref_509 + required: *ref_510 WorkspaceInvite: type: object properties: *ref_41 @@ -35061,20 +36104,20 @@ components: allOf: *ref_124 ExtraPerms: type: object - additionalProperties: *ref_510 + additionalProperties: *ref_511 FlowMetadata: type: object - properties: *ref_511 - required: *ref_512 + properties: *ref_512 + required: *ref_513 OpenFlowWPath: allOf: *ref_126 FlowPreview: type: object - properties: *ref_151 - required: *ref_152 + properties: *ref_152 + required: *ref_153 RestartedFrom: type: object - properties: *ref_513 + properties: *ref_151 Policy: type: object properties: *ref_127 @@ -35134,95 +36177,103 @@ components: enum: *ref_93 PolarsClientKwargs: type: object - properties: *ref_291 - required: *ref_292 + properties: *ref_292 + required: *ref_293 LargeFileStorage: type: object - properties: *ref_50 + properties: *ref_45 DucklakeSettings: type: object - required: *ref_51 - properties: *ref_52 + required: *ref_54 + properties: *ref_55 DataTableSettings: type: object - required: *ref_53 - properties: *ref_54 + required: *ref_46 + properties: *ref_47 DataTableSchema: type: object required: *ref_523 properties: *ref_524 + DataTableTables: + type: object + required: *ref_525 + properties: *ref_526 + DataTableTableSchema: + type: object + required: *ref_527 + properties: *ref_528 DynamicInputData: type: object - properties: *ref_525 - required: *ref_526 + properties: *ref_529 + required: *ref_530 WindmillLargeFile: type: object - properties: *ref_293 - required: *ref_294 + properties: *ref_294 + required: *ref_295 WindmillFileMetadata: type: object - properties: *ref_297 + properties: *ref_298 WindmillFilePreview: type: object - properties: *ref_295 - required: *ref_296 + properties: *ref_296 + required: *ref_297 S3Resource: type: object - properties: *ref_289 - required: *ref_290 + properties: *ref_290 + required: *ref_291 WorkspaceGitSyncSettings: type: object - properties: *ref_55 + properties: *ref_56 WorkspaceDeployUISettings: type: object - properties: *ref_58 + properties: *ref_49 WorkspaceDefaultScripts: type: object properties: *ref_59 S3PermissionRule: - type: object - properties: *ref_527 - required: *ref_528 - GitRepositorySettings: - type: object - properties: *ref_56 - required: *ref_57 - MetricMetadata: - type: object - properties: *ref_529 - required: *ref_530 - ScalarMetric: type: object properties: *ref_531 required: *ref_532 - TimeseriesMetric: + GitRepositorySettings: + type: object + properties: *ref_57 + required: *ref_58 + MetricMetadata: type: object properties: *ref_533 required: *ref_534 - MetricDataPoint: + ScalarMetric: type: object properties: *ref_535 required: *ref_536 + TimeseriesMetric: + type: object + properties: *ref_537 + required: *ref_538 + MetricDataPoint: + type: object + properties: *ref_539 + required: *ref_540 RawScriptForDependencies: type: object properties: *ref_143 required: *ref_144 ConcurrencyGroup: type: object - properties: *ref_537 - required: *ref_538 + properties: *ref_541 + required: *ref_542 ExtendedJobs: type: object - properties: *ref_539 - required: *ref_540 + properties: *ref_543 + required: *ref_544 ExportedUser: type: object properties: *ref_9 required: *ref_10 GlobalSetting: type: object - properties: *ref_541 - required: *ref_542 + properties: *ref_545 + required: *ref_546 InstanceConfig: type: object description: >- @@ -35231,52 +36282,52 @@ components: properties: *ref_26 Config: type: object - properties: *ref_543 - required: *ref_544 + properties: *ref_547 + required: *ref_548 ExportedInstanceGroup: type: object - properties: *ref_275 - required: *ref_276 + properties: *ref_276 + required: *ref_277 JobSearchHit: type: object - properties: *ref_545 + properties: *ref_549 LogSearchHit: type: object - properties: *ref_546 + properties: *ref_550 AutoscalingEvent: type: object - properties: *ref_547 + properties: *ref_551 CriticalAlert: type: object properties: *ref_63 CaptureTriggerKind: type: string - enum: *ref_282 + enum: *ref_283 Capture: type: object - properties: *ref_283 - required: *ref_284 + properties: *ref_284 + required: *ref_285 CaptureConfig: type: object - properties: *ref_548 - required: *ref_549 + properties: *ref_552 + required: *ref_553 OperatorSettings: nullable: true type: object required: *ref_37 properties: *ref_38 WorkspaceComparison: - type: object - required: *ref_550 - properties: *ref_551 - WorkspaceItemDiff: - type: object - required: *ref_552 - properties: *ref_553 - CompareSummary: type: object required: *ref_554 properties: *ref_555 + WorkspaceItemDiff: + type: object + required: *ref_556 + properties: *ref_557 + CompareSummary: + type: object + required: *ref_558 + properties: *ref_559 TeamInfo: type: object required: @@ -35297,12 +36348,12 @@ components: description: List of channels within the team items: type: object - required: &ref_556 + required: &ref_560 - channel_id - channel_name - tenant_id - service_url - properties: &ref_557 + properties: &ref_561 channel_id: type: string description: The unique identifier of the channel @@ -35322,11 +36373,11 @@ components: https://smba.trafficmanager.net/amer/12345678-1234-1234-1234-123456789012/ ChannelInfo: type: object - required: *ref_556 - properties: *ref_557 + required: *ref_560 + properties: *ref_561 GithubInstallations: type: array - items: *ref_558 + items: *ref_562 WorkspaceGithubInstallation: type: object properties: @@ -35367,14 +36418,14 @@ components: minLength: 1 AssetUsageKind: type: string - enum: *ref_302 + enum: *ref_303 AssetUsageAccessType: type: string - enum: *ref_301 + enum: *ref_302 nullable: true AssetKind: type: string - enum: *ref_300 + enum: *ref_301 Asset: type: object properties: @@ -35382,26 +36433,26 @@ components: type: string kind: type: string - enum: *ref_300 + enum: *ref_301 required: - path - kind Volume: type: object - required: *ref_559 - properties: *ref_560 + required: *ref_563 + properties: *ref_564 ProtectionRuleset: type: object description: A workspace protection rule defining restrictions and bypass permissions - required: *ref_561 - properties: *ref_562 + required: *ref_565 + properties: *ref_566 ProtectionRules: type: array description: Configuration of protection restrictions items: *ref_64 ProtectionRuleKind: type: string - enum: *ref_563 + enum: *ref_567 RuleBypasserGroups: type: array description: Groups that can bypass this ruleset @@ -35412,12 +36463,12 @@ components: items: *ref_66 DeploymentRequestEligibleDeployer: type: object - required: *ref_564 - properties: *ref_565 + required: *ref_568 + properties: *ref_569 DeploymentRequestAssignee: type: object - required: *ref_566 - properties: *ref_567 + required: *ref_570 + properties: *ref_571 DeploymentRequestComment: type: object required: *ref_69 @@ -35432,27 +36483,27 @@ components: required: *ref_72 NativeServiceName: type: string - enum: *ref_228 + enum: *ref_229 NativeTrigger: type: object description: A native trigger stored in Windmill - properties: *ref_568 - required: *ref_569 + properties: *ref_572 + required: *ref_573 NativeTriggerWithExternal: type: object description: >- Full trigger response containing both Windmill data and external service data - properties: *ref_570 - required: *ref_571 + properties: *ref_574 + required: *ref_575 WorkspaceIntegrations: type: object - properties: *ref_572 - required: *ref_573 + properties: *ref_576 + required: *ref_577 WorkspaceOAuthConfig: type: object - properties: *ref_229 - required: *ref_230 + properties: *ref_230 + required: *ref_231 WebhookEvent: type: object properties: @@ -35463,7 +36514,7 @@ components: request_type: type: string description: The type of webhook request (define possible values here) - enum: &ref_574 + enum: &ref_578 - async - sync required: @@ -35472,21 +36523,21 @@ components: WebhookRequestType: type: string description: The type of webhook request (define possible values here) - enum: *ref_574 + enum: *ref_578 RedirectUri: type: object - properties: *ref_231 - required: *ref_232 + properties: *ref_232 + required: *ref_233 NativeTriggerData: type: object description: Data for creating or updating a native trigger - properties: *ref_233 - required: *ref_234 + properties: *ref_234 + required: *ref_235 CreateTriggerResponse: type: object description: Response returned when a native trigger is created - properties: *ref_575 - required: *ref_576 + properties: *ref_579 + required: *ref_580 SyncResult: type: object properties: @@ -35509,29 +36560,29 @@ components: - total_external - total_windmill NextCloudEventType: - type: object - properties: *ref_577 - required: *ref_578 - GoogleCalendarEntry: - type: object - properties: *ref_579 - required: *ref_580 - GoogleDriveFile: type: object properties: *ref_581 required: *ref_582 - GoogleDriveFilesResponse: + GoogleCalendarEntry: type: object properties: *ref_583 required: *ref_584 - SharedDriveEntry: + GoogleDriveFile: type: object properties: *ref_585 required: *ref_586 - GithubRepoEntry: + GoogleDriveFilesResponse: type: object properties: *ref_587 required: *ref_588 + SharedDriveEntry: + type: object + properties: *ref_589 + required: *ref_590 + GithubRepoEntry: + type: object + properties: *ref_591 + required: *ref_592 schemas-StaticTransform: type: object description: >- @@ -35567,22 +36618,22 @@ components: Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms - properties: *ref_322 - required: *ref_323 + properties: *ref_323 + required: *ref_324 schemas-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: *ref_324 - required: *ref_325 + properties: *ref_325 + required: *ref_326 schemas-PathFlow: type: object description: >- Reference to an existing flow by path. Use this to call another flow as a subflow - properties: *ref_326 - required: *ref_327 + properties: *ref_327 + required: *ref_328 schemas-FlowModule: type: object description: A single step in a flow. Can be a script, subflow, loop, or branch @@ -35595,96 +36646,96 @@ components: '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: *ref_328 - required: *ref_329 + properties: *ref_329 + required: *ref_330 schemas-WhileloopFlow: type: object description: >- Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination - properties: *ref_330 - required: *ref_331 + properties: *ref_331 + required: *ref_332 schemas-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: *ref_332 - required: *ref_333 + properties: *ref_333 + required: *ref_334 schemas-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: *ref_334 - required: *ref_335 + properties: *ref_335 + required: *ref_336 schemas-Identity: type: object description: >- Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder - properties: *ref_336 - required: *ref_337 + properties: *ref_337 + required: *ref_338 AIProviderKind: type: string description: Supported AI provider types - enum: *ref_315 + enum: *ref_316 schemas-ProviderConfig: type: object description: >- Complete AI provider configuration with resource reference and model selection - properties: *ref_589 - required: *ref_590 + properties: *ref_593 + required: *ref_594 StaticProviderTransform: type: object description: Static provider configuration passed directly to the AI agent - properties: *ref_591 - required: *ref_592 + properties: *ref_595 + required: *ref_596 ProviderTransform: description: >- Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined - oneOf: *ref_340 - discriminator: *ref_341 + oneOf: *ref_341 + discriminator: *ref_342 MemoryOff: type: object description: No conversation memory/context - properties: *ref_316 - required: *ref_317 + properties: *ref_317 + required: *ref_318 MemoryAuto: type: object description: Automatic context management - properties: *ref_318 - required: *ref_319 + properties: *ref_319 + required: *ref_320 MemoryMessage: type: object description: A single message in conversation history - properties: *ref_593 - required: *ref_594 + properties: *ref_597 + required: *ref_598 MemoryManual: type: object description: Explicit message history - properties: *ref_320 - required: *ref_321 + properties: *ref_321 + required: *ref_322 schemas-MemoryConfig: description: Conversation memory configuration - oneOf: *ref_595 - discriminator: *ref_596 + oneOf: *ref_599 + discriminator: *ref_600 StaticMemoryTransform: type: object description: Static memory configuration passed directly to the AI agent - properties: *ref_597 - required: *ref_598 + properties: *ref_601 + required: *ref_602 MemoryTransform: description: >- Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined - oneOf: *ref_342 - discriminator: *ref_343 + oneOf: *ref_343 + discriminator: *ref_344 schemas-FlowModuleValue: description: >- The actual implementation of a flow step. Can be a script (inline or @@ -35695,41 +36746,41 @@ components: description: >- A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module - allOf: *ref_599 + allOf: *ref_603 McpToolValue: type: object description: >- Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers - properties: *ref_600 - required: *ref_601 + properties: *ref_604 + required: *ref_605 WebsearchToolValue: type: object description: >- A tool implemented as a websearch tool. The AI can call this like any other websearch tool - properties: *ref_602 - required: *ref_603 + properties: *ref_606 + required: *ref_607 ToolValue: description: >- The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference - oneOf: *ref_604 - discriminator: *ref_605 + oneOf: *ref_608 + discriminator: *ref_609 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: *ref_344 - required: *ref_345 + properties: *ref_345 + required: *ref_346 schemas-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: *ref_338 - required: *ref_339 + properties: *ref_339 + required: *ref_340 schemas-StopAfterIf: type: object description: Early termination condition for a module @@ -35738,12 +36789,12 @@ components: RetryIf: type: object description: Conditional retry based on error or result - properties: *ref_194 - required: *ref_195 + properties: *ref_195 + required: *ref_196 schemas-Retry: type: object description: Retry configuration for failed module executions - properties: *ref_314 + properties: *ref_315 schemas-FlowNote: type: object description: A sticky note attached to a flow for documentation and annotation @@ -35764,9 +36815,9 @@ components: description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_606 - required: *ref_607 + properties: *ref_610 + required: *ref_611 schemas-FlowStatusModule: type: object - properties: *ref_153 - required: *ref_154 + properties: *ref_154 + required: *ref_155 diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index b5c20d79a7..0ff12ef535 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.706.1 + version: 1.709.0 title: Windmill API contact: @@ -2574,6 +2574,104 @@ paths: - app_slug - client_id + /github_app/ghes/discover: + get: + summary: Discover GHES App installations + description: | + Lists every installation the configured self-managed GitHub App can see, + annotated with the workspaces in this Windmill instance the + installation is currently assigned to. Super-admin only. + operationId: discoverGhesInstallations + tags: + - Git Sync + responses: + "200": + description: Discovered installations + content: + application/json: + schema: + type: array + items: + type: object + required: + - installation_id + - account_id + - assigned_workspaces + properties: + installation_id: + type: integer + format: int64 + account_id: + type: string + description: GitHub login of the installation's account (org or user) + assigned_workspaces: + type: array + items: + type: object + required: + - workspace_id + - provisioned_by_admin + properties: + workspace_id: + type: string + provisioned_by_admin: + type: boolean + + /github_app/ghes/assign: + post: + summary: Assign GHES installation to a workspace + description: | + Assigns a discovered GHES App installation to a workspace. The resulting + installation is marked as admin-provisioned, so workspace admins cannot + remove it. Super-admin only. + operationId: assignGhesInstallation + tags: + - Git Sync + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - workspace_id + - installation_id + properties: + workspace_id: + type: string + installation_id: + type: integer + format: int64 + responses: + "200": + description: Installation assigned + + /github_app/ghes/assign/{workspace_id}/{installation_id}: + delete: + summary: Unassign GHES installation from a workspace + description: | + Removes an installation (admin-provisioned or otherwise) from a + workspace. Super-admin only. Does not affect the installation on the + GitHub side. + operationId: unassignGhesInstallation + tags: + - Git Sync + parameters: + - name: workspace_id + in: path + required: true + schema: + type: string + - name: installation_id + in: path + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: Installation unassigned + /users/accept_invite: post: summary: accept invite to workspace @@ -2718,6 +2816,15 @@ paths: properties: username: type: string + is_admin: + type: boolean + description: Grant the service account workspace admin. Defaults to false. Cannot be combined with operator=true. + operator: + type: boolean + description: Make the service account an operator. Defaults to true for backward compatibility. Set to false to count as a developer (1 seat) instead of 0.5 seat. + add_to_deployers: + type: boolean + description: Add the service account to the workspace `wm_deployers` group on creation. Recommended when the account will be used as a CLI sync / CI deploy identity so it can deploy on behalf of other users. required: - username responses: @@ -17653,6 +17760,38 @@ paths: additionalProperties: type: integer + /workers/workspace_fairness_events: + get: + summary: list last 100 workspace-fairness cap/uncap events (cloud-only) + operationId: getWorkspaceFairnessEvents + tags: + - worker + responses: + "200": + description: workspace fairness events (empty on non-cloud) + content: + application/json: + schema: + type: array + items: + type: object + properties: + timestamp: + type: string + format: date-time + operation: + type: string + workspace_id: + type: string + nullable: true + parameters: + type: object + nullable: true + additionalProperties: true + required: + - timestamp + - operation + /configs/list_worker_groups: get: summary: list worker groups @@ -26542,6 +26681,9 @@ components: type: string operator_only: type: boolean + is_workspace_admin: + type: boolean + description: Populated only for service accounts. True if the service account has workspace admin in its (single) workspace. first_time_user: type: boolean role_source: @@ -27893,6 +28035,13 @@ components: error: type: string description: Error message if token retrieval failed + github_base_url: + type: string + nullable: true + description: Set for self-managed (GHES) installs. Cloud installs omit this field. + provisioned_by_admin: + type: boolean + description: True when the installation was assigned by the instance super-admin from instance settings. Workspace admins cannot remove these. required: - installation_id - account_id diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index e7bc089d7c..6956192f04 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -1,5 +1,3 @@ -#[cfg(feature = "bedrock")] -use crate::bedrock; use crate::db::{ApiAuthed, DB}; use crate::utils::check_scopes; @@ -20,8 +18,12 @@ use windmill_ai::ai_cache::current_instance_ai_config_revision; use windmill_ai::ai_providers::{ empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel, }; +#[cfg(feature = "bedrock")] +use windmill_ai::providers::bedrock::{ + handle_bedrock_proxy, BedrockProxyResponse, BedrockProxyResponseBody, +}; use windmill_ai::providers::{ - create_proxy_query_builder, + create_query_builder, google_ai::{ handle_google_ai_chat_proxy, handle_google_ai_models_proxy, GoogleAIProxyResponse, GoogleAIProxyResponseBody, @@ -112,7 +114,7 @@ lazy_static::lazy_static! { static ref OPENAI_AZURE_BASE_PATH: Option = std::env::var("OPENAI_AZURE_BASE_PATH").ok(); - pub static ref AI_REQUEST_CACHE: Cache<(String, AIProvider), ExpiringAIRequestConfig> = Cache::new(500); + pub static ref AI_REQUEST_CACHE: Cache<(String, AIProvider), ExpiringProviderCredentials> = Cache::new(500); } @@ -179,26 +181,6 @@ enum AIResource { Standard(AIStandardResource), } -#[derive(Deserialize, Clone, Debug)] -struct AIRequestConfig { - pub base_url: String, - pub api_key: Option, - pub access_token: Option, - pub organization_id: Option, - pub user: Option, - #[allow(dead_code)] - pub region: Option, - #[allow(dead_code)] - pub aws_access_key_id: Option, - #[allow(dead_code)] - pub aws_secret_access_key: Option, - #[allow(dead_code)] - pub aws_session_token: Option, - pub platform: AIPlatform, - pub enable_1m_context: bool, - pub custom_headers: HashMap, -} - /// Resolve a `$var:` reference. When `user_db`/`authed` are provided the query /// goes through an RLS-scoped connection so the caller can only read variables /// they are authorised to access. Without auth context the raw pool is used @@ -216,196 +198,142 @@ async fn resolve_var( } } -impl AIRequestConfig { - pub async fn new( - provider: &AIProvider, - db: &DB, - w_id: &str, - resource: AIResource, - authed: Option<&ApiAuthed>, - ) -> Result { - // When authed is provided, resolve $var: references through RLS so that - // users can only read variables they have permission to access. - let user_db = authed.map(|_| UserDB::new(db.clone())); +async fn resolve_provider_credentials( + provider: &AIProvider, + db: &DB, + w_id: &str, + resource: AIResource, + authed: Option<&ApiAuthed>, +) -> Result { + // When authed is provided, resolve $var: references through RLS so that + // users can only read variables they have permission to access. + let user_db = authed.map(|_| UserDB::new(db.clone())); - let ( - api_key, - access_token, - organization_id, - base_url, - user, - region, - aws_access_key_id, - aws_secret_access_key, - aws_session_token, - platform, - enable_1m_context, - custom_headers, - ) = match resource { - AIResource::Standard(resource) => { - let region = resource.region.clone(); - let platform = resource.platform.clone(); - let enable_1m_context = resource.enable_1m_context; - let custom_headers = resource.headers.clone(); - // Skip get_base_url for Bedrock - it uses SDK directly, not HTTP - let base_url = if matches!(provider, AIProvider::AWSBedrock) { - String::new() - } else { - provider.get_base_url(resource.base_url, db).await? - }; - let api_key = if let Some(api_key) = resource.api_key { - Some(resolve_var(api_key, db, w_id, user_db.as_ref(), authed).await?) - } else { - None - }; - let organization_id = if let Some(organization_id) = resource.organization_id { - Some(resolve_var(organization_id, db, w_id, user_db.as_ref(), authed).await?) - } else { - None - }; - let aws_access_key_id = if let Some(access_key_id) = resource.aws_access_key_id { - Some(resolve_var(access_key_id, db, w_id, user_db.as_ref(), authed).await?) - } else { - None - }; - let aws_secret_access_key = if let Some(secret_access_key) = - resource.aws_secret_access_key - { + match resource { + AIResource::Standard(resource) => { + // Skip get_base_url for Bedrock - it uses SDK directly, not HTTP + let base_url = if matches!(provider, AIProvider::AWSBedrock) { + String::new() + } else { + provider.get_base_url(resource.base_url, db).await? + }; + let api_key = if let Some(api_key) = resource.api_key { + Some(resolve_var(api_key, db, w_id, user_db.as_ref(), authed).await?) + } else { + None + }; + let organization_id = if let Some(organization_id) = resource.organization_id { + Some(resolve_var(organization_id, db, w_id, user_db.as_ref(), authed).await?) + } else { + None + }; + let aws_access_key_id = if let Some(access_key_id) = resource.aws_access_key_id { + Some(resolve_var(access_key_id, db, w_id, user_db.as_ref(), authed).await?) + } else { + None + }; + let aws_secret_access_key = + if let Some(secret_access_key) = resource.aws_secret_access_key { Some(resolve_var(secret_access_key, db, w_id, user_db.as_ref(), authed).await?) } else { None }; - let aws_session_token = if let Some(session_token) = resource.aws_session_token { - Some(resolve_var(session_token, db, w_id, user_db.as_ref(), authed).await?) - } else { - None - }; + let aws_session_token = if let Some(session_token) = resource.aws_session_token { + Some(resolve_var(session_token, db, w_id, user_db.as_ref(), authed).await?) + } else { + None + }; - ( - api_key, - None, - organization_id, - base_url, - None, - region, - aws_access_key_id, - aws_secret_access_key, - aws_session_token, - platform, - enable_1m_context, - custom_headers, - ) - } - AIResource::OAuth(resource) => { - let user = if let Some(user) = resource.user.clone() { - Some(resolve_var(user, db, w_id, user_db.as_ref(), authed).await?) - } else { - None - }; - let token = - Self::get_token_using_oauth(resource, db, w_id, user_db.as_ref(), authed) - .await?; - let base_url = provider.get_base_url(None, db).await?; + Ok(ProviderCredentials { + provider: provider.clone(), + base_url, + api_key, + access_token: None, + organization_id, + user: None, + region: resource.region, + aws_access_key_id, + aws_secret_access_key, + aws_session_token, + platform: resource.platform, + enable_1m_context: resource.enable_1m_context, + custom_headers: resource.headers, + }) + } + AIResource::OAuth(resource) => { + let user = if let Some(user) = resource.user.clone() { + Some(resolve_var(user, db, w_id, user_db.as_ref(), authed).await?) + } else { + None + }; + let token = get_token_using_oauth(resource, db, w_id, user_db.as_ref(), authed).await?; + let base_url = provider.get_base_url(None, db).await?; - ( - None, - Some(token), - None, - base_url, - user, - None, - None, - None, - None, - AIPlatform::Standard, - false, - HashMap::new(), - ) - } - }; - - Ok(Self { - base_url, - organization_id, - api_key, - access_token, - user, - region, - aws_access_key_id, - aws_secret_access_key, - aws_session_token, - platform, - enable_1m_context, - custom_headers, - }) - } - - async fn get_token_using_oauth( - mut resource: AIOAuthResource, - db: &DB, - w_id: &str, - user_db: Option<&UserDB>, - authed: Option<&ApiAuthed>, - ) -> Result { - resource.client_id = resolve_var(resource.client_id, db, w_id, user_db, authed).await?; - resource.client_secret = - resolve_var(resource.client_secret, db, w_id, user_db, authed).await?; - resource.token_url = resolve_var(resource.token_url, db, w_id, user_db, authed).await?; - let mut params = HashMap::new(); - params.insert("grant_type", "client_credentials"); - params.insert("scope", "https://cognitiveservices.azure.com/.default"); - let response = HTTP_CLIENT - .post(resource.token_url) - .form(¶ms) - .basic_auth(resource.client_id, Some(resource.client_secret)) - .send() - .await - .and_then(|r| r.error_for_status()) - .map_err(|err| { - Error::internal_err(format!( - "Failed to get access token using credentials flow: {}", - err - )) - })?; - let response = response.json::().await.map_err(|err| { - Error::internal_err(format!( - "Failed to parse access token from credentials flow: {}", - err - )) - })?; - Ok(response.access_token) - } - - fn into_provider_credentials(self, provider: AIProvider) -> ProviderCredentials { - ProviderCredentials { - provider, - base_url: self.base_url, - api_key: self.api_key, - access_token: self.access_token, - organization_id: self.organization_id, - user: self.user, - region: self.region, - aws_access_key_id: self.aws_access_key_id, - aws_secret_access_key: self.aws_secret_access_key, - aws_session_token: self.aws_session_token, - platform: self.platform, - enable_1m_context: self.enable_1m_context, - custom_headers: self.custom_headers, + Ok(ProviderCredentials { + provider: provider.clone(), + base_url, + api_key: None, + access_token: Some(token), + organization_id: None, + user, + region: None, + aws_access_key_id: None, + aws_secret_access_key: None, + aws_session_token: None, + platform: AIPlatform::Standard, + enable_1m_context: false, + custom_headers: HashMap::new(), + }) } } } +async fn get_token_using_oauth( + mut resource: AIOAuthResource, + db: &DB, + w_id: &str, + user_db: Option<&UserDB>, + authed: Option<&ApiAuthed>, +) -> Result { + resource.client_id = resolve_var(resource.client_id, db, w_id, user_db, authed).await?; + resource.client_secret = resolve_var(resource.client_secret, db, w_id, user_db, authed).await?; + resource.token_url = resolve_var(resource.token_url, db, w_id, user_db, authed).await?; + let mut params = HashMap::new(); + params.insert("grant_type", "client_credentials"); + params.insert("scope", "https://cognitiveservices.azure.com/.default"); + let response = HTTP_CLIENT + .post(resource.token_url) + .form(¶ms) + .basic_auth(resource.client_id, Some(resource.client_secret)) + .send() + .await + .and_then(|r| r.error_for_status()) + .map_err(|err| { + Error::internal_err(format!( + "Failed to get access token using credentials flow: {}", + err + )) + })?; + let response = response.json::().await.map_err(|err| { + Error::internal_err(format!( + "Failed to parse access token from credentials flow: {}", + err + )) + })?; + Ok(response.access_token) +} + #[derive(Clone, Debug)] -pub struct ExpiringAIRequestConfig { - config: AIRequestConfig, +pub struct ExpiringProviderCredentials { + credentials: ProviderCredentials, expires_at: std::time::Instant, instance_ai_config_revision: Option, } -impl ExpiringAIRequestConfig { - fn new(config: AIRequestConfig, instance_ai_config_revision: Option) -> Self { +impl ExpiringProviderCredentials { + fn new(credentials: ProviderCredentials, instance_ai_config_revision: Option) -> Self { Self { - config, + credentials, expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60), instance_ai_config_revision, } @@ -540,6 +468,18 @@ fn google_ai_proxy_response_to_body( (response.status_code, response.headers, body) } +#[cfg(feature = "bedrock")] +fn bedrock_proxy_response_to_body( + response: BedrockProxyResponse, +) -> (http::StatusCode, HeaderMap, axum::body::Body) { + let body = match response.body { + BedrockProxyResponseBody::Fixed(body) => axum::body::Body::from(body), + BedrockProxyResponseBody::Stream(stream) => axum::body::Body::from_stream(stream), + }; + + (response.status_code, response.headers, body) +} + pub(crate) fn inject_keepalives( upstream: S, interval: Duration, @@ -608,7 +548,7 @@ async fn global_proxy( enable_1m_context: false, custom_headers: HashMap::new(), }; - let query_builder = create_proxy_query_builder(&credentials); + let query_builder = create_query_builder(&credentials); let proxy_request = query_builder.build_proxy_request(&ProxyBuildArgs { method: &method, path: &ai_path, @@ -701,9 +641,9 @@ async fn proxy( check_scopes(&authed, || format!("resources:read:{}", resource_path))?; } - let request_config = match workspace_cache { + let credentials = match workspace_cache { Some(request_cache) if !request_cache.is_expired() && forced_resource_path.is_none() => { - request_cache.config + request_cache.credentials } _ => { let (resource_path, save_to_cache, resource_workspace, instance_ai_config_revision) = @@ -811,7 +751,7 @@ async fn proxy( } else { None }; - let request_config = AIRequestConfig::new( + let credentials = resolve_provider_credentials( &provider, &db, &resource_workspace, @@ -822,13 +762,13 @@ async fn proxy( if save_to_cache { AI_REQUEST_CACHE.insert( (w_id.clone(), provider.clone()), - ExpiringAIRequestConfig::new( - request_config.clone(), + ExpiringProviderCredentials::new( + credentials.clone(), instance_ai_config_revision, ), ); } - request_config + credentials } }; @@ -862,7 +802,6 @@ async fn proxy( .await?; tx.commit().await?; - let credentials = request_config.into_provider_credentials(provider.clone()); let proxy_args = ProxyBuildArgs { method: &method, path: &ai_path, @@ -885,95 +824,30 @@ async fn proxy( // Handle Bedrock-specific logic when the feature is enabled #[cfg(feature = "bedrock")] - { - // Extract model and streaming flag for Bedrock transformation (only for POST requests) - let (model, is_streaming) = if matches!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock) - && method == Method::POST - { - #[derive(Deserialize, Debug)] - struct BedrockRequest { - model: String, - #[serde(default)] - stream: bool, - } - let parsed: BedrockRequest = serde_json::from_slice(&body) - .map_err(|e| Error::internal_err(format!("Failed to parse request body: {}", e)))?; - (Some(parsed.model), parsed.stream) - } else { - (None, false) - }; + if matches!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock) { + let mut tx = db.begin().await?; + audit_log( + &mut *tx, + &authed, + "ai.request", + ActionKind::Execute, + &w_id, + Some(&authed.email), + Some([("ai_config_path", &format!("{:?}", ai_path)[..])].into()), + ) + .await?; + tx.commit().await?; - // For Bedrock requests, use the SDK-based approach - if matches!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock) { - let region = request_config - .region - .as_deref() - .unwrap_or(windmill_ai::ai_providers::USE_ENV_REGION); + let response = handle_bedrock_proxy(&ProxyBuildArgs { + method: &method, + path: &ai_path, + headers: &headers, + body: &body, + credentials: &credentials, + }) + .await?; - // Audit log before making the SDK request - let mut tx = db.begin().await?; - audit_log( - &mut *tx, - &authed, - "ai.request", - ActionKind::Execute, - &w_id, - Some(&authed.email), - Some([("ai_config_path", &format!("{:?}", ai_path)[..])].into()), - ) - .await?; - tx.commit().await?; - - // Handle GET requests for control plane operations - if method == Method::GET { - if ai_path == "foundation-models" { - return bedrock::list_foundation_models( - request_config.api_key.as_deref(), - request_config.aws_access_key_id.as_deref(), - request_config.aws_secret_access_key.as_deref(), - request_config.aws_session_token.as_deref(), - region, - ) - .await; - } else if ai_path == "inference-profiles" { - return bedrock::list_inference_profiles( - request_config.api_key.as_deref(), - request_config.aws_access_key_id.as_deref(), - request_config.aws_secret_access_key.as_deref(), - request_config.aws_session_token.as_deref(), - region, - ) - .await; - } - } - - // Handle POST requests for inference - if method == Method::POST && model.is_some() { - if is_streaming { - return bedrock::handle_bedrock_sdk_streaming( - model.as_ref().unwrap(), - &body, - request_config.api_key.as_deref(), - request_config.aws_access_key_id.as_deref(), - request_config.aws_secret_access_key.as_deref(), - request_config.aws_session_token.as_deref(), - region, - ) - .await; - } else { - return bedrock::handle_bedrock_sdk_non_streaming( - model.as_ref().unwrap(), - &body, - request_config.api_key.as_deref(), - request_config.aws_access_key_id.as_deref(), - request_config.aws_secret_access_key.as_deref(), - request_config.aws_session_token.as_deref(), - region, - ) - .await; - } - } - } + return Ok(bedrock_proxy_response_to_body(response)); } // When bedrock feature is disabled, return error for Bedrock provider @@ -986,8 +860,7 @@ async fn proxy( let request = match proxy_mode { ProxyExecutionMode::HttpForward => { - let credentials = request_config.into_provider_credentials(provider.clone()); - let query_builder = create_proxy_query_builder(&credentials); + let query_builder = create_query_builder(&credentials); let proxy_request = query_builder.build_proxy_request(&ProxyBuildArgs { method: &method, path: &ai_path, @@ -1053,8 +926,9 @@ mod tests { static TEST_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); - fn sample_request_config() -> AIRequestConfig { - AIRequestConfig { + fn sample_provider_credentials() -> ProviderCredentials { + ProviderCredentials { + provider: AIProvider::OpenAI, base_url: "https://example.com".to_string(), api_key: None, access_token: None, @@ -1070,70 +944,21 @@ mod tests { } } - #[test] - fn maps_request_config_to_provider_credentials() { - let mut custom_headers = HashMap::new(); - custom_headers.insert("X-Test".to_string(), "yes".to_string()); - - let config = AIRequestConfig { - base_url: "https://example.com".to_string(), - api_key: Some("api-key".to_string()), - access_token: Some("access-token".to_string()), - organization_id: Some("org-id".to_string()), - user: Some("user-id".to_string()), - region: Some("us-east-1".to_string()), - aws_access_key_id: Some("aws-access-key".to_string()), - aws_secret_access_key: Some("aws-secret-key".to_string()), - aws_session_token: Some("aws-session-token".to_string()), - platform: AIPlatform::GoogleVertexAi, - enable_1m_context: true, - custom_headers, - }; - - let credentials = config.into_provider_credentials(AIProvider::Anthropic); - - assert_eq!(credentials.provider, AIProvider::Anthropic); - assert_eq!(credentials.base_url, "https://example.com"); - assert_eq!(credentials.api_key.as_deref(), Some("api-key")); - assert_eq!(credentials.access_token.as_deref(), Some("access-token")); - assert_eq!(credentials.organization_id.as_deref(), Some("org-id")); - assert_eq!(credentials.user.as_deref(), Some("user-id")); - assert_eq!(credentials.region.as_deref(), Some("us-east-1")); - assert_eq!( - credentials.aws_access_key_id.as_deref(), - Some("aws-access-key") - ); - assert_eq!( - credentials.aws_secret_access_key.as_deref(), - Some("aws-secret-key") - ); - assert_eq!( - credentials.aws_session_token.as_deref(), - Some("aws-session-token") - ); - assert_eq!(credentials.platform, AIPlatform::GoogleVertexAi); - assert!(credentials.enable_1m_context); - assert_eq!( - credentials.custom_headers.get("X-Test").map(String::as_str), - Some("yes") - ); - } - #[test] fn invalidates_all_cached_providers_for_workspace() { let _guard = TEST_LOCK.lock().unwrap(); AI_REQUEST_CACHE.clear(); AI_REQUEST_CACHE.insert( ("workspace-a".to_string(), AIProvider::OpenAI), - ExpiringAIRequestConfig::new(sample_request_config(), None), + ExpiringProviderCredentials::new(sample_provider_credentials(), None), ); AI_REQUEST_CACHE.insert( ("workspace-a".to_string(), AIProvider::Anthropic), - ExpiringAIRequestConfig::new(sample_request_config(), None), + ExpiringProviderCredentials::new(sample_provider_credentials(), None), ); AI_REQUEST_CACHE.insert( ("workspace-b".to_string(), AIProvider::OpenAI), - ExpiringAIRequestConfig::new(sample_request_config(), None), + ExpiringProviderCredentials::new(sample_provider_credentials(), None), ); invalidate_ai_request_cache_for_workspace("workspace-a"); @@ -1154,8 +979,8 @@ mod tests { let _guard = TEST_LOCK.lock().unwrap(); AI_REQUEST_CACHE.clear(); - let cached = ExpiringAIRequestConfig::new( - sample_request_config(), + let cached = ExpiringProviderCredentials::new( + sample_provider_credentials(), Some(current_instance_ai_config_revision()), ); assert!(!cached.is_expired()); diff --git a/backend/windmill-api/src/bedrock.rs b/backend/windmill-api/src/bedrock.rs deleted file mode 100644 index cc84fda1e8..0000000000 --- a/backend/windmill-api/src/bedrock.rs +++ /dev/null @@ -1,873 +0,0 @@ -//! AWS Bedrock SDK-based operations for the AI chat proxy. -//! -//! This module provides SDK-based request handling for Bedrock: -//! -//! ## Inference (Runtime SDK): -//! - `handle_bedrock_sdk_streaming`: Uses BedrockClient for streaming requests -//! - `handle_bedrock_sdk_non_streaming`: Uses BedrockClient for non-streaming requests -//! - `sdk_stream_to_sse`: Converts SDK ConverseStream events to SSE format -//! -//! ## Control Plane (Bedrock SDK): -//! - `list_foundation_models`: Lists available foundation models -//! - `list_inference_profiles`: Lists inference profiles -//! -//! Shared AWS SDK code is available in `windmill_common::ai_bedrock`, including: -//! - `BedrockClient`: SDK wrapper with bearer token and IAM auth -//! - Stream event parsing functions -//! - Helper utilities - -use axum::body::Bytes; -use serde::Deserialize; -use windmill_ai::ai_bedrock::build_tool_config; -use windmill_ai::ai_bedrock::{ - bedrock_stream_event_is_block_stop, bedrock_stream_event_to_text, - bedrock_stream_event_to_tool_delta, bedrock_stream_event_to_tool_start, format_bedrock_error, - BedrockClient, -}; -use windmill_ai::ai_types::{ - OpenAIFunction, OpenAIMessage, OpenAIToolCall, ToolDef, ToolDefFunction, -}; -use windmill_common::error::{Error, Result}; - -// ============================================================================ -// Shared Request Types for SDK-Based Handlers -// ============================================================================ - -/// OpenAI-format request body for Bedrock SDK handlers -#[derive(Deserialize, Debug)] -struct OpenAIRequest { - messages: Vec, - #[serde(default)] - tools: Option>, - #[serde(default)] - tool_choice: Option, - #[serde(default)] - max_tokens: Option, - #[serde(default)] - temperature: Option, -} - -#[derive(Deserialize, Debug)] -struct OpenAIToolDef { - #[serde(default)] - #[allow(dead_code)] - r#type: Option, - function: OpenAIToolFunction, -} - -#[derive(Deserialize, Debug)] -struct OpenAIToolFunction { - name: String, - #[serde(default)] - description: Option, - #[serde(default)] - parameters: Option, -} - -// ============================================================================ -// Shared Helper Functions for SDK-Based Handlers -// ============================================================================ - -/// Authentication configuration for Bedrock clients -enum BedrockAuthConfig { - BearerToken(String), - IamCredentials { - access_key_id: String, - secret_access_key: String, - session_token: Option, - }, - Environment, -} - -/// Determine auth configuration with priority: bearer token → IAM credentials → environment -fn determine_auth_config( - api_key: Option<&str>, - aws_access_key_id: Option<&str>, - aws_secret_access_key: Option<&str>, - aws_session_token: Option<&str>, -) -> BedrockAuthConfig { - if let Some(key) = api_key.filter(|k| !k.is_empty()) { - BedrockAuthConfig::BearerToken(key.to_string()) - } else if let (Some(access_key_id), Some(secret_access_key)) = ( - aws_access_key_id.filter(|s| !s.is_empty()), - aws_secret_access_key.filter(|s| !s.is_empty()), - ) { - BedrockAuthConfig::IamCredentials { - access_key_id: access_key_id.to_string(), - secret_access_key: secret_access_key.to_string(), - session_token: aws_session_token - .filter(|token| !token.is_empty()) - .map(str::to_string), - } - } else { - BedrockAuthConfig::Environment - } -} - -/// Create a BedrockClient with auth priority: bearer token → IAM credentials → environment -async fn create_bedrock_client( - api_key: Option<&str>, - aws_access_key_id: Option<&str>, - aws_secret_access_key: Option<&str>, - aws_session_token: Option<&str>, - region: &str, -) -> Result { - match determine_auth_config( - api_key, - aws_access_key_id, - aws_secret_access_key, - aws_session_token, - ) { - BedrockAuthConfig::BearerToken(key) => BedrockClient::from_bearer_token(key, region).await, - BedrockAuthConfig::IamCredentials { access_key_id, secret_access_key, session_token } => { - BedrockClient::from_credentials(access_key_id, secret_access_key, session_token, region) - .await - } - BedrockAuthConfig::Environment => BedrockClient::from_env(region).await, - } -} - -/// Convert OpenAIToolDef array to tool configuration for Bedrock SDK -fn build_tool_config_from_request( - tools: Option<&[OpenAIToolDef]>, - tool_choice: Option<&serde_json::Value>, - enable_prompt_caching: bool, -) -> Result> { - if let Some(tools) = tools { - let tool_defs: Vec = tools - .iter() - .map(|t| ToolDef { - r#type: "function".to_string(), - function: ToolDefFunction { - name: t.function.name.clone(), - description: t.function.description.clone(), - parameters: Box::from( - serde_json::value::RawValue::from_string( - serde_json::to_string( - &t.function - .parameters - .clone() - .unwrap_or(serde_json::json!({})), - ) - .unwrap_or_default(), - ) - .unwrap_or_else(|_| { - serde_json::value::RawValue::from_string("{}".to_string()).unwrap() - }), - ), - }, - }) - .collect(); - - // Determine if we should force tool use based on tool_choice - let force_tool_use = tool_choice - .map(|tc| tc == "required" || tc.as_str() == Some("required")) - .unwrap_or(false); - - build_tool_config(Some(&tool_defs), force_tool_use, enable_prompt_caching) - } else { - Ok(None) - } -} - -// ============================================================================ -// Control Plane Operations (using aws-sdk-bedrock) -// ============================================================================ - -/// Create a Bedrock control plane client with auth priority: bearer token → IAM credentials → environment -async fn create_bedrock_control_client( - api_key: Option<&str>, - aws_access_key_id: Option<&str>, - aws_secret_access_key: Option<&str>, - aws_session_token: Option<&str>, - region: &str, -) -> Result { - use aws_config::BehaviorVersion; - use windmill_ai::ai_bedrock::BearerTokenProvider; - - let region_provider = aws_sdk_bedrock::config::Region::new(region.to_string()); - - match determine_auth_config( - api_key, - aws_access_key_id, - aws_secret_access_key, - aws_session_token, - ) { - BedrockAuthConfig::BearerToken(key) => { - let config = aws_sdk_bedrock::config::Builder::new() - .region(region_provider) - .behavior_version(BehaviorVersion::latest()) - .token_provider(BearerTokenProvider::new(key)) - .build(); - Ok(aws_sdk_bedrock::Client::from_conf(config)) - } - BedrockAuthConfig::IamCredentials { access_key_id, secret_access_key, session_token } => { - let credentials = aws_credential_types::Credentials::new( - access_key_id, - secret_access_key, - session_token, - None, - "windmill", - ); - let config = aws_sdk_bedrock::config::Builder::new() - .region(region_provider) - .behavior_version(BehaviorVersion::latest()) - .credentials_provider(credentials) - .build(); - Ok(aws_sdk_bedrock::Client::from_conf(config)) - } - BedrockAuthConfig::Environment => { - let config = aws_config::defaults(BehaviorVersion::latest()) - .region(region_provider) - .load() - .await; - Ok(aws_sdk_bedrock::Client::new(&config)) - } - } -} - -/// List foundation models using the Bedrock SDK -pub async fn list_foundation_models( - api_key: Option<&str>, - aws_access_key_id: Option<&str>, - aws_secret_access_key: Option<&str>, - aws_session_token: Option<&str>, - region: &str, -) -> Result<(http::StatusCode, http::HeaderMap, axum::body::Body)> { - let client = create_bedrock_control_client( - api_key, - aws_access_key_id, - aws_secret_access_key, - aws_session_token, - region, - ) - .await?; - - let response = client - .list_foundation_models() - .send() - .await - .map_err(|e| Error::internal_err(format!("Failed to list foundation models: {}", e)))?; - - // Convert to JSON response - let models: Vec = response - .model_summaries() - .iter() - .map(|m| { - serde_json::json!({ - "modelId": m.model_id(), - "modelName": m.model_name(), - "providerName": m.provider_name(), - "modelArn": m.model_arn(), - "inputModalities": m.input_modalities().iter().map(|i| i.as_str()).collect::>(), - "outputModalities": m.output_modalities().iter().map(|o| o.as_str()).collect::>(), - "responseStreamingSupported": m.response_streaming_supported(), - "inferenceTypesSupported": m.inference_types_supported().iter().map(|i| i.as_str()).collect::>(), - }) - }) - .collect(); - - let body = serde_json::json!({ "modelSummaries": models }); - let body_bytes = serde_json::to_vec(&body) - .map_err(|e| Error::internal_err(format!("Failed to serialize response: {}", e)))?; - - let mut headers = http::HeaderMap::new(); - headers.insert("content-type", "application/json".parse().unwrap()); - - Ok(( - http::StatusCode::OK, - headers, - axum::body::Body::from(body_bytes), - )) -} - -/// List inference profiles using the Bedrock SDK -pub async fn list_inference_profiles( - api_key: Option<&str>, - aws_access_key_id: Option<&str>, - aws_secret_access_key: Option<&str>, - aws_session_token: Option<&str>, - region: &str, -) -> Result<(http::StatusCode, http::HeaderMap, axum::body::Body)> { - let client = create_bedrock_control_client( - api_key, - aws_access_key_id, - aws_secret_access_key, - aws_session_token, - region, - ) - .await?; - - let response = - client.list_inference_profiles().send().await.map_err(|e| { - Error::internal_err(format!("Failed to list inference profiles: {}", e)) - })?; - - // Convert to JSON response - let profiles: Vec = response - .inference_profile_summaries() - .iter() - .map(|p| { - serde_json::json!({ - "inferenceProfileId": p.inference_profile_id(), - "inferenceProfileName": p.inference_profile_name(), - "inferenceProfileArn": p.inference_profile_arn(), - "description": p.description(), - "status": p.status().as_str(), - "type": p.r#type().as_str(), - }) - }) - .collect(); - - let body = serde_json::json!({ "inferenceProfileSummaries": profiles }); - let body_bytes = serde_json::to_vec(&body) - .map_err(|e| Error::internal_err(format!("Failed to serialize response: {}", e)))?; - - let mut headers = http::HeaderMap::new(); - headers.insert("content-type", "application/json".parse().unwrap()); - - Ok(( - http::StatusCode::OK, - headers, - axum::body::Body::from(body_bytes), - )) -} - -// ============================================================================ -// Inference Operations (using aws-sdk-bedrockruntime) -// ============================================================================ - -/// Handle Bedrock streaming request using the AWS SDK. -/// -/// This function uses the shared BedrockClient to make streaming requests -/// and converts the SDK stream events to SSE format for the proxy response. -/// -/// Auth priority: bearer token → IAM credentials → environment credentials -pub async fn handle_bedrock_sdk_streaming( - model: &str, - body: &Bytes, - api_key: Option<&str>, - aws_access_key_id: Option<&str>, - aws_secret_access_key: Option<&str>, - aws_session_token: Option<&str>, - region: &str, -) -> Result<(http::StatusCode, http::HeaderMap, axum::body::Body)> { - let openai_req: OpenAIRequest = serde_json::from_slice(body) - .map_err(|e| Error::internal_err(format!("Failed to parse OpenAI request: {}", e)))?; - - // Create Bedrock client using shared helper - let bedrock_client = create_bedrock_client( - api_key, - aws_access_key_id, - aws_secret_access_key, - aws_session_token, - region, - ) - .await?; - - // Convert messages using shared conversion - let enable_prompt_caching = - windmill_ai::ai_bedrock::bedrock_model_supports_prompt_caching(model); - let (bedrock_messages, system_prompts) = - windmill_ai::ai_bedrock::openai_messages_to_bedrock( - &openai_req.messages, - enable_prompt_caching, - )?; - - // Build inference configuration - let inference_config = windmill_ai::ai_bedrock::create_inference_config( - openai_req.temperature, - openai_req.max_tokens, - ); - - // Convert tools using shared helper - let tool_config = build_tool_config_from_request( - openai_req.tools.as_deref(), - openai_req.tool_choice.as_ref(), - enable_prompt_caching, - )?; - - // Build the SDK request - let mut request_builder = bedrock_client - .client() - .converse_stream() - .model_id(model) - .set_messages(Some(bedrock_messages)); - - if !system_prompts.is_empty() { - request_builder = request_builder.set_system(Some(system_prompts)); - } - - if let Some(config) = inference_config { - request_builder = request_builder.inference_config(config); - } - - if let Some(config) = tool_config { - request_builder = request_builder.set_tool_config(Some(config)); - } - - // Send the request and get the stream - tracing::debug!("Bedrock SDK streaming: sending converse_stream request"); - let stream_output = request_builder.send().await.map_err(|e| { - let error_msg = format!("Bedrock SDK streaming error: {}", format_bedrock_error(&e)); - tracing::error!("Bedrock SDK streaming failed: {}", error_msg); - Error::internal_err(error_msg) - })?; - tracing::debug!("Bedrock SDK streaming: stream established successfully"); - - // Convert SDK stream to SSE (pass the inner stream, not the full output) - let sse_stream = sdk_stream_to_sse(stream_output.stream, model.to_string()); - - // Build response headers - let mut response_headers = http::HeaderMap::new(); - response_headers.insert("content-type", "text/event-stream".parse().unwrap()); - response_headers.insert("cache-control", "no-cache".parse().unwrap()); - response_headers.insert("connection", "keep-alive".parse().unwrap()); - - Ok(( - http::StatusCode::OK, - response_headers, - axum::body::Body::from_stream(sse_stream), - )) -} - -/// Convert AWS SDK ConverseStream events to SSE format. -/// -/// Uses shared stream parsing functions from windmill_common::ai_bedrock -/// to extract text deltas and tool calls from the SDK stream events. -pub fn sdk_stream_to_sse( - stream: aws_sdk_bedrockruntime::primitives::event_stream::EventReceiver< - aws_sdk_bedrockruntime::types::ConverseStreamOutput, - aws_sdk_bedrockruntime::types::error::ConverseStreamOutputError, - >, - model: String, -) -> impl futures::Stream> + Send { - use std::collections::HashMap; - - let id = format!("chatcmpl-{}", uuid::Uuid::new_v4().simple()); - let created = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - // State to track partial tool calls - struct StreamState { - id: String, - model: String, - created: u64, - tool_calls: HashMap, // index -> (id, name, args) - current_tool_index: usize, - } - - let state = std::sync::Arc::new(tokio::sync::Mutex::new(StreamState { - id: id.clone(), - model: model.clone(), - created, - tool_calls: HashMap::new(), - current_tool_index: 0, - })); - - async_stream::stream! { - let mut stream = stream; - let state = state.clone(); - - loop { - match stream.recv().await { - Ok(Some(event)) => { - let mut state = state.lock().await; - - // Handle tool use start - if let Some(tool_call) = bedrock_stream_event_to_tool_start(&event) { - let index = state.current_tool_index; - state.tool_calls.insert( - index, - (tool_call.id.clone(), tool_call.name.clone(), String::new()), - ); - - // Send initial tool call chunk - let chunk = serde_json::json!({ - "id": state.id, - "object": "chat.completion.chunk", - "created": state.created, - "model": state.model, - "choices": [{ - "index": 0, - "delta": { - "tool_calls": [{ - "index": index, - "id": tool_call.id, - "type": "function", - "function": { - "name": tool_call.name, - "arguments": "" - } - }] - }, - "finish_reason": serde_json::Value::Null - }] - }); - - yield Ok(bytes::Bytes::from(format!("data: {}\n\n", chunk))); - } - - // Handle text delta - if let Some(text) = bedrock_stream_event_to_text(&event) { - let chunk = serde_json::json!({ - "id": state.id, - "object": "chat.completion.chunk", - "created": state.created, - "model": state.model, - "choices": [{ - "index": 0, - "delta": { - "content": text - }, - "finish_reason": serde_json::Value::Null - }] - }); - - yield Ok(bytes::Bytes::from(format!("data: {}\n\n", chunk))); - } - - // Handle tool use input delta - if let Some(input_delta) = bedrock_stream_event_to_tool_delta(&event) { - let index = state.current_tool_index; - if let Some((_id, _name, ref mut args)) = state.tool_calls.get_mut(&index) { - args.push_str(&input_delta); - - let chunk = serde_json::json!({ - "id": state.id, - "object": "chat.completion.chunk", - "created": state.created, - "model": state.model, - "choices": [{ - "index": 0, - "delta": { - "tool_calls": [{ - "index": index, - "function": { - "arguments": input_delta - } - }] - }, - "finish_reason": serde_json::Value::Null - }] - }); - - yield Ok(bytes::Bytes::from(format!("data: {}\n\n", chunk))); - } - } - - // Handle content block stop - if bedrock_stream_event_is_block_stop(&event) { - state.current_tool_index += 1; - } - - // Handle message stop - if let aws_sdk_bedrockruntime::types::ConverseStreamOutput::MessageStop(stop) = &event { - let stop_reason = stop.stop_reason().as_str(); - let finish_reason = match stop_reason { - "end_turn" => "stop", - "max_tokens" => "length", - "tool_use" => "tool_calls", - "stop_sequence" => "stop", - "guardrail_intervened" | "content_filtered" => "content_filter", - _ => "stop", - }; - - let chunk = serde_json::json!({ - "id": state.id, - "object": "chat.completion.chunk", - "created": state.created, - "model": state.model, - "choices": [{ - "index": 0, - "delta": {}, - "finish_reason": finish_reason - }] - }); - - yield Ok(bytes::Bytes::from(format!("data: {}\n\n", chunk))); - } - } - Ok(None) => break, - Err(e) => { - yield Err(std::io::Error::new( - std::io::ErrorKind::Other, - e.to_string(), - )); - break; - } - } - } - - // Send [DONE] at the end - yield Ok(bytes::Bytes::from("data: [DONE]\n\n")); - } -} - -/// Handle non-streaming Bedrock request using the AWS SDK. -/// -/// Auth priority: bearer token → IAM credentials → environment credentials -pub async fn handle_bedrock_sdk_non_streaming( - model: &str, - body: &Bytes, - api_key: Option<&str>, - aws_access_key_id: Option<&str>, - aws_secret_access_key: Option<&str>, - aws_session_token: Option<&str>, - region: &str, -) -> Result<(http::StatusCode, http::HeaderMap, axum::body::Body)> { - let openai_req: OpenAIRequest = serde_json::from_slice(body) - .map_err(|e| Error::internal_err(format!("Failed to parse OpenAI request: {}", e)))?; - - // Create Bedrock client using shared helper - let bedrock_client = create_bedrock_client( - api_key, - aws_access_key_id, - aws_secret_access_key, - aws_session_token, - region, - ) - .await?; - - // Convert messages using shared conversion - let enable_prompt_caching = - windmill_ai::ai_bedrock::bedrock_model_supports_prompt_caching(model); - let (bedrock_messages, system_prompts) = - windmill_ai::ai_bedrock::openai_messages_to_bedrock( - &openai_req.messages, - enable_prompt_caching, - )?; - - // Build inference configuration - let inference_config = windmill_ai::ai_bedrock::create_inference_config( - openai_req.temperature, - openai_req.max_tokens, - ); - - // Convert tools using shared helper - let tool_config = build_tool_config_from_request( - openai_req.tools.as_deref(), - openai_req.tool_choice.as_ref(), - enable_prompt_caching, - )?; - - // Build the SDK request (non-streaming) - let mut request_builder = bedrock_client - .client() - .converse() - .model_id(model) - .set_messages(Some(bedrock_messages)); - - if !system_prompts.is_empty() { - request_builder = request_builder.set_system(Some(system_prompts)); - } - - if let Some(config) = inference_config { - request_builder = request_builder.inference_config(config); - } - - if let Some(config) = tool_config { - request_builder = request_builder.set_tool_config(Some(config)); - } - - // Send the request - tracing::debug!("Bedrock SDK non-streaming: sending converse request"); - let response = request_builder.send().await.map_err(|e| { - let error_msg = format!( - "Bedrock SDK non-streaming error: {}", - format_bedrock_error(&e) - ); - tracing::error!("Bedrock SDK non-streaming failed: {}", error_msg); - Error::internal_err(error_msg) - })?; - tracing::debug!( - "Bedrock SDK non-streaming: response received, stop_reason={}", - response.stop_reason().as_str() - ); - - // Convert response to OpenAI format - let id = format!("chatcmpl-{}", uuid::Uuid::new_v4().simple()); - let created = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - // Extract stop reason - let stop_reason = response.stop_reason().as_str(); - let finish_reason = match stop_reason { - "end_turn" => "stop", - "max_tokens" => "length", - "tool_use" => "tool_calls", - "stop_sequence" => "stop", - "guardrail_intervened" | "content_filtered" => "content_filter", - _ => "stop", - }; - - // Extract message content - let mut text_content = String::new(); - let mut tool_calls: Vec = Vec::new(); - - if let Some(output) = response.output() { - if let aws_sdk_bedrockruntime::types::ConverseOutput::Message(message) = output { - for block in message.content() { - match block { - aws_sdk_bedrockruntime::types::ContentBlock::Text(text) => { - text_content.push_str(text); - } - aws_sdk_bedrockruntime::types::ContentBlock::ToolUse(tool_use) => { - // Convert Document back to JSON string - let input_json = document_to_json(tool_use.input()); - tool_calls.push(OpenAIToolCall { - id: tool_use.tool_use_id().to_string(), - function: OpenAIFunction { - name: tool_use.name().to_string(), - arguments: serde_json::to_string(&input_json).unwrap_or_default(), - }, - r#type: "function".to_string(), - extra_content: None, - }); - } - _ => {} - } - } - } - } - - // Build the message - let message = if !tool_calls.is_empty() { - serde_json::json!({ - "role": "assistant", - "content": if text_content.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(text_content) }, - "tool_calls": tool_calls - }) - } else { - serde_json::json!({ - "role": "assistant", - "content": text_content - }) - }; - - // Extract usage information - let usage = if let Some(usage_data) = response.usage() { - serde_json::json!({ - "prompt_tokens": usage_data.input_tokens(), - "completion_tokens": usage_data.output_tokens(), - "total_tokens": usage_data.total_tokens() - }) - } else { - serde_json::json!({ - "prompt_tokens": 0, - "completion_tokens": 0, - "total_tokens": 0 - }) - }; - - // Build OpenAI-format response - let openai_resp = serde_json::json!({ - "id": id, - "object": "chat.completion", - "created": created, - "model": model, - "choices": [{ - "index": 0, - "message": message, - "finish_reason": finish_reason - }], - "usage": usage - }); - - let response_body = serde_json::to_vec(&openai_resp) - .map_err(|e| Error::internal_err(format!("Failed to serialize OpenAI response: {}", e)))?; - - let mut response_headers = http::HeaderMap::new(); - response_headers.insert("content-type", "application/json".parse().unwrap()); - - Ok(( - http::StatusCode::OK, - response_headers, - axum::body::Body::from(response_body), - )) -} - -/// Convert AWS Smithy Document to serde_json::Value -fn document_to_json(doc: &aws_smithy_types::Document) -> serde_json::Value { - match doc { - aws_smithy_types::Document::Object(map) => { - let mut json_map = serde_json::Map::new(); - for (k, v) in map { - json_map.insert(k.clone(), document_to_json(v)); - } - serde_json::Value::Object(json_map) - } - aws_smithy_types::Document::Array(arr) => { - serde_json::Value::Array(arr.iter().map(document_to_json).collect()) - } - aws_smithy_types::Document::Number(num) => match num { - aws_smithy_types::Number::PosInt(n) => serde_json::Value::Number((*n).into()), - aws_smithy_types::Number::NegInt(n) => serde_json::Value::Number((*n).into()), - aws_smithy_types::Number::Float(f) => serde_json::json!(*f), - }, - aws_smithy_types::Document::String(s) => serde_json::Value::String(s.clone()), - aws_smithy_types::Document::Bool(b) => serde_json::Value::Bool(*b), - aws_smithy_types::Document::Null => serde_json::Value::Null, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn determine_auth_config_prioritizes_bearer_token() { - let config = determine_auth_config( - Some("bearer-token"), - Some("AKIA123"), - Some("secret"), - Some("session-token"), - ); - - match config { - BedrockAuthConfig::BearerToken(token) => assert_eq!(token, "bearer-token"), - _ => panic!("expected bearer token auth config"), - } - } - - #[test] - fn determine_auth_config_uses_iam_with_optional_session_token() { - let config = - determine_auth_config(None, Some("AKIA123"), Some("secret"), Some("session-token")); - - match config { - BedrockAuthConfig::IamCredentials { - access_key_id, - secret_access_key, - session_token, - } => { - assert_eq!(access_key_id, "AKIA123"); - assert_eq!(secret_access_key, "secret"); - assert_eq!(session_token.as_deref(), Some("session-token")); - } - _ => panic!("expected IAM auth config"), - } - } - - #[test] - fn determine_auth_config_treats_empty_session_token_as_none() { - let config = determine_auth_config(None, Some("AKIA123"), Some("secret"), Some("")); - - match config { - BedrockAuthConfig::IamCredentials { session_token, .. } => { - assert!(session_token.is_none()); - } - _ => panic!("expected IAM auth config"), - } - } - - #[test] - fn determine_auth_config_falls_back_to_environment() { - let config = determine_auth_config(None, Some("AKIA123"), None, Some("session-token")); - assert!(matches!(config, BedrockAuthConfig::Environment)); - } -} diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 34aa593d8f..875a1e80d2 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -7218,6 +7218,7 @@ async fn get_job_update( None, false, &mut false, + &mut false, ) .await?, )) @@ -7308,6 +7309,10 @@ pub fn start_job_update_sse_stream( // Latched once the early_return node's failure is observed alongside a // failure_module — subsequent polls then skip the redundant per-node lookup. let mut early_return_suppressed = false; + // Latched once we've verified the job was created by "anonymous" — for + // unauthenticated SSE streams, this gates access and is checked once per + // stream rather than once per poll (created_by cannot change). + let mut anonymous_verified = false; // Send initial update immediately let mut running = running; @@ -7332,6 +7337,7 @@ pub fn start_job_update_sse_stream( early_return.as_deref(), has_failure_module, &mut early_return_suppressed, + &mut anonymous_verified, ) .await { @@ -7452,6 +7458,7 @@ pub fn start_job_update_sse_stream( early_return.as_deref(), has_failure_module, &mut early_return_suppressed, + &mut anonymous_verified, ) .await { @@ -7585,6 +7592,7 @@ async fn get_job_update_data( early_return: Option<&str>, has_failure_module: bool, early_return_suppressed: &mut bool, + anonymous_verified: &mut bool, ) -> error::Result { let tags = if log_view { log_job_view( @@ -7606,6 +7614,32 @@ async fn get_job_update_data( let ignore_flow_stream_job_id = is_flow.is_some_and(|x| !x) || flow_stream_job_id.is_some(); if only_result.unwrap_or(false) { + // Unauthenticated callers may only read jobs whose creator is "anonymous". + // The non-only_result branch enforces this via `record.created_by` from its + // main query, but the only_result branch below fetches solely the result by + // (workspace_id, job_id), so we guard here to close the gap. The + // `anonymous_verified` flag is preserved across SSE poll iterations so the + // lookup only happens once per stream — `created_by` cannot change for a + // given job once it has been created. + if opt_authed.is_none() && !*anonymous_verified { + let created_by = sqlx::query_scalar!( + "SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2", + job_id, + w_id, + ) + .fetch_optional(db) + .await? + .ok_or_else(|| Error::NotFound(format!("Job not found: {}", job_id)))?; + + if created_by != "anonymous" { + return Err(Error::BadRequest( + "As a non logged in user, you can only see jobs ran by anonymous users" + .to_string(), + )); + } + *anonymous_verified = true; + } + let (result, running, mut result_stream, mut new_stream_offset, new_flow_stream_job_id) = if let Some(tags) = tags { let r = sqlx::query!( diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index e7e4508971..8b2b9d8128 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -73,8 +73,6 @@ pub mod auth; #[cfg(all(feature = "private", feature = "parquet"))] pub mod azure_proxy_ee; mod azure_proxy_oss; -#[cfg(feature = "bedrock")] -mod bedrock; mod capture; mod concurrency_groups; mod db; diff --git a/backend/windmill-common/src/error.rs b/backend/windmill-common/src/error.rs index 49dcf25450..f202bf27f9 100644 --- a/backend/windmill-common/src/error.rs +++ b/backend/windmill-common/src/error.rs @@ -72,7 +72,7 @@ pub enum Error { ExecutionRawError(Box), #[error("Error: {error:#} @{location:#}")] Anyhow { error: anyhow::Error, location: String }, - #[error("Error: {0:#?}")] + #[error("{}", format_json_err_message(.0))] JsonErr(serde_json::Value), #[error("{0}")] AIError(String), @@ -256,6 +256,7 @@ impl IntoResponse for Error { Self::SqlErr { .. } | Self::BadRequest(_) | Self::AIError(_) + | Self::JsonErr(_) | Self::QuotaExceeded(_) => axum::http::StatusCode::BAD_REQUEST, Self::BadGateway(_) => axum::http::StatusCode::BAD_GATEWAY, Self::Generic(status_code, _) => status_code, @@ -280,6 +281,59 @@ impl IntoResponse for Error { } } +/// Render a `JsonErr` payload as a readable message suitable for direct +/// display in a toast: surface the `error` field as the headline, append a +/// short summary of `details` (e.g. duplicate paths) when present, and fall +/// back to pretty JSON for unknown shapes. Avoids the Rust `Debug` output +/// (`Object { "error": String("..."), ... }`) that previously leaked to users. +fn format_json_err_message(v: &serde_json::Value) -> String { + if let Some(obj) = v.as_object() { + let headline = obj + .get("error") + .and_then(|e| e.as_str()) + .map(|s| s.to_string()); + let details_summary = obj.get("details").and_then(|d| { + let arr = d.as_array()?; + if arr.is_empty() { + return None; + } + let preview = arr + .iter() + .take(5) + .map(|item| match item { + serde_json::Value::Object(o) => { + let parts: Vec = o + .iter() + .map(|(k, val)| match val { + serde_json::Value::String(s) => format!("{k}={s}"), + _ => format!("{k}={val}"), + }) + .collect(); + format!("- {}", parts.join(", ")) + } + serde_json::Value::String(s) => format!("- {s}"), + other => format!("- {other}"), + }) + .collect::>() + .join("\n"); + let suffix = if arr.len() > 5 { + format!("\n... ({} more)", arr.len() - 5) + } else { + String::new() + }; + Some(format!("{preview}{suffix}")) + }); + + match (headline, details_summary) { + (Some(h), Some(d)) => return format!("{h}\n{d}"), + (Some(h), None) => return h, + (None, Some(d)) => return d, + (None, None) => {} + } + } + serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string()) +} + pub trait OrElseNotFound { fn or_else_not_found(self, s: impl ToString) -> Result; } @@ -316,3 +370,53 @@ where Self(err.into()) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn json_err_message_error_and_details() { + let v = json!({ + "error": "Duplicate HTTP route paths detected", + "details": [ + { "route_path": "a", "workspace_id": "admins", "http_method": "post" }, + { "route_path": "a", "workspace_id": "starter", "http_method": "post" }, + ], + }); + let rendered = Error::JsonErr(v).to_string(); + assert_eq!( + rendered, + "Duplicate HTTP route paths detected\n\ + - route_path=a, workspace_id=admins, http_method=post\n\ + - route_path=a, workspace_id=starter, http_method=post" + ); + } + + #[test] + fn json_err_message_error_only() { + let v = json!({ "error": "Something went wrong" }); + assert_eq!(Error::JsonErr(v).to_string(), "Something went wrong"); + } + + #[test] + fn json_err_message_truncates_long_details() { + let details: Vec<_> = (0..8).map(|i| json!({ "k": i })).collect(); + let v = json!({ "error": "boom", "details": details }); + let rendered = Error::JsonErr(v).to_string(); + assert!(rendered.starts_with("boom\n- k=0\n- k=1\n- k=2\n- k=3\n- k=4")); + assert!(rendered.ends_with("... (3 more)")); + // Items beyond the cap aren't enumerated. + assert!(!rendered.contains("- k=5")); + } + + #[test] + fn json_err_message_fallback_to_pretty_json() { + let v = json!([1, 2, 3]); + // Non-object payload falls back to pretty JSON instead of leaking + // Rust `Debug` syntax. + let rendered = Error::JsonErr(v).to_string(); + assert_eq!(rendered, "[\n 1,\n 2,\n 3\n]"); + } +} diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 5b14d809bd..75f3fdf06b 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -86,6 +86,15 @@ pub const WORKSPACE_REGISTRIES_SETTING: &str = "workspace_registries"; pub const RESTART_COORDINATION_SETTING: &str = "_restart_coordination"; pub const ALERT_CONFIG_SETTING: &str = "alert_job_queue_waiting"; +// Workspace fairness: cloud-only mechanism that caps any single workspace at +// `workspace_fairness_max_percent`% of the shared worker pool once it has been +// occupying it for more than `workspace_fairness_duration_secs` seconds. See +// `windmill-queue/src/workspace_fairness.rs`. +pub const WORKSPACE_FAIRNESS_ENABLED_SETTING: &str = "workspace_fairness_enabled"; +pub const WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING: &str = "workspace_fairness_max_percent"; +pub const WORKSPACE_FAIRNESS_DURATION_SECS_SETTING: &str = "workspace_fairness_duration_secs"; +pub const WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING: &str = "workspace_fairness_min_total_jobs"; + use std::sync::atomic::AtomicBool; lazy_static::lazy_static! { diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 44b5721553..4b6abac8dd 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -260,6 +260,12 @@ lazy_static::lazy_static! { pub static ref QUIET_LOGS: bool = std::env::var("QUIET_LOGS").map(|s| s.parse::().unwrap_or(false)).unwrap_or(false); + /// Snapshot of the standard outbound-proxy env vars, read once at startup. + /// Lowercase (`no_proxy`, `http_proxy`, `https_proxy`) is preferred to match + /// the convention used by libcurl / reqwest; uppercase is the fallback. + pub static ref NO_PROXY: Option = std::env::var("no_proxy").ok().or_else(|| std::env::var("NO_PROXY").ok()); + pub static ref HTTP_PROXY: Option = std::env::var("http_proxy").ok().or_else(|| std::env::var("HTTP_PROXY").ok()); + pub static ref HTTPS_PROXY: Option = std::env::var("https_proxy").ok().or_else(|| std::env::var("HTTPS_PROXY").ok()); } const LATEST_VERSION_ID_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60); diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index ca141bfebd..5a7c6d2bfa 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -17,7 +17,7 @@ use std::{ panic::Location, path::{Component, Path, PathBuf}, str::FromStr, - sync::atomic::AtomicBool, + sync::atomic::{AtomicBool, AtomicI64, AtomicU32}, time::Duration, }; #[cfg(windows)] @@ -237,14 +237,30 @@ lazy_static::lazy_static! { }); pub static ref WORKER_PULL_QUERIES: arc_swap::ArcSwap> = arc_swap::ArcSwap::from_pointee(vec![]); + pub static ref WORKER_PULL_QUERIES_FAIRNESS: arc_swap::ArcSwap> = arc_swap::ArcSwap::from_pointee(vec![]); pub static ref WORKER_SUSPENDED_PULL_QUERY: arc_swap::ArcSwap = arc_swap::ArcSwap::from_pointee("".to_string()); + // Workspace fairness (cloud-only). When enabled, a workspace whose footprint over the rolling + // `WORKSPACE_FAIRNESS_DURATION_SECS` window represents >= `WORKSPACE_FAIRNESS_MAX_PERCENT`% of + // all worker activity gets excluded from the pull query, freeing slots for other workspaces. + // The list of overloaded workspaces is computed cluster-wide via a single coordinated UPDATE + // on `background_task_state` so only one process per refresh interval runs the aggregation. + pub static ref WORKSPACE_FAIRNESS_ENABLED: AtomicBool = AtomicBool::new(false); + pub static ref WORKSPACE_FAIRNESS_MAX_PERCENT: AtomicU32 = AtomicU32::new(50); + pub static ref WORKSPACE_FAIRNESS_DURATION_SECS: AtomicU32 = AtomicU32::new(10); + pub static ref WORKSPACE_FAIRNESS_MIN_TOTAL: AtomicU32 = AtomicU32::new(4); + pub static ref WORKSPACE_FAIRNESS_OVERLOADED: arc_swap::ArcSwap> = arc_swap::ArcSwap::from_pointee(vec![]); + pub static ref WORKSPACE_FAIRNESS_LAST_REFRESH_MICROS: AtomicI64 = AtomicI64::new(0); + pub static ref SMTP_CONFIG: arc_swap::ArcSwap> = arc_swap::ArcSwap::from_pointee(None); pub static ref INDEXER_CONFIG: arc_swap::ArcSwap = arc_swap::ArcSwap::from_pointee(TantivyIndexerSettings::default()); pub static ref CLOUD_HOSTED: bool = std::env::var("CLOUD_HOSTED").is_ok(); + /// Host used to gate cloud-only features that must only ever run on the + /// production `app.windmill.dev` cluster, not on staging or self-hosted. + pub static ref CLOUD_PRODUCTION_HOST: &'static str = "app.windmill.dev"; pub static ref CUSTOM_TAGS: Vec = std::env::var("CUSTOM_TAGS") .ok() @@ -289,6 +305,34 @@ pub fn is_native_mode_from_env() -> bool { *NATIVE_MODE || *WORKER_GROUP == "native" } +/// True iff this process is configured to act as the production cloud cluster: +/// `CLOUD_HOSTED=true` AND `BASE_URL`'s host matches `CLOUD_PRODUCTION_HOST`. +/// Centralized so the API setter, the runtime pull path, and any future cloud- +/// only feature share one canonical check (rather than re-implementing the +/// scheme/host parser at each call site). +pub fn is_cloud_production_host() -> bool { + if !*CLOUD_HOSTED { + return false; + } + let base = crate::BASE_URL.load(); + let s = base.as_str(); + if s.is_empty() { + return false; + } + let after_scheme = s + .strip_prefix("https://") + .or_else(|| s.strip_prefix("http://")) + .unwrap_or(s); + let host = after_scheme + .split('/') + .next() + .unwrap_or("") + .split(':') + .next() + .unwrap_or(""); + host == *CLOUD_PRODUCTION_HOST +} + /// Cached resolved native mode flag, updated when worker config is reloaded. /// Use this for hot-path checks (e.g. per-job dispatch) to avoid read-locking WORKER_CONFIG. pub static NATIVE_MODE_RESOLVED: AtomicBool = AtomicBool::new(false); @@ -520,17 +564,43 @@ pub fn make_pull_query(tags: &[String]) -> String { query } +// Variant of `make_pull_query` that additionally excludes jobs whose workspace_id is in the +// overloaded-list bind parameter ($2::text[]). Built as a separate string (rather than reusing +// `make_pull_query` with an always-bound array) so the planner can keep using the same indexes +// when fairness is off — the default `make_pull_query` text stays bit-identical to today's. +// +// `pub(crate)` because only `store_pull_query` consumes it; the resulting query string is what +// crosses crate boundaries via `WORKER_PULL_QUERIES_FAIRNESS`. +pub(crate) fn make_pull_query_fairness(tags: &[String]) -> String { + let query = format_pull_query(format!( + "SELECT id + FROM v2_job_queue + WHERE running = false AND tag IN ({}) AND scheduled_for <= now() + AND workspace_id <> ALL($2::text[]) + ORDER BY priority DESC NULLS LAST, scheduled_for + FOR UPDATE SKIP LOCKED + LIMIT 1", + tags.iter().map(|x| format!("'{x}'")).join(", ") + )); + query +} + pub async fn store_pull_query(wc: &WorkerConfig) { let mut queries = vec![]; + let mut fairness_queries = vec![]; + let fairness_enabled = WORKSPACE_FAIRNESS_ENABLED.load(std::sync::atomic::Ordering::Relaxed); for tags in wc.priority_tags_sorted.iter() { if tags.tags.len() == 0 { tracing::error!("Empty tags in priority tags, skipping"); continue; } - let query = make_pull_query(&tags.tags); - queries.push(query); + queries.push(make_pull_query(&tags.tags)); + if fairness_enabled { + fairness_queries.push(make_pull_query_fairness(&tags.tags)); + } } WORKER_PULL_QUERIES.store(std::sync::Arc::new(queries)); + WORKER_PULL_QUERIES_FAIRNESS.store(std::sync::Arc::new(fairness_queries)); } lazy_static::lazy_static! { diff --git a/backend/windmill-queue/Cargo.toml b/backend/windmill-queue/Cargo.toml index ad485d33d8..ac956cb709 100644 --- a/backend/windmill-queue/Cargo.toml +++ b/backend/windmill-queue/Cargo.toml @@ -33,6 +33,7 @@ uuid.workspace = true chrono.workspace = true chrono-tz.workspace = true hex.workspace = true +rand.workspace = true reqwest.workspace = true lazy_static.workspace = true prometheus = { workspace = true, optional = true } diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 52e6b09c6f..4cac407adf 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -78,7 +78,8 @@ use windmill_common::{ utils::{not_found_if_none, report_critical_error, StripPath, WarnAfterExt}, worker::{ to_raw_value, CLOUD_HOSTED, DISABLE_FLOW_SCRIPT, NO_LOGS, PREVIEW_TAGS_OVERRIDE, - WORKER_PULL_QUERIES, WORKER_SUSPENDED_PULL_QUERY, + WORKER_PULL_QUERIES, WORKER_PULL_QUERIES_FAIRNESS, WORKER_SUSPENDED_PULL_QUERY, + WORKSPACE_FAIRNESS_OVERLOADED, }, DB, METRICS_ENABLED, }; @@ -3632,28 +3633,74 @@ async fn pull_single_job_and_mark_as_running_no_concurrency_limit<'c>( return Ok((None, false)); } - for query in queries.iter() { - // tracing::info!("Pulling job with query: {}", query); - // let instant = std::time::Instant::now(); + // Workspace fairness (Enterprise): if the fairness refresh has flagged + // any overloaded workspaces, this pull is randomly routed between two + // pull queries: + // * with probability `(cap + ε)/100`, the standard query (capped + // workspaces are admissible — they win via FIFO when noisy) + // * with probability `1 - (cap + ε)/100`, the fairness query (the + // overloaded workspace_ids are filtered out) + // Over many pulls this converges to a steady share around the cap, + // without the on/off oscillation a binary cap/uncap dispatch produces. + // If the chosen query returns nothing, we always fall back to the + // standard query so workers don't idle when only capped jobs remain. + // Lazy refresh fires from the same place: runs at most once per + // process per refresh interval, never blocks this pull. + crate::workspace_fairness::maybe_refresh_overloaded(db); + let overloaded = WORKSPACE_FAIRNESS_OVERLOADED.load_full(); + let fairness_active = !overloaded.is_empty(); - #[cfg(feature = "benchmark")] - add_time!(bench, "pre pull"); + if fairness_active && !crate::workspace_fairness::should_admit_capped() { + let fairness_queries = WORKER_PULL_QUERIES_FAIRNESS.load(); + let overloaded_slice: &[String] = overloaded.as_slice(); + for query in fairness_queries.iter() { + #[cfg(feature = "benchmark")] + add_time!(bench, "pre pull (fairness)"); - let r = sqlx::query_as::<_, PulledJob>(query) - .bind(worker_name) - .fetch_optional(db) - .await?; + let r = sqlx::query_as::<_, PulledJob>(query) + .bind(worker_name) + .bind(overloaded_slice) + .fetch_optional(db) + .await?; - #[cfg(feature = "benchmark")] - add_time!(bench, "post pull"); + #[cfg(feature = "benchmark")] + add_time!(bench, "post pull (fairness)"); - if let Some(pulled_job) = r { - // tracing::info!("pulled job: {:?}", instant.elapsed().as_micros()); - - highest_priority_job = Some(pulled_job); - break; + if let Some(pulled_job) = r { + highest_priority_job = Some(pulled_job); + break; + } + } + } + + if highest_priority_job.is_none() { + // Standard pull path. Also acts as the fallback when fairness + // filtered out every candidate: prefer running a capped + // workspace's job over leaving a worker idle. (The cap is + // re-asserted statistically on subsequent pulls.) + for query in queries.iter() { + // tracing::info!("Pulling job with query: {}", query); + // let instant = std::time::Instant::now(); + + #[cfg(feature = "benchmark")] + add_time!(bench, "pre pull"); + + let r = sqlx::query_as::<_, PulledJob>(query) + .bind(worker_name) + .fetch_optional(db) + .await?; + + #[cfg(feature = "benchmark")] + add_time!(bench, "post pull"); + + if let Some(pulled_job) = r { + // tracing::info!("pulled job: {:?}", instant.elapsed().as_micros()); + + highest_priority_job = Some(pulled_job); + break; + } + // else continue pulling for lower priority tags } - // else continue pulling for lower priority tags } // #[cfg(feature = "benchmark")] diff --git a/backend/windmill-queue/src/lib.rs b/backend/windmill-queue/src/lib.rs index 7a00dfb4df..2f73a1a97a 100644 --- a/backend/windmill-queue/src/lib.rs +++ b/backend/windmill-queue/src/lib.rs @@ -15,6 +15,9 @@ pub mod schedule; pub use jobs::*; pub mod flow_status; pub mod tags; +pub mod workspace_fairness; +#[cfg(feature = "private")] +pub mod workspace_fairness_ee; #[cfg(feature = "cloud")] pub mod cloud_usage; diff --git a/backend/windmill-queue/src/workspace_fairness.rs b/backend/windmill-queue/src/workspace_fairness.rs new file mode 100644 index 0000000000..9f4ba00211 --- /dev/null +++ b/backend/windmill-queue/src/workspace_fairness.rs @@ -0,0 +1,36 @@ +//! Per-workspace fairness for the shared worker pool (Enterprise feature). +//! +//! The real algorithm — overloaded-set aggregation, coordinated refresh on +//! `background_task_state`, audit emission, stochastic admission decision — +//! lives in [`crate::workspace_fairness_ee`] and only compiles when the +//! `private` feature is on. This module is the public surface used by the +//! pull dispatch in `jobs.rs` and the integration tests; when EE is on it +//! transparently re-exports the EE implementation, when EE is off it +//! provides no-op stubs so the OSS build stays bit-identical to the +//! pre-fairness pull path. +//! +//! See [`crate::workspace_fairness_ee`] for design notes and SQL details. + +#[cfg(feature = "private")] +#[allow(unused)] +pub use crate::workspace_fairness_ee::*; + +#[cfg(not(feature = "private"))] +mod oss_stubs { + use sqlx::{Pool, Postgres}; + + /// No-op on OSS — workspace fairness is an Enterprise feature. + #[inline] + pub fn maybe_refresh_overloaded(_db: &Pool) {} + + /// No-op on OSS — always returns `true` so the dispatch never reaches + /// the fairness pull query (which is empty anyway, since + /// `store_pull_query` only materialises it when fairness is enabled). + #[inline] + pub fn should_admit_capped() -> bool { + true + } +} + +#[cfg(not(feature = "private"))] +pub use oss_stubs::*; diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 5c17308bbf..3e5292e805 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -10,8 +10,8 @@ use std::collections::HashMap; use std::net::IpAddr; use windmill_api_auth::{ - check_scopes, maybe_refresh_folders, require_owner_of_path, require_super_admin, ApiAuthed, - Tokened, + build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path, + require_super_admin, ApiAuthed, Tokened, }; use windmill_common::db::DB; use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; @@ -194,6 +194,7 @@ async fn list_names( Extension(user_db): Extension, ) -> JsonResult> { let mut tx = user_db.begin(&authed).await?; + let allowed = build_scope_path_predicate(&authed, "resources", "read"); let rows = sqlx::query!( "SELECT value->>'name' as name, path from resource WHERE resource_type = $1 AND workspace_id = $2", rt, @@ -203,6 +204,7 @@ async fn list_names( .await? .into_iter() .filter_map(|x| x.name.map(|name| NamePath { name, path: x.path })) + .filter(|np| allowed(&np.path)) .collect::>(); tx.commit().await?; Ok(Json(rows)) @@ -225,6 +227,7 @@ async fn list_search_resources( #[cfg(not(feature = "enterprise"))] let n = 3; + let allowed = build_scope_path_predicate(&authed, "resources", "read"); let rows = sqlx::query_as!( SearchResource, "SELECT path, value from resource WHERE workspace_id = $1 LIMIT $2", @@ -234,6 +237,7 @@ async fn list_search_resources( .fetch_all(&mut *tx) .await? .into_iter() + .filter(|r| allowed(&r.path)) .collect::>(); tx.commit().await?; Ok(Json(rows)) @@ -338,9 +342,13 @@ async fn list_resources( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; + let allowed = build_scope_path_predicate(&authed, "resources", "read"); let rows = sqlx::query_as::<_, ListableResource>(&sql) .fetch_all(&mut *tx) - .await?; + .await? + .into_iter() + .filter(|r| allowed(&r.path)) + .collect::>(); tx.commit().await?; diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 4c2a0ed670..2d767b393b 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -6,7 +6,10 @@ * LICENSE-AGPL for a copy of the license. */ -use windmill_api_auth::{check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed}; +use windmill_api_auth::{ + build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path, + ApiAuthed, +}; use windmill_common::db::DB; use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; @@ -188,9 +191,13 @@ async fn list_variables( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; + let allowed = build_scope_path_predicate(&authed, "variables", "read"); let rows = sqlx::query_as::<_, ListableVariable>(&sql) .fetch_all(&mut *tx) - .await?; + .await? + .into_iter() + .filter(|r| allowed(&r.path)) + .collect::>(); tx.commit().await?; Ok(Json(rows)) diff --git a/backend/windmill-trigger-websocket/Cargo.toml b/backend/windmill-trigger-websocket/Cargo.toml index 6b239d237c..ccf8d2b120 100644 --- a/backend/windmill-trigger-websocket/Cargo.toml +++ b/backend/windmill-trigger-websocket/Cargo.toml @@ -20,6 +20,8 @@ windmill-trigger.workspace = true windmill-git-sync.workspace = true windmill-queue.workspace = true tokio-tungstenite.workspace = true +base64.workspace = true +url.workspace = true axum.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/backend/windmill-trigger-websocket/src/handler.rs b/backend/windmill-trigger-websocket/src/handler.rs index 5adfc5a1e2..df8bccaaa6 100644 --- a/backend/windmill-trigger-websocket/src/handler.rs +++ b/backend/windmill-trigger-websocket/src/handler.rs @@ -4,7 +4,6 @@ use async_trait::async_trait; use itertools::Itertools; use serde_json::value::RawValue; use sqlx::{types::Json as SqlxJson, PgConnection}; -use tokio_tungstenite::connect_async; use windmill_api_auth::ApiAuthed; use windmill_common::DB; use windmill_common::{ @@ -16,8 +15,8 @@ use windmill_git_sync::DeployedObject; use windmill_trigger::{Trigger, TriggerCrud, TriggerData}; use super::{ - get_url_from_runnable_value, TestWebsocketConfig, WebsocketConfig, WebsocketConfigRequest, - WebsocketTrigger, + get_url_from_runnable_value, proxy::connect_async_with_proxy, TestWebsocketConfig, + WebsocketConfig, WebsocketConfigRequest, WebsocketTrigger, }; #[async_trait] @@ -277,12 +276,14 @@ impl TriggerCrud for WebsocketTrigger { Cow::Borrowed(&url) }; - connect_async(&*connect_url).await.map_err(|err| { - Error::BadConfig(format!( - "Error connecting to WebSocket: {}", - err.to_string() - )) - })?; + connect_async_with_proxy(&*connect_url) + .await + .map_err(|err| { + Error::BadConfig(format!( + "Error connecting to WebSocket: {}", + err.to_string() + )) + })?; Ok(()) } diff --git a/backend/windmill-trigger-websocket/src/lib.rs b/backend/windmill-trigger-websocket/src/lib.rs index bcf4a144db..e61c067479 100644 --- a/backend/windmill-trigger-websocket/src/lib.rs +++ b/backend/windmill-trigger-websocket/src/lib.rs @@ -18,6 +18,7 @@ use windmill_trigger::trigger_helpers::{ pub mod handler; pub mod listener; +pub mod proxy; #[derive(Copy, Clone)] pub struct WebsocketTrigger; diff --git a/backend/windmill-trigger-websocket/src/listener.rs b/backend/windmill-trigger-websocket/src/listener.rs index b729c49a28..54dd4ddf60 100644 --- a/backend/windmill-trigger-websocket/src/listener.rs +++ b/backend/windmill-trigger-websocket/src/listener.rs @@ -1,4 +1,6 @@ -use super::{get_url_from_runnable_value, WebsocketConfig, WebsocketTrigger}; +use super::{ + get_url_from_runnable_value, proxy::connect_async_with_proxy, WebsocketConfig, WebsocketTrigger, +}; use anyhow::Context; use async_trait::async_trait; use futures::{stream::SplitSink, SinkExt, StreamExt}; @@ -8,7 +10,7 @@ use serde::Deserialize; use serde_json::value::RawValue; use std::{borrow::Cow, collections::HashMap, sync::Arc}; use tokio::{net::TcpStream, sync::RwLock}; -use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; +use tokio_tungstenite::{tungstenite::Message, MaybeTlsStream, WebSocketStream}; use windmill_common::{ error::{to_anyhow, Error, Result}, jobs::JobTriggerKind, @@ -171,7 +173,7 @@ impl Listener for WebsocketTrigger { Cow::Borrowed(&url) }; - let connection = connect_async(&*connect_url) + let connection = connect_async_with_proxy(&*connect_url) .await .map(|conn| Some(conn)) .map_err(|err| to_anyhow(err).into()); diff --git a/backend/windmill-trigger-websocket/src/proxy.rs b/backend/windmill-trigger-websocket/src/proxy.rs new file mode 100644 index 0000000000..8670b7ca3a --- /dev/null +++ b/backend/windmill-trigger-websocket/src/proxy.rs @@ -0,0 +1,436 @@ +//! HTTP CONNECT proxy support for outbound WebSocket connections. +//! +//! `tokio-tungstenite::connect_async` opens a raw TCP socket and does not +//! honour `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY`. On networks without +//! direct egress this leaves WebSocket triggers unable to reach the +//! upstream service. This module re-uses the env-var snapshots already +//! parsed by `windmill-common` and, when a proxy applies to the target +//! host, opens an HTTP CONNECT tunnel before delegating the TLS + +//! WebSocket handshake back to tungstenite. +//! +//! When no proxy env vars are set (the common case), this module +//! forwards straight to `tokio_tungstenite::connect_async` so the +//! networking path stays byte-for-byte identical to the previous +//! behaviour. + +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use std::io; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::TcpStream, +}; +use tokio_tungstenite::{ + client_async_tls_with_config, connect_async, + tungstenite::{ + client::IntoClientRequest, + error::{Error as WsError, UrlError}, + handshake::client::Response, + }, + MaybeTlsStream, WebSocketStream, +}; +use url::Url; +use windmill_common::{HTTPS_PROXY, HTTP_PROXY, NO_PROXY}; + +/// Drop-in replacement for `tokio_tungstenite::connect_async` that routes +/// the underlying TCP connection through `HTTPS_PROXY` / `HTTP_PROXY` +/// (with `NO_PROXY` exclusions) when those env vars are set. When none +/// is set we short-circuit straight to `connect_async`, keeping the +/// behaviour for non-proxied deployments unchanged. +pub async fn connect_async_with_proxy( + request: R, +) -> Result<(WebSocketStream>, Response), WsError> +where + R: IntoClientRequest + Unpin, +{ + if HTTPS_PROXY.is_none() && HTTP_PROXY.is_none() { + return connect_async(request).await; + } + + let request = request.into_client_request()?; + let uri = request.uri().clone(); + let scheme = uri.scheme_str().unwrap_or_default().to_ascii_lowercase(); + let host = uri + .host() + .ok_or(WsError::Url(UrlError::NoHostName))? + .to_string(); + let port = uri + .port_u16() + .or_else(|| match scheme.as_str() { + "wss" => Some(443), + "ws" => Some(80), + _ => None, + }) + .ok_or(WsError::Url(UrlError::UnsupportedUrlScheme))?; + + let proxy = proxy_url_for(&scheme, &host).and_then(|raw| parse_proxy_target(&raw)); + + let Some(proxy) = proxy else { + // Proxy env was set but doesn't apply to this host (NO_PROXY hit + // or unparseable URL): preserve the original connect path. + return connect_async(request).await; + }; + + tracing::debug!( + "Connecting to WebSocket {}:{} through HTTP proxy {}:{}", + host, + port, + proxy.host, + proxy.port, + ); + let socket = http_connect_tunnel(&proxy, &host, port) + .await + .map_err(WsError::Io)?; + + client_async_tls_with_config(request, socket, None, None).await +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProxyTarget { + host: String, + port: u16, + /// Base64-encoded `user:pass` from URL userinfo, ready to drop into + /// the `Proxy-Authorization: Basic …` header value. + basic_auth: Option, +} + +/// Resolve the proxy URL string to use for outbound `(scheme, host)`. +/// +/// `wss://`/`https://` reads `HTTPS_PROXY`, `ws://`/`http://` reads +/// `HTTP_PROXY`. `NO_PROXY` short-circuits to `None`. The env-var +/// snapshots come from `windmill-common` so they share a single source +/// of truth with the worker's `PROXY_ENVS`. +fn proxy_url_for(scheme: &str, host: &str) -> Option { + if let Some(no_proxy) = NO_PROXY.as_deref() { + if matches_no_proxy(host, no_proxy) { + return None; + } + } + let primary = if scheme.eq_ignore_ascii_case("wss") || scheme.eq_ignore_ascii_case("https") { + HTTPS_PROXY.as_deref() + } else { + HTTP_PROXY.as_deref() + }; + primary + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) +} + +/// Match a host against a `NO_PROXY` value (comma-separated list). +/// +/// Supports the conventional rules used by `curl`/`reqwest`: +/// - `*` matches everything +/// - exact host match +/// - bare-domain entry (`example.com`) matches `example.com` and any +/// subdomain (`foo.example.com`) +/// - leading-dot entry (`.example.com`) is normalised to the bare form +/// (matches `example.com` and any subdomain), to match what `reqwest` +/// and most ops folks expect +/// - any `:port` suffix on entries is ignored +/// +/// CIDR/IP-range matches are intentionally not supported. +fn matches_no_proxy(host: &str, no_proxy: &str) -> bool { + let host = host.trim().trim_end_matches('.').to_ascii_lowercase(); + if host.is_empty() { + return false; + } + for raw in no_proxy.split(',') { + let entry = raw.trim().to_ascii_lowercase(); + if entry.is_empty() { + continue; + } + if entry == "*" { + return true; + } + let entry = entry.split(':').next().unwrap_or(&entry); + let entry = entry.trim_end_matches('.'); + let bare = entry.trim_start_matches('.'); + if bare.is_empty() { + continue; + } + if host == bare { + return true; + } + if host.ends_with(&format!(".{}", bare)) { + return true; + } + } + false +} + +/// Parse a proxy URL string into host/port and optional pre-encoded basic +/// auth. Accepts `host`, `host:port`, `scheme://host[:port]`, with an +/// optional `user[:pass]@` userinfo prefix. The scheme is used only to +/// pick a default port (`https` → 443, anything else → 80). +fn parse_proxy_target(raw: &str) -> Option { + let raw = raw.trim(); + if raw.is_empty() { + return None; + } + + // `url::Url::parse` requires an explicit scheme — prepend `http://` + // when the user passed a bare `host[:port]`. + let prepended = if raw.contains("://") { + std::borrow::Cow::Borrowed(raw) + } else { + std::borrow::Cow::Owned(format!("http://{raw}")) + }; + let url = Url::parse(&prepended).ok()?; + + // `host_str()` keeps brackets around IPv6 literals; strip them so + // `TcpStream::connect((host, port))` resolves the address correctly. + let host = url + .host_str()? + .trim_start_matches('[') + .trim_end_matches(']'); + if host.is_empty() { + return None; + } + let host = host.to_string(); + let port = + url.port_or_known_default() + .unwrap_or(if url.scheme().eq_ignore_ascii_case("https") { + 443 + } else { + 80 + }); + + let basic_auth = match (url.username(), url.password()) { + ("", None) => None, + (user, pass) => { + let creds = match pass { + Some(p) => format!("{user}:{p}"), + None => user.to_string(), + }; + Some(BASE64_STANDARD.encode(creds)) + } + }; + + Some(ProxyTarget { host, port, basic_auth }) +} + +/// Open a TCP connection to `proxy` and ask it to tunnel to +/// `(target_host, target_port)` via HTTP CONNECT. Returns the raw socket +/// once the proxy has acknowledged with a 2xx response — subsequent bytes +/// belong to the tunneled connection. +async fn http_connect_tunnel( + proxy: &ProxyTarget, + target_host: &str, + target_port: u16, +) -> io::Result { + let mut stream = TcpStream::connect((proxy.host.as_str(), proxy.port)).await?; + + let host_header = format!("{}:{}", target_host, target_port); + let mut req = format!("CONNECT {h} HTTP/1.1\r\nHost: {h}\r\n", h = host_header,); + if let Some(ref auth) = proxy.basic_auth { + req.push_str("Proxy-Authorization: Basic "); + req.push_str(auth); + req.push_str("\r\n"); + } + req.push_str("Proxy-Connection: keep-alive\r\n\r\n"); + + stream.write_all(req.as_bytes()).await?; + stream.flush().await?; + + let mut reader = BufReader::new(stream); + let mut status_line = String::new(); + let n = reader.read_line(&mut status_line).await?; + if n == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "HTTP proxy closed connection before sending CONNECT response", + )); + } + + let status_ok = status_line + .split_whitespace() + .nth(1) + .map(|s| s == "200") + .unwrap_or(false); + + loop { + let mut line = String::new(); + let n = reader.read_line(&mut line).await?; + if n == 0 || line == "\r\n" || line == "\n" { + break; + } + } + + if !status_ok { + return Err(io::Error::new( + io::ErrorKind::Other, + format!( + "HTTP proxy CONNECT to {} rejected: {}", + host_header, + status_line.trim_end() + ), + )); + } + + // A conforming proxy stays silent after the CONNECT response until the + // client speaks. If our read buffer is non-empty, the proxy spoke + // first — handing the raw socket to TLS would silently drop those + // bytes and break the handshake. + if !reader.buffer().is_empty() { + return Err(io::Error::new( + io::ErrorKind::Other, + "HTTP proxy sent unexpected bytes after CONNECT response", + )); + } + + Ok(reader.into_inner()) +} + +#[cfg(test)] +mod tests { + //! The single live test (`http_connect_tunnel_…_unwraps_stream`) drives + //! a real `TcpListener` masquerading as a proxy and verifies both the + //! on-the-wire CONNECT request and that the returned `TcpStream` + //! actually carries tunneled bytes. The other proxy-URL and NO_PROXY + //! checks are kept under `#[ignore]` for manual debugging — they cover + //! logic that's mostly delegated to `url::Url::parse` and trivial + //! string matching, so re-running them on every CI build is low ROI. + use super::*; + + #[test] + #[ignore = "covered by upstream `url::Url::parse`; run manually with `--ignored` if changed"] + fn parse_proxy_target_shapes_and_ipv6_and_basic_auth() { + let p = parse_proxy_target("http://outbound.eps.apple.com:80").unwrap(); + assert_eq!(p.host, "outbound.eps.apple.com"); + assert_eq!(p.port, 80); + + let p = parse_proxy_target("https://proxy.internal").unwrap(); + assert_eq!(p.port, 443); + + let p = parse_proxy_target("proxy.internal:3128").unwrap(); + assert_eq!(p.port, 3128); + + let p = parse_proxy_target("http://alice:s3cret@proxy.lan:8080").unwrap(); + // base64("alice:s3cret") = YWxpY2U6czNjcmV0 + assert_eq!(p.basic_auth.as_deref(), Some("YWxpY2U6czNjcmV0")); + + let p = parse_proxy_target("http://[::1]:3128").unwrap(); + assert_eq!(p.host, "::1"); + assert_eq!(p.port, 3128); + + assert!(parse_proxy_target("").is_none()); + assert!(parse_proxy_target("http://").is_none()); + } + + #[test] + #[ignore = "trivial string matching; run manually with `--ignored` if rules change"] + fn no_proxy_matching_rules() { + assert!(matches_no_proxy("example.com", "*")); + assert!(matches_no_proxy("example.com", "example.com")); + assert!(matches_no_proxy("api.example.com", "example.com")); + assert!(matches_no_proxy("api.example.com", ".example.com")); + assert!(matches_no_proxy("example.com", ".example.com")); + assert!(matches_no_proxy("example.com", "example.com:8080")); + assert!(matches_no_proxy("API.Example.COM", "example.com")); + assert!(!matches_no_proxy("notexample.com", "example.com")); + assert!(!matches_no_proxy("slack.com", "example.com,internal.lan")); + } + + /// Spin up a one-shot TCP listener acting as an HTTP proxy. + /// Reads the CONNECT request, asserts on it via `validate`, then + /// either replies `200 Connection Established` or the supplied + /// `respond` string. Echoes any further client bytes back so the test + /// can confirm the returned `TcpStream` carries the tunneled session. + async fn fake_proxy( + respond: &'static str, + validate: F, + ) -> (std::net::SocketAddr, tokio::task::JoinHandle>) + where + F: FnOnce(&str) + Send + 'static, + { + use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = String::new(); + { + let mut reader = BufReader::new(&mut socket); + loop { + let mut line = String::new(); + let n = reader.read_line(&mut line).await.unwrap(); + request.push_str(&line); + if n == 0 || line == "\r\n" || line == "\n" { + break; + } + } + } + validate(&request); + socket.write_all(respond.as_bytes()).await.unwrap(); + socket.flush().await.unwrap(); + + let mut tunneled = Vec::new(); + socket.read_to_end(&mut tunneled).await.unwrap(); + tunneled + }); + (addr, handle) + } + + #[tokio::test] + async fn http_connect_tunnel_sends_well_formed_request_and_unwraps_stream() { + use tokio::io::AsyncWriteExt; + + let (addr, handle) = fake_proxy( + "HTTP/1.1 200 Connection Established\r\nProxy-Agent: test\r\n\r\n", + |req| { + assert!( + req.starts_with("CONNECT slack.com:443 HTTP/1.1\r\n"), + "got: {req:?}" + ); + assert!(req.contains("Host: slack.com:443\r\n")); + assert!(!req.contains("Proxy-Authorization")); + }, + ) + .await; + + let proxy = + ProxyTarget { host: addr.ip().to_string(), port: addr.port(), basic_auth: None }; + let mut stream = http_connect_tunnel(&proxy, "slack.com", 443).await.unwrap(); + stream.write_all(b"hello-tls").await.unwrap(); + stream.shutdown().await.unwrap(); + + let tunneled = handle.await.unwrap(); + assert_eq!(tunneled, b"hello-tls"); + } + + #[tokio::test] + #[ignore = "manual; fake-proxy edge cases (auth, error status). Run with `--ignored` if `http_connect_tunnel` changes."] + async fn http_connect_tunnel_forwards_basic_auth_and_surfaces_non_2xx() { + use tokio::io::AsyncWriteExt; + + // Basic-auth header is forwarded. + let (addr, handle) = fake_proxy("HTTP/1.1 200 OK\r\n\r\n", |req| { + assert!(req.contains("Proxy-Authorization: Basic YWxpY2U6czNjcmV0\r\n")); + }) + .await; + let proxy = ProxyTarget { + host: addr.ip().to_string(), + port: addr.port(), + basic_auth: Some("YWxpY2U6czNjcmV0".to_string()), + }; + let mut stream = http_connect_tunnel(&proxy, "slack.com", 443).await.unwrap(); + stream.shutdown().await.unwrap(); + let _ = handle.await.unwrap(); + + // Non-2xx status surfaces as an error. + let (addr, handle) = fake_proxy( + "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic\r\n\r\n", + |_| {}, + ) + .await; + let proxy = + ProxyTarget { host: addr.ip().to_string(), port: addr.port(), basic_auth: None }; + let err = http_connect_tunnel(&proxy, "slack.com", 443) + .await + .unwrap_err(); + assert!(err.to_string().contains("407")); + let _ = handle.await.unwrap(); + } +} diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index dae4f277f2..49c24eac2d 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -586,16 +586,12 @@ pub async fn run_agent( tool_abort_handles: ToolAbortHandles, ) -> error::Result> { let output_type = args.output_type.as_ref().unwrap_or(&OutputType::Text); - // Skip get_base_url for Bedrock - it uses SDK directly, not HTTP - let base_url = if args.provider.kind == AIProvider::AWSBedrock { - String::new() - } else { - args.provider.get_base_url(db).await? - }; - let api_key = args.provider.get_api_key().unwrap_or(""); + let credentials = args.provider.to_provider_credentials(db).await?; + let base_url = &credentials.base_url; + let api_key = credentials.api_key.as_deref().unwrap_or(""); // Create the query builder for the provider - let query_builder = create_query_builder(&args.provider); + let query_builder = create_query_builder(&credentials); // Initialize messages let mut messages = @@ -859,12 +855,12 @@ pub async fn run_agent( } // Handle AWS Bedrock provider specially using the official SDK - let parsed = if args.provider.kind == AIProvider::AWSBedrock { + let parsed = if credentials.provider == AIProvider::AWSBedrock { #[cfg(feature = "bedrock")] { - let region = args - .provider - .get_region() + let region = credentials + .region + .as_deref() .unwrap_or(windmill_ai::ai_providers::USE_ENV_REGION); // Use Bedrock SDK via dedicated query builder windmill_ai::providers::bedrock::BedrockQueryBuilder::default() @@ -880,9 +876,9 @@ pub async fn run_agent( client, &job.workspace_id, structured_output_tool_name.as_deref(), - args.provider.get_aws_access_key_id(), - args.provider.get_aws_secret_access_key(), - args.provider.get_aws_session_token(), + credentials.aws_access_key_id.as_deref(), + credentials.aws_secret_access_key.as_deref(), + credentials.aws_session_token.as_deref(), ) .await? } @@ -913,14 +909,14 @@ pub async fn run_agent( .await?; let endpoint = - query_builder.get_endpoint(&base_url, args.provider.get_model(), output_type); - let auth_headers = query_builder.get_auth_headers(api_key, &base_url, output_type); + query_builder.get_endpoint(base_url, args.provider.get_model(), output_type); + let auth_headers = query_builder.get_auth_headers(api_key, base_url, output_type); let timeout = resolve_job_timeout(conn, &job.workspace_id, job.id, job.timeout) .await .0; - let resource_headers = args.provider.get_headers(); + let resource_headers = &credentials.custom_headers; // Helper to build HTTP request with headers let build_http_request = |body: String| { diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index ee8982fdc9..9029de5572 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -13,6 +13,8 @@ use anyhow::anyhow; use futures::TryFutureExt; use tokio::sync::Mutex; 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; @@ -48,6 +50,7 @@ use windmill_common::{ worker_group_job_stats::JobStatsMap, KillpillSender, }; +pub use windmill_common::{HTTPS_PROXY, HTTP_PROXY, NO_PROXY}; #[cfg(feature = "enterprise")] use windmill_common::ee_oss::LICENSE_KEY_VALID; @@ -554,11 +557,9 @@ lazy_static::lazy_static! { .and_then(|x| x.parse::().ok()) .unwrap_or(false)); - pub static ref NO_PROXY: Option = std::env::var("no_proxy").ok().or(std::env::var("NO_PROXY").ok()); - pub static ref HTTP_PROXY: Option = std::env::var("http_proxy").ok().or(std::env::var("HTTP_PROXY").ok()); - pub static ref HTTPS_PROXY: Option = std::env::var("https_proxy").ok().or(std::env::var("HTTPS_PROXY").ok()); - - /// Static proxy environment variables from env vars (for languages not using dynamic OTEL tracing proxy config) + /// Static proxy environment variables from env vars (for languages not using dynamic OTEL tracing proxy config). + /// The underlying `NO_PROXY` / `HTTP_PROXY` / `HTTPS_PROXY` snapshots live in `windmill_common` + /// so other crates (e.g. native triggers) can reuse the same source of truth. pub static ref PROXY_ENVS: Vec<(&'static str, String)> = { let mut proxy_env = Vec::new(); if let Some(no_proxy) = NO_PROXY.as_ref() { diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 57355c67cc..fee997975f 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.706.1"; +export const VERSION = "v1.709.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 7cf2ae0adc..65ccdf7c2a 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -605,9 +605,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -618,8 +619,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -629,8 +632,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * \`\`\` + * + * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -640,8 +645,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * \`\`\` + * + * @param workspace - Workspace to write to (defaults to the \`WM_WORKSPACE\` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * \`\`\`typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * \`\`\` + * + * @param s3object - S3 object identifying the file to delete (must have \`s3\` set) + * @param workspace - Workspace to delete from (defaults to the \`WM_WORKSPACE\` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps @@ -1296,9 +1315,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -1309,8 +1329,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -1320,8 +1342,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * \`\`\` + * + * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -1331,8 +1355,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * \`\`\` + * + * @param workspace - Workspace to write to (defaults to the \`WM_WORKSPACE\` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * \`\`\`typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * \`\`\` + * + * @param s3object - S3 object identifying the file to delete (must have \`s3\` set) + * @param workspace - Workspace to delete from (defaults to the \`WM_WORKSPACE\` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps @@ -2075,9 +2113,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -2088,8 +2127,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -2099,8 +2140,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * \`\`\` + * + * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -2110,8 +2153,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * \`\`\` + * + * @param workspace - Workspace to write to (defaults to the \`WM_WORKSPACE\` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * \`\`\`typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * \`\`\` + * + * @param s3object - S3 object identifying the file to delete (must have \`s3\` set) + * @param workspace - Workspace to delete from (defaults to the \`WM_WORKSPACE\` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps @@ -3277,9 +3334,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -3290,8 +3348,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -3301,8 +3361,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * \`\`\` + * + * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -3312,8 +3374,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * \`\`\` + * + * @param workspace - Workspace to write to (defaults to the \`WM_WORKSPACE\` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * \`\`\`typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * \`\`\` + * + * @param s3object - S3 object identifying the file to delete (must have \`s3\` set) + * @param workspace - Workspace to delete from (defaults to the \`WM_WORKSPACE\` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps diff --git a/cli/src/main.ts b/cli/src/main.ts index 170d9e318f..9faf4e5308 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -87,7 +87,7 @@ export { token, }; -export const VERSION = "1.706.1"; +export const VERSION = "1.709.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/docs/windmill-ai-refactor-plan.md b/docs/windmill-ai-refactor-plan.md index ea2b9f451e..6dcd2d7605 100644 --- a/docs/windmill-ai-refactor-plan.md +++ b/docs/windmill-ai-refactor-plan.md @@ -5,10 +5,10 @@ AI provider logic is currently split across three crates with duplicate code: - **windmill-common** — base types (`ai_types`, `ai_providers`, `ai_google`, `ai_bedrock`, `ai_cache`) -- **windmill-api** — chat proxy (`ai.rs`, `google.rs`, `bedrock.rs`) with its own request building for Google/Bedrock, plus `AIRequestConfig::prepare_request` for auth/URL handling +- **windmill-api** — chat proxy routes (`ai.rs`), audit logging, caching, and DB-backed credential resolution into `ProviderCredentials` - **windmill-worker** — agent execution (`ai/` module) with `QueryBuilder` trait, SSE parsers, provider implementations -The goal: a single `windmill-ai` crate with all AI provider logic. Both the API proxy and worker agent use `QueryBuilder` for every provider — no more duplicate logic. +The goal: a single `windmill-ai` crate with all AI provider logic. Worker agent execution uses `QueryBuilder`; the API proxy uses `QueryBuilder::build_proxy_request` for HTTP-forwarding providers and native proxy handlers for providers that need response conversion or SDK execution. ## Dependency Direction @@ -25,7 +25,7 @@ windmill-common does **NOT** re-export from windmill-ai (would be circular). All ## Reviewer Note: Keep API Proxy Unification Split -The crate boundary, shared utilities, SSE parsers, image handling, and worker provider implementations are now in `windmill-ai`. The remaining duplication is the API proxy path: `AIRequestConfig::prepare_request`, `windmill-api/src/google.rs`, and `windmill-api/src/bedrock.rs` still own API-specific request transformation. +The crate boundary, shared utilities, SSE parsers, image handling, worker provider implementations, provider-specific API proxy transformations, and resolved runtime credential shape are now in `windmill-ai`. Raw API resources and worker agent provider payloads remain separate input/deserialization shapes and convert into `ProviderCredentials` at execution boundaries. Do not jump directly from the current state to full proxy and credential unification in one PR. The API proxy combines request transformation, endpoint selection, auth headers, custom headers, OAuth user injection, Azure URL handling, Anthropic Vertex handling, Bedrock SDK calls, and SSE keepalive behavior. Split the work by risk: - Introduce shared proxy request and credential types first. @@ -62,7 +62,7 @@ Out of scope: Validation: - `cargo test -p windmill-ai proxy` -- `cargo test -p windmill-api maps_request_config_to_provider_credentials` +- `cargo test -p windmill-api invalidates_all_cached_providers_for_workspace` - `cargo check -p windmill-ai -p windmill-api` - `cargo check -p windmill-ai -p windmill-api --features bedrock` @@ -70,7 +70,7 @@ Follow-up status: Anthropic/Vertex proxy handling has since moved into `windmill-ai`, and the dead `AIRequestConfig::prepare_request` fallback has been removed. -## Current Phase PR: Proxy Execution Mode + Google AI Proxy Migration +## Completed Phase: Proxy Execution Mode + Google AI Proxy Migration ✅ Goal: introduce a shared provider execution classifier before moving Google AI and Bedrock. `ProxyRequest` is a good contract for HTTP-forwarding providers @@ -98,9 +98,120 @@ Out of scope: Validation: - `cargo test -p windmill-ai google_ai` - `cargo test -p windmill-ai proxy` -- `cargo test -p windmill-api maps_request_config_to_provider_credentials` +- `cargo test -p windmill-api invalidates_all_cached_providers_for_workspace` - `cargo test -p windmill-ai anthropic` +Follow-up status: Bedrock native proxy handling has since moved into +`windmill-ai`, and the API-local `windmill-api/src/bedrock.rs` module has been +removed. + +## Completed Phase: Bedrock Native Proxy Migration ✅ + +Goal: move the remaining native-provider API proxy execution out of +`windmill-api` and into `windmill-ai`, while leaving API-owned routing, +credential resolution, auditing, cache behavior, and Axum response conversion in +`windmill-api`. + +Suggested PR title: `refactor(ai): move bedrock proxy handling to windmill-ai`. + +Scope: +- Move Bedrock control-plane proxy calls (`foundation-models`, + `inference-profiles`) into `windmill-ai::providers::bedrock`. +- Move Bedrock chat proxy OpenAI request parsing, Converse request execution, + streaming SSE conversion, non-streaming OpenAI-shaped response conversion, and + auth selection into `windmill-ai::providers::bedrock`. +- Add an Axum-free `BedrockProxyResponse` shape in `windmill-ai`; the API route + converts it into an Axum body. +- Move the optional `aws-sdk-bedrock` dependency from `windmill-api` to + `windmill-ai`. +- Delete the API-local `windmill-api/src/bedrock.rs` module. + +Out of scope: +- Do not unify `AIRequestConfig` and `ProviderWithResource`. +- Do not change Bedrock credential resolution, audit logging, request caching, + or non-Bedrock proxy behavior. + +Validation: +- `cargo test -p windmill-ai bedrock --features bedrock` +- `cargo check -p windmill-ai -p windmill-api` +- `cargo check -p windmill-ai -p windmill-api --features bedrock` + +## Known Follow-Ups + +These are not blockers for the current migration PR because they either preserve +existing behavior or need a separate product decision, but they should stay +visible for later hardening work. + +- **Google AI/Gemini native proxy custom headers**: the native Google AI proxy + path intentionally does not apply `AI_HTTP_HEADERS` or resource-level custom + headers today. Decide whether and how env/resource custom-header injection + should apply to Google AI once the proxy behavior is unified further. +- **Bedrock SSE tool-call indexing**: Bedrock streaming currently increments + the OpenAI tool-call index on every Bedrock `ContentBlockStop`, including text + content blocks. This behavior existed before the move from `windmill-api` to + `windmill-ai`, but a later cleanup should advance the index only when the + stopped block was a tool-use block. +- **Bedrock SSE keepalives**: Bedrock native SSE streams are still returned + directly without the API proxy keepalive injection used by other SSE paths. + This also preserves the pre-move behavior. A later cleanup can generalize the + keepalive wrapper so it works for both `reqwest::Error` streams and Bedrock's + SDK-backed `std::io::Error` streams. + +## Completed Phase: Credential Unification Phase 1 ✅ + +Goal: make `ProviderCredentials` the shared resolved runtime credential shape +without overloading it with raw resource input or model-selection state. + +`AIRequestConfig` and `ProviderWithResource` are not equivalent concepts: +`AIRequestConfig` is API-side resolved state after DB, variable, OAuth, and +resource handling, while `ProviderWithResource` is worker-side raw agent input +that also carries the selected model. Keep raw/deserialization types separate and +convert them into `ProviderCredentials` at execution boundaries. + +Suggested PR title: `refactor(ai): use provider credentials for worker builders`. + +Scope: +- Add a worker-side conversion from `ProviderWithResource` to + `ProviderCredentials`. +- Keep `model` outside `ProviderCredentials`; it remains agent request data. +- Keep `ProviderWithResource` as the backward-compatible deserialization type for + existing agent payloads. +- Use `ProviderCredentials` for worker query-builder creation. +- Collapse `create_query_builder` and `create_proxy_query_builder` into one + `create_query_builder(&ProviderCredentials)` factory. + +Out of scope: +- Do not remove API-local `AIRequestConfig` yet. +- Do not change API request-cache behavior. +- Do not change worker agent payload shape or serialized field names. + +Validation: +- `cargo check -p windmill-ai -p windmill-api -p windmill-worker` +- `cargo check -p windmill-ai -p windmill-api -p windmill-worker --features bedrock` + +## Completed Phase: Credential Unification Phase 2 ✅ + +Goal: remove the API-local resolved credential wrapper after worker execution +already uses the shared shape. + +Suggested PR title: `refactor(ai): resolve api proxy credentials directly`. + +Scope: +- Change API credential resolution to return `ProviderCredentials` directly. +- Replace `ExpiringAIRequestConfig` with an expiring `ProviderCredentials` + cache entry. +- Remove `AIRequestConfig::into_provider_credentials`. +- Delete `AIRequestConfig` entirely if no API-only behavior remains. + +Out of scope: +- Do not merge raw worker resource input into `ProviderCredentials`. +- Do not put model selection into `ProviderCredentials`. + +Validation: +- `cargo check -p windmill-ai -p windmill-api -p windmill-worker` +- `cargo check -p windmill-ai -p windmill-api -p windmill-worker --features bedrock` +- `cargo test -p windmill-api invalidates_all_cached_providers_for_workspace` + ## Step-by-Step Plan Each step produces a compiling, working backend. @@ -200,9 +311,10 @@ Move `AI_HTTP_HEADERS` lazy_static (currently duplicated in `windmill-api/src/ai --- -### Step 8: Add proxy support to QueryBuilder — API uses QueryBuilder for all providers +### Step 8: Add API proxy execution support to windmill-ai ✅ -This is the key unification step. Add a new method to the `QueryBuilder` trait: +This is the key proxy unification step. HTTP-forwarding providers use +`QueryBuilder::build_proxy_request`: ```rust /// Build a request from a raw OpenAI-format proxy request. @@ -237,35 +349,43 @@ pub struct ProxyRequest { **Provider implementations:** - **OpenAI-compatible** (OpenAI, Mistral, DeepSeek, Groq, TogetherAI, CustomAI, OpenRouter): Minimal transformation — pass body through, build URL and auth headers. - **Anthropic**: Handle standard vs Vertex AI. For Vertex: transform body (extract model, add anthropic_version). For standard: pass through with appropriate headers. -- **Google AI**: Convert OpenAI format → Gemini format (using existing `ai_google` functions). Replaces `windmill-api/src/google.rs`. -- **Bedrock**: Convert OpenAI format → Bedrock SDK calls. Replaces `windmill-api/src/bedrock.rs`. +- **Google AI**: Native execution mode converts OpenAI format → Gemini format and Gemini responses → OpenAI shape. Replaces `windmill-api/src/google.rs`. +- **Bedrock**: Native execution mode converts OpenAI format → Bedrock SDK calls and SDK responses → OpenAI shape. Replaces `windmill-api/src/bedrock.rs`. **Refactor API proxy** (`windmill-api/src/ai.rs`): 1. Parse provider from headers, resolve credentials → `ProviderCredentials` 2. Create `QueryBuilder` via `create_query_builder` -3. Call `query_builder.build_proxy_request(&proxy_args)` → `ProxyRequest` -4. Send the request, return response with SSE keepalive injection +3. Dispatch by `ProxyExecutionMode`: + - HTTP-forwarding providers call `query_builder.build_proxy_request(&proxy_args)` → `ProxyRequest` + - Google AI and Bedrock call native handlers in `windmill-ai` +4. Convert the provider response to the API response body **Remove** from windmill-api: - `AIRequestConfig::prepare_request` — replaced by `QueryBuilder::build_proxy_request` -- `google.rs` — replaced by `GoogleAIQueryBuilder::build_proxy_request` -- `bedrock.rs` — replaced by `BedrockQueryBuilder::build_proxy_request` +- `google.rs` — replaced by `windmill_ai::providers::google_ai` native proxy handlers +- `bedrock.rs` — replaced by `windmill_ai::providers::bedrock` native proxy handlers - `transform_anthropic_for_vertex` — moved to `AnthropicQueryBuilder` - `supports_native_fim`, `transform_fim_to_chat_completions` — moved to windmill-ai **Keep** in API: -- `AIRequestConfig::new` credential resolution until it is refactored to produce `ProviderCredentials` +- credential resolution from DB, workspace settings, instance settings, variables, and OAuth into `ProviderCredentials` - HTTP routes, audit logging, request caching - `inject_keepalives`, `is_sse_response` helpers -- `AIConfig`, `ExpiringAIRequestConfig` caching types +- `AIConfig`, `ExpiringProviderCredentials` caching types --- ### Step 9: Unify credential resolution -Merge `AIRequestConfig` (API-side) and `ProviderWithResource` (worker-side) into a single credential shape in windmill-ai. +Make `ProviderCredentials` the single resolved runtime credential shape in +windmill-ai, while keeping raw API and worker input/deserialization types at +their boundaries. -Both currently carry: api_key, base_url, region, platform, custom_headers, AWS credentials. The API's `AIRequestConfig::new` resolves credentials from DB (workspace/instance settings). The worker's `ProviderWithResource` gets credentials from the flow module definition. +The API's `resolve_provider_credentials` resolves credentials from DB, workspace +or instance settings, variables, and OAuth. The worker's `ProviderWithResource` +gets raw credentials from the flow module definition and also carries the +selected model. Convert both paths into `ProviderCredentials`; do not make +`ProviderCredentials` carry raw resource state or the model. Extend `windmill_ai::proxy::ProviderCredentials` as needed so both can produce it: ```rust @@ -310,8 +430,8 @@ windmill-ai/src/ ├── mod.rs # create_query_builder factory ├── anthropic.rs # build_request + build_proxy_request ├── openai.rs # build_request + build_proxy_request - ├── google_ai.rs # build_request + build_proxy_request - ├── bedrock.rs # build_request + build_proxy_request (feature: bedrock) + ├── google_ai.rs # build_request + native proxy handlers + ├── bedrock.rs # build_request + native proxy handlers (feature: bedrock) ├── other.rs # build_request + build_proxy_request └── openrouter.rs # build_request + build_proxy_request ``` diff --git a/fixtures/README.md b/fixtures/README.md new file mode 100644 index 0000000000..693a813936 --- /dev/null +++ b/fixtures/README.md @@ -0,0 +1,68 @@ +# Fixtures + +Helpers for sharing a reproducible test workspace alongside a PR. + +The workflow is: + +1. While iterating on a PR, snapshot your local test workspace into + `fixtures/cli-sync/` so a teammate (or CI) can replay it. +2. Commit the snapshot on your branch so reviewers can load it locally. +3. **Before merging**, clear `fixtures/cli-sync/` again. CI fails on `main` / + PRs that try to merge a non-empty fixture (see `check-empty.sh`). + +## Scripts + +All scripts assume: + +- A local Windmill backend running at `http://localhost:8000` (see top-level + `AGENTS.md` for `cargo run` / `npm run dev`). +- Default super-admin credentials `admin@windmill.dev` / `changeme`. +- `bun` and `python3` are installed and on `PATH`. No `wmill` install + required — the scripts invoke `cli/src/main.ts` via `bun run` directly. + `python3` is used only to build correctly-escaped JSON request bodies. + +The login token is passed to the CLI via `--token`, which means it appears in +`/proc//cmdline` for the duration of the `bun run` call. This is fine +against a local dev instance; if you point the scripts at a real instance, +the credentials are no more exposed than running `wmill` directly with +explicit flags, but bear it in mind. + +### `load.sh` — load the fixture into a fresh workspace + +```bash +./fixtures/load.sh +``` + +Logs in as `admin@windmill.dev`, creates a new workspace with a random id +(`fixture-<8 hex chars>`), and pushes `fixtures/cli-sync/` into it via +`wmill sync push`. Prints the workspace id at the end so you can open it in +the UI. + +Flags: +- `--base-url ` (default `http://localhost:8000`) +- `--email ` (default `admin@windmill.dev`) +- `--password ` (default `changeme`) — prefer `WMILL_PASSWORD=` env + var when using a real password, since `--password` ends up in `ps` / + shell history. +- `--workspace ` (default `fixture-`) +- `--dir ` (default `fixtures/cli-sync`) + +### `snapshot.sh` — snapshot a workspace into the fixture folder + +```bash +./fixtures/snapshot.sh +``` + +Pulls the given workspace into `fixtures/cli-sync/` via `wmill sync pull`. +The target directory is cleared first (everything except `wmill.yaml` and +`.gitkeep`) so the snapshot reflects exactly what's in the workspace. + +Flags: same as `load.sh` minus `--workspace` (passed as positional arg). + +### `check-empty.sh` — fail if the fixture is non-empty + +Used by CI to guard `main`. Run it locally before opening a PR for merge: + +```bash +./fixtures/check-empty.sh +``` diff --git a/fixtures/check-empty.sh b/fixtures/check-empty.sh new file mode 100755 index 0000000000..9adfc25d3c --- /dev/null +++ b/fixtures/check-empty.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Fail if fixtures/cli-sync/ contains anything beyond the fixture scaffold +# (wmill.yaml, .gitkeep). Used by CI to guard `main` against accidentally +# merging PRs with a test workspace snapshot still committed. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +DIR="${1:-$SCRIPT_DIR/cli-sync}" + +ALLOWED_RE='^fixtures/cli-sync/(\.gitkeep|wmill\.yaml)$' + +# Use `git ls-files` so we only check tracked files. Untracked local snapshots +# are fine — devs may keep them locally between sessions. +EXTRA=$(cd "$REPO_ROOT" && git ls-files fixtures/cli-sync \ + | grep -vE "$ALLOWED_RE" || true) + +if [[ -n "$EXTRA" ]]; then + echo "✗ fixtures/cli-sync/ contains committed snapshot files:" >&2 + echo "$EXTRA" | sed 's/^/ /' >&2 + echo >&2 + echo " Run fixtures/snapshot.sh against an empty workspace or" >&2 + echo " remove the files before merging." >&2 + exit 1 +fi + +echo "✓ fixtures/cli-sync/ is clean" diff --git a/fixtures/cli-sync/.gitkeep b/fixtures/cli-sync/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/fixtures/cli-sync/wmill.yaml b/fixtures/cli-sync/wmill.yaml new file mode 100644 index 0000000000..82029f8951 --- /dev/null +++ b/fixtures/cli-sync/wmill.yaml @@ -0,0 +1,15 @@ +defaultTs: bun +includes: + - f/** +excludes: [] +skipVariables: false +skipResources: false +skipResourceTypes: false +skipSecrets: true +includeSchedules: true +includeTriggers: true +includeUsers: false +includeGroups: false +includeSettings: false +includeKey: false +syncBehavior: v1 diff --git a/fixtures/load.sh b/fixtures/load.sh new file mode 100755 index 0000000000..09d0719d3d --- /dev/null +++ b/fixtures/load.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Load fixtures/cli-sync/ into a fresh workspace on a local Windmill instance. +# +# Requires: bun on PATH. No `wmill` install needed — we invoke +# cli/src/main.ts directly via `bun run`. +# +# Assumes a Windmill backend running at http://localhost:8000 with the +# default super-admin (admin@windmill.dev / changeme). Override via flags. +set -euo pipefail + +BASE_URL="http://localhost:8000" +EMAIL="admin@windmill.dev" +# Prefer WMILL_PASSWORD env var over --password flag — flags leak into +# /proc//cmdline and shell history. +PASSWORD="${WMILL_PASSWORD:-changeme}" +WORKSPACE="" +DIR="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --base-url) BASE_URL="$2"; shift 2 ;; + --email) EMAIL="$2"; shift 2 ;; + --password) PASSWORD="$2"; shift 2 ;; + --workspace) WORKSPACE="$2"; shift 2 ;; + --dir) DIR="$2"; shift 2 ;; + -h|--help) + sed -n '2,8p' "$0"; exit 0 ;; + *) echo "Unknown flag: $1" >&2; exit 2 ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +DIR="${DIR:-$SCRIPT_DIR/cli-sync}" +DIR="$(cd "$DIR" && pwd)" +CLI_ENTRY="$REPO_ROOT/cli/src/main.ts" + +if ! command -v bun >/dev/null 2>&1; then + echo "✗ bun is required but not found on PATH" >&2 + exit 1 +fi +if [[ ! -f "$CLI_ENTRY" ]]; then + echo "✗ Cannot find CLI entry at $CLI_ENTRY" >&2 + exit 1 +fi +if [[ ! -f "$DIR/wmill.yaml" ]]; then + echo "✗ No wmill.yaml in $DIR — is the fixture folder set up?" >&2 + exit 1 +fi + +if [[ -z "$WORKSPACE" ]]; then + # Use $RANDOM rather than piping /dev/urandom through head -c, which + # SIGPIPEs `tr` and aborts the script under `set -o pipefail`. + printf -v WORKSPACE 'fixture-%04x%04x' $RANDOM $RANDOM +fi + +# JSON body builder — interpolation via printf '%s' is unsafe for arbitrary +# emails / passwords / workspace ids. Python is universal enough for a dev +# script and produces correctly escaped JSON. +json_object() { + python3 -c ' +import json, sys +print(json.dumps(dict(zip(sys.argv[1::2], sys.argv[2::2])))) +' "$@" +} + +echo "→ Logging in as $EMAIL on $BASE_URL" +TOKEN="$(curl -sS -f -X POST "$BASE_URL/api/auth/login" \ + -H "Content-Type: application/json" \ + -d "$(json_object email "$EMAIL" password "$PASSWORD")")" +if [[ -z "$TOKEN" ]]; then + echo "✗ Login failed (empty token)" >&2 + exit 1 +fi + +echo "→ Creating workspace '$WORKSPACE'" +CREATE_OUT="$(mktemp)" +trap 'rm -f "$CREATE_OUT"' EXIT +HTTP_CODE="$(curl -sS -o "$CREATE_OUT" -w '%{http_code}' \ + -X POST "$BASE_URL/api/workspaces/create" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$(json_object id "$WORKSPACE" name "$WORKSPACE")")" +if [[ "$HTTP_CODE" != "200" && "$HTTP_CODE" != "201" ]]; then + echo "✗ Workspace creation failed (HTTP $HTTP_CODE):" >&2 + cat "$CREATE_OUT" >&2 + echo >&2 + exit 1 +fi + +echo "→ Pushing $DIR to workspace '$WORKSPACE'" +( + cd "$DIR" + bun run "$CLI_ENTRY" sync push --yes \ + --base-url "$BASE_URL" \ + --workspace "$WORKSPACE" \ + --token "$TOKEN" +) + +echo +echo "✓ Fixture loaded into workspace '$WORKSPACE'" +echo " Open: ${BASE_URL%/}/?workspace=$WORKSPACE" diff --git a/fixtures/snapshot.sh b/fixtures/snapshot.sh new file mode 100755 index 0000000000..a984a47635 --- /dev/null +++ b/fixtures/snapshot.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Snapshot a workspace into fixtures/cli-sync/ so it can be committed +# alongside a PR. +# +# Requires: bun on PATH. No `wmill` install needed. +# +# Assumes a Windmill backend running at http://localhost:8000 with the +# default super-admin (admin@windmill.dev / changeme). Override via flags. +set -euo pipefail + +BASE_URL="http://localhost:8000" +EMAIL="admin@windmill.dev" +# Prefer WMILL_PASSWORD env var over --password flag — flags leak into +# /proc//cmdline and shell history. +PASSWORD="${WMILL_PASSWORD:-changeme}" +DIR="" +WORKSPACE="" + +if [[ $# -lt 1 || "$1" == "-h" || "$1" == "--help" ]]; then + sed -n '2,8p' "$0" + echo + echo "Usage: $(basename "$0") [--base-url URL] [--email E] [--password P] [--dir PATH]" + exit 0 +fi + +WORKSPACE="$1"; shift + +while [[ $# -gt 0 ]]; do + case "$1" in + --base-url) BASE_URL="$2"; shift 2 ;; + --email) EMAIL="$2"; shift 2 ;; + --password) PASSWORD="$2"; shift 2 ;; + --dir) DIR="$2"; shift 2 ;; + *) echo "Unknown flag: $1" >&2; exit 2 ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +DIR="${DIR:-$SCRIPT_DIR/cli-sync}" +DIR="$(cd "$DIR" && pwd)" +CLI_ENTRY="$REPO_ROOT/cli/src/main.ts" + +if ! command -v bun >/dev/null 2>&1; then + echo "✗ bun is required but not found on PATH" >&2 + exit 1 +fi +if [[ ! -f "$CLI_ENTRY" ]]; then + echo "✗ Cannot find CLI entry at $CLI_ENTRY" >&2 + exit 1 +fi +if [[ ! -f "$DIR/wmill.yaml" ]]; then + echo "✗ No wmill.yaml in $DIR — is the fixture folder set up?" >&2 + exit 1 +fi + +# JSON body builder — interpolation via printf '%s' is unsafe for arbitrary +# emails / passwords. Python is universal enough for a dev script and +# produces correctly escaped JSON. +json_object() { + python3 -c ' +import json, sys +print(json.dumps(dict(zip(sys.argv[1::2], sys.argv[2::2])))) +' "$@" +} + +echo "→ Logging in as $EMAIL on $BASE_URL" +TOKEN="$(curl -sS -f -X POST "$BASE_URL/api/auth/login" \ + -H "Content-Type: application/json" \ + -d "$(json_object email "$EMAIL" password "$PASSWORD")")" +if [[ -z "$TOKEN" ]]; then + echo "✗ Login failed (empty token)" >&2 + exit 1 +fi + +# Clear previous snapshot content while preserving the fixture scaffold +# (wmill.yaml, .gitkeep). Anything else is removed so the snapshot reflects +# exactly what is in the workspace. +echo "→ Clearing previous snapshot in $DIR" +( + cd "$DIR" + find . -mindepth 1 -maxdepth 1 \ + ! -name 'wmill.yaml' \ + ! -name '.gitkeep' \ + -exec rm -rf {} + +) + +echo "→ Pulling workspace '$WORKSPACE' into $DIR" +( + cd "$DIR" + bun run "$CLI_ENTRY" sync pull --yes \ + --base-url "$BASE_URL" \ + --workspace "$WORKSPACE" \ + --token "$TOKEN" +) + +echo +echo "✓ Snapshot of '$WORKSPACE' written to $DIR" +echo " Commit the changes to share with reviewers. Run fixtures/check-empty.sh" +echo " to verify the dir is empty again before merging." diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9e2c4b2d2a..a61d577ead 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.706.1", + "version": "1.709.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.706.1", + "version": "1.709.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -71,7 +71,6 @@ "svelte-carousel": "^1.0.25", "svelte-exmarkdown": "^5.0.0", "svelte-infinite-loading": "^1.4.0", - "tabbable": "^6.4.0", "tailwind-merge": "^1.13.2", "unist-util-visit": "^5.0.0", "vscode": "npm:@codingame/monaco-vscode-extension-api@=25.0.0", @@ -12393,9 +12392,10 @@ } }, "node_modules/tabbable": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", - "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "dev": true, "license": "MIT" }, "node_modules/table": { diff --git a/frontend/package.json b/frontend/package.json index 32c10b91ff..723f4075e9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,8 +1,9 @@ { "name": "@windmill-labs/components", - "version": "1.706.1", + "version": "1.709.0", "scripts": { "dev": "vite dev", + "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", "build": "vite build", "build:utils": "vite build --config sharedUtils/vite.sharedUtils.config.js", "preview": "vite preview", @@ -125,6 +126,7 @@ "lru-cache": "^11.1.0", "lucide-svelte": "^0.540.0", "mdast-util-find-and-replace": "^3.0.2", + "unist-util-visit": "^5.0.0", "minimatch": "^10.0.1", "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0", "monaco-languageclient": "10.6.0", @@ -144,9 +146,7 @@ "svelte-carousel": "^1.0.25", "svelte-exmarkdown": "^5.0.0", "svelte-infinite-loading": "^1.4.0", - "tabbable": "^6.4.0", "tailwind-merge": "^1.13.2", - "unist-util-visit": "^5.0.0", "vscode": "npm:@codingame/monaco-vscode-extension-api@=25.0.0", "vscode-languageclient": "~9.0.1", "vscode-uri": "~3.1.0", diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json index 1cfc1aef8c..05a9068963 100644 --- a/frontend/scripts/ui_builder_artifact.json +++ b/frontend/scripts/ui_builder_artifact.json @@ -1,5 +1,5 @@ { "baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev", - "version": "6715153", - "sha256": "1485930ea5f5309e4bdc09a55aae72eae8230eb74f0928715a0e6fe610703d9b" + "version": "00c9834", + "sha256": "5757e5b9cbf79c20d507dc4c588640368e84b873cb48f3806ca8c67fd1aa625f" } diff --git a/frontend/src/lib/components/AddUser.svelte b/frontend/src/lib/components/AddUser.svelte index 3a76bb96e1..e3b1455d77 100644 --- a/frontend/src/lib/components/AddUser.svelte +++ b/frontend/src/lib/components/AddUser.svelte @@ -9,6 +9,8 @@ import { goto } from '$lib/navigation' import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' + import Toggle from './Toggle.svelte' + import Tooltip from './Tooltip.svelte' import { UserPlus } from 'lucide-svelte' const dispatch = createEventDispatcher() @@ -36,7 +38,12 @@ if (!username) return await WorkspaceService.createServiceAccount({ workspace: $workspaceStore!, - requestBody: { username: username! } + requestBody: { + username: username!, + is_admin: serviceAccountRole === 'admin', + operator: serviceAccountRole === 'operator', + add_to_deployers: serviceAccountRole === 'developer' && addToDeployers + } }) sendUserToast(`Service account '${username}' created`) } else { @@ -80,7 +87,10 @@ } type UserRole = 'operator' | 'developer' | 'admin' | 'service_account' + type ServiceAccountRole = 'operator' | 'developer' | 'admin' let selected: UserRole = $state('developer' as UserRole) + let serviceAccountRole: ServiceAccountRole = $state('operator' as ServiceAccountRole) + let addToDeployers: boolean = $state(true) let isServiceAccount = $derived(selected === 'service_account') @@ -144,6 +154,52 @@ /> {/snippet} + + {#if isServiceAccount} + Service account role + + {#snippet children({ item })} + + + + {/snippet} + + + {#if serviceAccountRole === 'developer'} +
+ + + Add to wm_deployers + + Recommended when this service account will be used as a wmill sync push + / CI deploy identity. Members of wm_deployers can deploy on behalf of + other users in the target workspace. + Learn more. + + +
+ {/if} + {/if} - + {#if !installation.github_base_url} + + {/if} + {#if !installation.provisioned_by_admin} + + {/if} @@ -381,7 +396,10 @@ {#if installation.error} - Token error + Token error {:else} {installation.repositories.length} repos {/if} @@ -414,26 +432,28 @@ -
-

Import installation from other instance:

-
- - +
+ + +
-
+ {/if} {/snippet} diff --git a/frontend/src/lib/components/Path.svelte b/frontend/src/lib/components/Path.svelte index 8306c8a410..b4bd67632e 100644 --- a/frontend/src/lib/components/Path.svelte +++ b/frontend/src/lib/components/Path.svelte @@ -5,7 +5,7 @@ + +
+ {#snippet action()} +
diff --git a/frontend/src/lib/components/common/button/model.ts b/frontend/src/lib/components/common/button/model.ts index b950bd1bff..8126ef8667 100644 --- a/frontend/src/lib/components/common/button/model.ts +++ b/frontend/src/lib/components/common/button/model.ts @@ -17,7 +17,7 @@ export namespace ButtonType { * @deprecated Use `UnifiedSize` instead */ export type Size = 'xs3' | 'xs2' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' - export type UnifiedSize = 'xs' | 'sm' | 'md' | 'lg' + export type UnifiedSize = '2xs' | 'xs' | 'sm' | 'md' | 'lg' export type ExtendedSize = 'xs2' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' /** * @deprecated Use `Variant` instead @@ -221,6 +221,7 @@ export namespace ButtonType { // New unified sizing system export const UnifiedSizingClasses: Record = { + '2xs': 'px-1', // Compact horizontal padding xs: 'px-2', sm: 'px-2', // Regular horizontal padding md: 'px-4', @@ -228,6 +229,7 @@ export namespace ButtonType { } export const UnifiedIconOnlySizingClasses: Record = { + '2xs': 'px-1', xs: 'px-1', sm: 'px-2', // Square padding for icon-only (same as width padding) md: 'px-2', @@ -235,6 +237,7 @@ export namespace ButtonType { } export const UnifiedMinHeightClasses: Record = { + '2xs': 'min-h-5', xs: 'min-h-5', sm: 'min-h-7', md: 'min-h-8', @@ -242,6 +245,7 @@ export namespace ButtonType { } export const UnifiedHeightClasses: Record = { + '2xs': 'h-5', xs: 'h-5', sm: 'h-7', md: 'h-8', @@ -249,6 +253,7 @@ export namespace ButtonType { } export const UnifiedIconSizes: Record = { + '2xs': 12, xs: 12, sm: 13, md: 14, @@ -256,6 +261,7 @@ export namespace ButtonType { } export const UnifiedFontSizes: Record = { + '2xs': 'font-normal', xs: 'font-normal', sm: 'font-normal', md: 'font-medium', diff --git a/frontend/src/lib/components/common/tabs/DraggableTabs.svelte b/frontend/src/lib/components/common/tabs/DraggableTabs.svelte new file mode 100644 index 0000000000..937191d272 --- /dev/null +++ b/frontend/src/lib/components/common/tabs/DraggableTabs.svelte @@ -0,0 +1,251 @@ + + + + +{#snippet tabButton(tab: TabItem)} + {@const isActive = tab.id === activeId} + {@const Icon = tab.icon} + + +{/snippet} + +
+
+
+ +
+
+ {#each pinnedLeft as tab (tab.id)} + {@render tabButton(tab)} + {/each} + +
+ {#each dndMiddle as tab (tab.id)} +
+ {@render tabButton(tab)} +
+ {/each} +
+ + {#each pinnedRight as tab (tab.id)} + {@render tabButton(tab)} + {/each} + + + +
+
+
+ +
+
+
+
+ + {#if trailing} +
+ {@render trailing()} +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index e689c3e6c0..bb97d61ee5 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -6,9 +6,11 @@ import { AlertTriangle, ArrowDown, + AtSign, ChevronDown, ChevronsRight, CheckIcon, + Hand, HistoryIcon, Hourglass, MousePointer2, @@ -20,6 +22,7 @@ import Button from '$lib/components/common/button/Button.svelte' import { fade } from 'svelte/transition' import Popover from '$lib/components/meltComponents/Popover.svelte' + import DropdownV2 from '$lib/components/DropdownV2.svelte' import { type DisplayMessage } from './shared' import type { ContextElement } from './context' import ChatQuickActions from './ChatQuickActions.svelte' @@ -38,16 +41,21 @@ const MAX_YOLO_TOOLTIP_TOOLS = 8 const aiChatManager = getAiChatManager() - type AutonomyModeOption = { label: string; mode: AIAutonomyMode } + // `label` is shown in the dropdown; `shortLabel` (when set) is shown in the + // compact trigger pill to save horizontal space. + type AutonomyModeOption = { label: string; shortLabel?: string; mode: AIAutonomyMode } const autonomyModeOptions: AutonomyModeOption[] = [ - { label: 'auto accept off', mode: AIAutonomyMode.DEFAULT }, - { label: 'auto accept on', mode: AIAutonomyMode.ACCEPT_EDIT }, - { label: 'yolo on', mode: AIAutonomyMode.YOLO } + { label: 'Ask permission', mode: AIAutonomyMode.DEFAULT }, + { label: 'Auto-accept edits', mode: AIAutonomyMode.ACCEPT_EDIT }, + { label: 'Yolo (bypass permissions)', shortLabel: 'Yolo', mode: AIAutonomyMode.YOLO } ] - const autonomyModeLabel = ( - mode: AIAutonomyMode, - options: AutonomyModeOption[] = autonomyModeOptions - ) => options.find((option) => option.mode === mode)?.label ?? autonomyModeOptions[0].label + const autonomyModeLabel = (mode: AIAutonomyMode) => { + const option = autonomyModeOptions.find((o) => o.mode === mode) ?? autonomyModeOptions[0] + return option.shortLabel ?? option.label + } + // "Auto-accept edits" only applies where script/flow edits can be accepted, + // "Bypass permissions" only where tool confirmations exist; filter the picker + // to the levels that actually do something in the current mode. const isAutonomyModeAvailable = ( mode: AIAutonomyMode, autoAcceptEditsAvailable: boolean, @@ -63,6 +71,16 @@ } return false } + // Ask-permission holds (raised hand); auto-accept/bypass fast-forward. Color + // ramps from muted (ask) to accent (auto-accept) to red (bypass). + const autonomyModeIcon = (mode: AIAutonomyMode) => + mode === AIAutonomyMode.DEFAULT ? Hand : ChevronsRight + const autonomyModeIconColor = (mode: AIAutonomyMode) => + mode === AIAutonomyMode.YOLO + ? 'text-red-500' + : mode === AIAutonomyMode.DEFAULT + ? 'text-secondary' + : 'text-accent' let { messages, @@ -218,6 +236,8 @@ ) ) ) + // Fall back to ask-permission when the persisted mode isn't applicable in the + // current AI mode (e.g. auto-accept edits while in a mode without edits). const effectiveAutonomyMode = $derived( availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode) ? aiChatManager.autonomyMode @@ -405,7 +425,7 @@ {#if showTypingIndicator}
@@ -494,7 +514,7 @@ isFirstMessage={messages.length === 0} />
@@ -503,12 +523,14 @@ {#if showContextPicker && !disabled} {#snippet trigger()} -
- @ -
+ iconOnly + startIcon={{ icon: AtSign }} + /> {/snippet} {#snippet content({ close })} {#if aiChatManager.mode === AIMode.APP} @@ -538,53 +560,33 @@
{/if} {#if showAutonomyModeSelector} -
- - {#snippet trigger()} -
- - {autonomyModeLabel( - effectiveAutonomyMode, - availableAutonomyModeOptions - )} -
- -
-
- {/snippet} - {#snippet content({ close })} -
- {#each availableAutonomyModeOptions as option (option.mode)} - - {/each} -
- {/snippet} -
-
+ + availableAutonomyModeOptions.map((option) => ({ + displayName: option.label, + selected: effectiveAutonomyMode === option.mode, + action: () => aiChatManager.setAutonomyMode(option.mode) + }))} + placement="bottom-start" + fixedHeight={false} + customWidth={240} + > + {#snippet buttonReplacement()} + + {/snippet} + {/if} {#if effectiveAutonomyMode === AIAutonomyMode.YOLO && aiChatManager.autoAcceptToolConfirmationsAvailable} @@ -593,8 +595,8 @@

{aiChatManager.autoAcceptEditsAvailable - ? 'Yolo auto-accepts edits and tool usage.' - : 'Yolo auto-accepts tool usage.'} + ? 'Bypass permissions auto-accepts edits and tool usage.' + : 'Bypass permissions auto-accepts tool usage.'}

{aiChatManager.autoAcceptEditsAvailable diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 93145e3b1b..79bf64ffb7 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -126,15 +126,18 @@ function isWorkspacePath(path: string | undefined): path is string { function getPersistedAutonomyMode(): AIAutonomyMode { if (!BROWSER || typeof localStorage === 'undefined') { - return AIAutonomyMode.DEFAULT + return AIAutonomyMode.ACCEPT_EDIT } const persistedMode = localStorage.getItem(AI_AUTONOMY_MODE_STORAGE_KEY) if (isAIAutonomyMode(persistedMode)) { return persistedMode } + // No stored preference: default to auto-accepting edits (tool calls still + // require confirmation; only YOLO bypasses those). Note this means users who + // never opened the autonomy picker now start with edit auto-accept on. return localStorage.getItem(LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY) === 'true' ? AIAutonomyMode.YOLO - : AIAutonomyMode.DEFAULT + : AIAutonomyMode.ACCEPT_EDIT } function persistAutonomyMode(mode: AIAutonomyMode) { diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index df2cba5575..35ebbc257d 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -46,6 +46,13 @@ vi.mock('./global/gate', () => ({ isGlobalAiEnabled: () => true })) +// Force BROWSER=true so localStorage-backed autonomy persistence is exercised +// (the vitest "server" env reports BROWSER=false, which would short-circuit it). +vi.mock('esm-env', async (importOriginal) => ({ + ...(await importOriginal()), + BROWSER: true +})) + function createFlowHelpers({ hasPendingChanges, acceptAllModuleActions @@ -75,6 +82,9 @@ function createFlowHelpers({ describe('AIChatManager autonomy mode', () => { beforeEach(() => { localStorage.clear() + // These tests exercise the transition into auto-accept, so start from the + // ask-permission baseline rather than the new auto-accept-edits default. + localStorage.setItem('ai-chat-autonomy-mode', AIAutonomyMode.DEFAULT) vi.clearAllMocks() }) @@ -153,3 +163,28 @@ describe('AIChatManager autonomy mode', () => { expect(applied).toBe(true) }) }) + +describe('AIChatManager persisted autonomy default', () => { + // Mirrors the private storage keys in AIChatManager.svelte.ts. + const AUTONOMY_KEY = 'ai-chat-autonomy-mode' + const LEGACY_YOLO_KEY = 'ai-chat-yolo-mode' + + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + }) + + it('defaults to auto-accept edits when no preference is stored', () => { + expect(new AIChatManager().autonomyMode).toBe(AIAutonomyMode.ACCEPT_EDIT) + }) + + it('maps the legacy auto-accept-tool-confirmations flag to YOLO', () => { + localStorage.setItem(LEGACY_YOLO_KEY, 'true') + expect(new AIChatManager().autonomyMode).toBe(AIAutonomyMode.YOLO) + }) + + it('restores an explicitly persisted autonomy mode', () => { + localStorage.setItem(AUTONOMY_KEY, AIAutonomyMode.DEFAULT) + expect(new AIChatManager().autonomyMode).toBe(AIAutonomyMode.DEFAULT) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte index 29481a04f2..e59ed4b513 100644 --- a/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte @@ -2,6 +2,7 @@ import { onMount, tick } from 'svelte' import { CircleHelp } from 'lucide-svelte' import Button from '$lib/components/common/button/Button.svelte' + import TextInput from '$lib/components/text_input/TextInput.svelte' import { aiChatManager } from './AIChatManager.svelte' import type { UserQuestionDisplay } from './shared' @@ -13,6 +14,8 @@ let { toolCallId, userQuestion }: Props = $props() let choiceButtons = $state<(HTMLButtonElement | undefined)[]>([]) + let customAnswer = $state('') + let canSubmitCustomAnswer = $derived(customAnswer.trim().length > 0) onMount(() => { if (userQuestion.choices.length === 0) { @@ -32,6 +35,15 @@ aiChatManager.handleUserQuestionAnswer(toolCallId, choice) } + function submitCustomAnswer() { + const answer = customAnswer.trim() + if (!answer) { + return + } + + aiChatManager.handleUserQuestionAnswer(toolCallId, answer) + } + function handleChoiceKeydown(event: KeyboardEvent, choice: string, index: number) { if (event.key === 'ArrowDown' || event.key === 'ArrowRight') { event.preventDefault() @@ -53,6 +65,16 @@ selectChoice(choice) } } + + function handleCustomAnswerKeydown(event: KeyboardEvent) { + if (event.key !== 'Enter') { + return + } + + event.preventDefault() + event.stopPropagation() + submitCustomAnswer() + }

{/each} + +
+ + +
diff --git a/frontend/src/lib/components/copilot/chat/ChatMode.svelte b/frontend/src/lib/components/copilot/chat/ChatMode.svelte index 8bb0ab5c0d..5a40c49e36 100644 --- a/frontend/src/lib/components/copilot/chat/ChatMode.svelte +++ b/frontend/src/lib/components/copilot/chat/ChatMode.svelte @@ -1,54 +1,37 @@ -
- aiChatManager.allowedModes[k] - ).length < 2} - class="max-w-full" +{#if hasMultiple} + + allowedModeList.map((mode) => ({ + displayName: modeLabel(mode), + selected: aiChatManager.mode === mode, + action: () => aiChatManager.changeMode(mode) + }))} + placement="bottom-start" + fixedHeight={false} + customWidth={170} > - {#snippet trigger()} - -
- - {aiChatManager.mode.charAt(0).toUpperCase() + aiChatManager.mode.slice(1)} mode - - {#if Object.keys(aiChatManager.allowedModes).filter((k) => aiChatManager.allowedModes[k]).length > 1} -
- -
- {/if} -
- - {/snippet} - {#snippet content({ close })} - -
- {#each Object.values(AIMode) as possibleMode} - {#if aiChatManager.allowedModes[possibleMode]} - - {/if} - {/each} -
- - {/snippet} -
-
+ {#snippet buttonReplacement()} + + {/snippet} + +{:else} + +{/if} diff --git a/frontend/src/lib/components/copilot/chat/ProviderModelSelector.svelte b/frontend/src/lib/components/copilot/chat/ProviderModelSelector.svelte index ed6118bc48..7f9d993a6c 100644 --- a/frontend/src/lib/components/copilot/chat/ProviderModelSelector.svelte +++ b/frontend/src/lib/components/copilot/chat/ProviderModelSelector.svelte @@ -1,12 +1,12 @@ -
- - {#snippet trigger()} -
- {providerModel.model} - {#if multipleModels} -
- -
- {/if} -
+{#if multipleModels} + + $copilotInfo.aiModels.map((m) => ({ + displayName: m.model, + selected: m.model === providerModel.model, + action: () => { + $copilotSessionModel = m + storeLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME, m.model) + storeLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME, m.provider) + } + }))} + placement="bottom-end" + fixedHeight={false} + > + {#snippet buttonReplacement()} + {/snippet} - {#snippet content({ close })} -
- {#each $copilotInfo.aiModels as providerModel} - - {/each} -
- {/snippet} -
-
+ +{:else} + +{/if} diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 13b870901b..6a7a9c8a9c 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -42,17 +42,75 @@ vi.mock('$lib/gen', async () => { return { ...actual, + ScriptService: wrapService(actual.ScriptService, { + existsScriptByPath: vi.fn(async () => false), + createScript: vi.fn(async () => 'created'), + getScriptByPathWithDraft: vi.fn(async () => { + throw new Error('getScriptByPathWithDraft mock not configured') + }), + listScripts: vi.fn(async () => []) + }), FlowService: wrapService(actual.FlowService, { - existsFlowByPath: vi.fn(async () => false) + existsFlowByPath: vi.fn(async () => false), + createFlow: vi.fn(async () => 'created'), + updateFlow: vi.fn(async () => 'updated'), + getFlowByPath: vi.fn(async () => { + throw new Error('getFlowByPath mock not configured') + }), + getFlowByPathWithDraft: vi.fn(async () => { + throw new Error('getFlowByPathWithDraft mock not configured') + }), + getFlowLatestVersion: vi.fn(async () => ({ id: 1 })), + listFlows: vi.fn(async () => []) + }), + ScheduleService: wrapService(actual.ScheduleService, { + existsSchedule: vi.fn(async () => false), + getSchedule: vi.fn(async () => { + throw new Error('getSchedule mock not configured') + }) + }), + HttpTriggerService: wrapService(actual.HttpTriggerService, { + existsHttpTrigger: vi.fn(async () => false), + getHttpTrigger: vi.fn(async () => { + throw new Error('getHttpTrigger mock not configured') + }) + }), + AppService: wrapService(actual.AppService, { + existsApp: vi.fn(async () => false), + getAppByPathWithDraft: vi.fn(async () => { + throw new Error('getAppByPathWithDraft mock not configured') + }), + listApps: vi.fn(async () => []) + }), + ResourceService: wrapService(actual.ResourceService, { + existsResource: vi.fn(async () => false), + getResource: vi.fn(async () => { + throw new Error('getResource mock not configured') + }) }), VariableService: wrapService(actual.VariableService, { - existsVariable: vi.fn(async () => false) + existsVariable: vi.fn(async () => false), + getVariable: vi.fn(async () => { + throw new Error('getVariable mock not configured') + }), + createVariable: vi.fn(async () => 'created'), + updateVariable: vi.fn(async () => 'updated') }) } }) -import { globalTools, prepareGlobalUserMessage } from './core' -import { globalDraftStore } from './draftStore.svelte' +import { globalTools, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './core' +import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte' +import { clearGlobalDrafts } from './userDraftAdapter' +import { + AppService, + FlowService, + HttpTriggerService, + ResourceService, + ScheduleService, + ScriptService, + VariableService +} from '$lib/gen' import type { Tool, ToolCallbacks } from '../shared' const WORKSPACE = 'global-core-test' @@ -84,9 +142,20 @@ async function callGlobalTool( }) } +function localStorageSnapshot(): string { + const values: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key) values.push(`${key}: ${localStorage.getItem(key)}`) + } + return values.join('\n') +} + describe('global AI tools', () => { beforeEach(() => { - globalDraftStore.clearDrafts(WORKSPACE) + __resetUserDraftForTesting() + localStorage.clear() + clearGlobalDrafts(WORKSPACE) vi.clearAllMocks() }) @@ -105,6 +174,7 @@ describe('global AI tools', () => { const item = JSON.parse(raw) expect(raw).not.toContain('super-secret-token') + expect(localStorageSnapshot()).not.toContain('super-secret-token') expect(item).toEqual({ type: 'variable', path: 'f/secrets/api_key', @@ -113,6 +183,837 @@ describe('global AI tools', () => { }) }) + it('writes resource drafts in the editor UserDraft shape', async () => { + vi.mocked(ResourceService.existsResource).mockResolvedValueOnce(true) + vi.mocked(ResourceService.getResource).mockResolvedValueOnce({ + path: 'f/resources/db', + description: 'existing database', + value: { host: 'old.example.com', port: 5432 }, + resource_type: 'postgresql', + labels: ['prod'], + ws_specific: true, + edited_at: '2026-05-22T09:30:00Z' + } as any) + + await callGlobalTool('write_resource', { + path: 'f/resources/db', + value: { host: 'new.example.com', port: 5432 }, + resource_type: 'postgresql' + }) + + expect(UserDraft.get('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({ + path: 'f/resources/db', + description: 'existing database', + args: { host: 'new.example.com', port: 5432 }, + labels: ['prod'], + wsSpecific: true, + resource_type: 'postgresql' + }) + expect(UserDraft.getMeta('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({ + remoteRev: '2026-05-22T09:30:00Z' + }) + }) + + it('writes variable drafts in the editor UserDraft shape', async () => { + vi.mocked(VariableService.existsVariable).mockResolvedValueOnce(true) + vi.mocked(VariableService.getVariable).mockResolvedValueOnce({ + path: 'f/secrets/api_key', + value: undefined, + is_secret: true, + description: 'old description', + account: 123, + is_oauth: true, + expires_at: '2026-06-22T09:30:00Z', + labels: ['prod'], + ws_specific: true, + edited_at: '2026-05-22T09:30:00Z' + } as any) + + await callGlobalTool('write_variable', { + path: 'f/secrets/api_key', + value: 'new-secret-token', + is_secret: true, + description: 'new description' + }) + + expect(UserDraft.get('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual({ + path: 'f/secrets/api_key', + variable: { + value: '', + is_secret: true, + description: 'new description' + }, + labels: ['prod'], + wsSpecific: true, + account: 123, + is_oauth: true, + expires_at: '2026-06-22T09:30:00Z' + }) + expect(UserDraft.getMeta('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual({ + remoteRev: '2026-05-22T09:30:00Z' + }) + expect(localStorageSnapshot()).not.toContain('new-secret-token') + }) + + it('deploys secret variable drafts with ephemeral values only', async () => { + await callGlobalTool('write_variable', { + path: 'f/secrets/api_key', + value: 'new-secret-token', + is_secret: true, + description: 'new description' + }) + + expect( + UserDraft.get('variable', 'f/secrets/api_key', { workspace: WORKSPACE }) + ).toMatchObject({ + path: 'f/secrets/api_key', + variable: { + value: '', + is_secret: true, + description: 'new description' + }, + wsSpecific: false + }) + expect(localStorageSnapshot()).not.toContain('new-secret-token') + + await callGlobalTool('deploy_workspace_item', { + type: 'variable', + path: 'f/secrets/api_key' + }) + + expect(VariableService.createVariable).toHaveBeenCalledWith({ + workspace: WORKSPACE, + requestBody: expect.objectContaining({ + path: 'f/secrets/api_key', + value: 'new-secret-token', + is_secret: true, + description: 'new description', + ws_specific: false + }) + }) + expect(UserDraft.get('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toBeUndefined() + expect(localStorageSnapshot()).not.toContain('new-secret-token') + }) + + it('does not deploy a secret variable draft when the ephemeral value is gone', async () => { + UserDraft.save( + 'variable', + 'f/secrets/api_key', + { + path: 'f/secrets/api_key', + variable: { + value: '', + is_secret: true, + description: 'new description' + }, + labels: undefined, + wsSpecific: false + }, + { workspace: WORKSPACE } + ) + + await expect( + callGlobalTool('deploy_workspace_item', { + type: 'variable', + path: 'f/secrets/api_key' + }) + ).rejects.toThrow('secret draft values are kept only in memory') + expect(VariableService.createVariable).not.toHaveBeenCalled() + expect(VariableService.updateVariable).not.toHaveBeenCalled() + }) + + it('writes script drafts into UserDraft', async () => { + const content = 'export async function main() {\n\treturn "hello"\n}' + + await callGlobalTool('write_script', { + path: 'f/scripts/hello', + summary: 'Hello script', + language: 'bun', + content + }) + + expect(UserDraft.get('script', 'f/scripts/hello', { workspace: WORKSPACE })).toMatchObject( + { + path: 'f/scripts/hello', + summary: 'Hello script', + language: 'bun', + content + } + ) + }) + + it('applies path_prefix to local drafts before enforcing the result limit', async () => { + await callGlobalTool('write_script', { + path: 'f/other/outside', + summary: 'Outside draft', + language: 'bun', + content: 'export async function main() { return "outside" }' + }) + await callGlobalTool('write_script', { + path: 'f/matching/inside', + summary: 'Inside draft', + language: 'bun', + content: 'export async function main() { return "inside" }' + }) + + const raw = await callGlobalTool('list_workspace_items', { + types: ['script'], + path_prefix: 'f/matching/', + limit: 1 + }) + + expect(JSON.parse(raw)).toEqual([ + expect.objectContaining({ + type: 'script', + path: 'f/matching/inside', + isDraft: true + }) + ]) + }) + + it('lists and edits the live script editor draft through its effective path', async () => { + UserDraft.save( + 'script', + '', + { + path: 'u/admin/amazed_script', + summary: 'Live script', + description: '', + content: 'export async function main(a: number, b: number) {\n\treturn a + b\n}', + schema: {}, + is_template: false, + language: 'bun', + kind: 'script' + }, + { workspace: WORKSPACE } + ) + UserDraft.setLiveEditorDraft({ + workspace: WORKSPACE, + itemKind: 'script', + storagePath: '', + effectivePath: 'u/admin/amazed_script' + }) + + const listRaw = await callGlobalTool('list_workspace_items', { types: ['script'] }) + expect(JSON.parse(listRaw)).toContainEqual( + expect.objectContaining({ + type: 'script', + path: 'u/admin/amazed_script', + isDraft: true, + isLiveDraft: true + }) + ) + + await callGlobalTool('edit_script', { + path: 'u/admin/amazed_script', + old_string: 'return a + b', + new_string: 'return a * b' + }) + + expect(UserDraft.get('script', '', { workspace: WORKSPACE })).toMatchObject({ + path: 'u/admin/amazed_script', + content: 'export async function main(a: number, b: number) {\n\treturn a * b\n}' + }) + expect( + UserDraft.get('script', 'u/admin/amazed_script', { workspace: WORKSPACE }) + ).toBeUndefined() + }) + + it('lists and writes the live flow editor draft through its effective path', async () => { + UserDraft.save( + 'flow', + '', + { + path: '', + summary: 'Live flow', + value: { modules: [] }, + schema: {}, + edited_by: '', + edited_at: '', + archived: false, + extra_perms: {} + }, + { workspace: WORKSPACE } + ) + UserDraft.setLiveEditorDraft({ + workspace: WORKSPACE, + itemKind: 'flow', + storagePath: '', + effectivePath: 'u/admin/live_flow' + }) + + const listRaw = await callGlobalTool('list_workspace_items', { types: ['flow'] }) + expect(JSON.parse(listRaw)).toContainEqual( + expect.objectContaining({ + type: 'flow', + path: 'u/admin/live_flow', + isDraft: true, + isLiveDraft: true + }) + ) + + await callGlobalTool('write_flow', { + path: 'u/admin/live_flow', + summary: 'Updated live flow', + modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }]) + }) + + expect(UserDraft.get('flow', '', { workspace: WORKSPACE })).toMatchObject({ + path: 'u/admin/live_flow', + summary: 'Updated live flow', + value: { modules: [{ id: 'step', value: { type: 'identity' } }] } + }) + expect(UserDraft.get('flow', 'u/admin/live_flow', { workspace: WORKSPACE })).toBeUndefined() + }) + + it('writes the live raw app editor draft through its effective path', async () => { + UserDraft.save( + 'raw_app', + '', + { + summary: 'Live app', + files: { '/src/App.tsx': 'export default function App() { return null }' }, + runnables: {}, + data: { tables: [] } + }, + { workspace: WORKSPACE } + ) + UserDraft.setLiveEditorDraft({ + workspace: WORKSPACE, + itemKind: 'raw_app', + storagePath: '', + effectivePath: 'u/admin/live_app' + }) + + await callGlobalTool('write_app_file', { + path: 'u/admin/live_app', + file_path: '/src/New.tsx', + content: 'export default function New() { return null }' + }) + + expect(UserDraft.get('raw_app', '', { workspace: WORKSPACE })).toMatchObject({ + files: { + '/src/App.tsx': 'export default function App() { return null }', + '/src/New.tsx': 'export default function New() { return null }' + } + }) + expect(UserDraft.get('raw_app', 'u/admin/live_app', { workspace: WORKSPACE })).toBeUndefined() + }) + + it('discards a local draft without deleting the workspace item', async () => { + await callGlobalTool('write_script', { + path: 'f/scripts/discard-me', + summary: 'Temporary draft', + language: 'bun', + content: 'export async function main() { return 1 }' + }) + + expect(UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })).toBeDefined() + + const raw = await callGlobalTool('discard_local_draft', { + type: 'script', + path: 'f/scripts/discard-me' + }) + + expect(JSON.parse(raw)).toMatchObject({ + success: true, + type: 'script', + path: 'f/scripts/discard-me' + }) + expect(raw).toContain('The deployed workspace item was not changed') + expect( + UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE }) + ).toBeUndefined() + }) + + it('requires trigger_kind when discarding a trigger draft', async () => { + await expect( + callGlobalTool('discard_local_draft', { + type: 'trigger', + path: 'f/routes/missing-kind' + }) + ).rejects.toThrow('trigger_kind is required') + }) + + it('preserves existing script metadata and seeds freshness on first script write', async () => { + vi.mocked(ScriptService.existsScriptByPath).mockResolvedValueOnce(true) + vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({ + path: 'f/scripts/existing', + hash: 'deployed-hash', + draft_created_at: '2026-05-22T10:00:00Z', + summary: 'deployed summary', + description: 'deployed description', + content: 'old deployed content', + language: 'bun', + kind: 'script', + draft: { + path: 'f/scripts/existing', + summary: 'db draft summary', + description: 'db draft description', + content: 'old draft content', + language: 'bun', + kind: 'script' + } + } as any) + + await callGlobalTool('write_script', { + path: 'f/scripts/existing', + summary: 'new summary', + language: 'bun', + content: 'new content' + }) + + expect( + UserDraft.get('script', 'f/scripts/existing', { workspace: WORKSPACE }) + ).toMatchObject({ + path: 'f/scripts/existing', + parent_hash: 'deployed-hash', + summary: 'new summary', + description: 'db draft description', + content: 'new content', + language: 'bun' + }) + expect(UserDraft.getMeta('script', 'f/scripts/existing', { workspace: WORKSPACE })).toEqual({ + remoteRev: 'deployed-hash', + remoteDraftRev: '2026-05-22T10:00:00Z' + }) + }) + + it('preserves existing flow metadata and seeds freshness on first flow write', async () => { + vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true) + vi.mocked(FlowService.getFlowLatestVersion).mockResolvedValueOnce({ id: 42 } as any) + vi.mocked(FlowService.getFlowByPathWithDraft).mockResolvedValueOnce({ + path: 'f/flows/existing', + summary: 'deployed summary', + description: 'deployed description', + value: { modules: [] }, + schema: { properties: { deployed: { type: 'boolean' } } }, + edited_by: 'admin', + edited_at: '2026-05-22T09:00:00Z', + archived: false, + extra_perms: {}, + draft_created_at: '2026-05-22T10:00:00Z', + draft: { + path: 'f/flows/existing', + summary: 'db draft summary', + description: 'db draft description', + value: { modules: [] }, + schema: { properties: { draft: { type: 'string' } } }, + edited_by: 'admin', + edited_at: '2026-05-22T09:30:00Z', + archived: false, + extra_perms: {} + } + } as any) + + await callGlobalTool('write_flow', { + path: 'f/flows/existing', + summary: 'new summary', + modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }]) + }) + + expect(UserDraft.get('flow', 'f/flows/existing', { workspace: WORKSPACE })).toMatchObject({ + path: 'f/flows/existing', + summary: 'new summary', + description: 'db draft description', + value: { modules: [{ id: 'step', value: { type: 'identity' } }] } + }) + expect(UserDraft.getMeta('flow', 'f/flows/existing', { workspace: WORKSPACE })).toEqual({ + remoteRev: 42, + remoteDraftRev: '2026-05-22T10:00:00Z' + }) + }) + + it('preserves editor schedule fields when writing over an existing schedule', async () => { + vi.mocked(ScheduleService.existsSchedule).mockResolvedValueOnce(true) + vi.mocked(ScheduleService.getSchedule).mockResolvedValueOnce({ + path: 'f/schedules/nightly', + schedule: '0 0 0 * * *', + timezone: 'UTC', + enabled: true, + script_path: 'f/scripts/old', + is_flow: false, + args: {}, + extra_perms: { 'u/viewer': true }, + email: 'admin@windmill.dev', + permissioned_as: 'u/admin', + edited_by: 'admin', + edited_at: '2026-05-22T09:00:00Z', + summary: 'old summary', + description: 'keep this description', + no_flow_overlap: true, + cron_version: 'v2' + } as any) + + await callGlobalTool('write_schedule', { + path: 'f/schedules/nightly', + schedule: '0 15 0 * * *', + timezone: 'Europe/Paris', + script_path: 'f/flows/new', + is_flow: true, + args: { limit: 5 } + }) + + expect( + UserDraft.get('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE }) + ).toMatchObject({ + path: 'f/schedules/nightly', + schedule: '0 15 0 * * *', + timezone: 'Europe/Paris', + script_path: 'f/flows/new', + is_flow: true, + args: { limit: 5 }, + extra_perms: { 'u/viewer': true }, + permissioned_as: 'u/admin', + summary: 'old summary', + description: 'keep this description', + no_flow_overlap: true + }) + expect( + UserDraft.get('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE }) + ).not.toMatchObject({ + edited_by: expect.anything() + }) + }) + + it('preserves editor trigger fields when writing over an existing trigger', async () => { + vi.mocked(HttpTriggerService.existsHttpTrigger).mockResolvedValueOnce(true) + vi.mocked(HttpTriggerService.getHttpTrigger).mockResolvedValueOnce({ + path: 'f/routes/api', + script_path: 'f/scripts/old', + is_flow: false, + route_path: 'api/old', + http_method: 'post', + request_type: 'sync', + authentication_method: 'none', + is_static_website: false, + workspaced_route: false, + wrap_body: false, + raw_string: false, + mode: 'enabled', + extra_perms: { 'u/viewer': true }, + workspace_id: WORKSPACE, + edited_by: 'admin', + edited_at: '2026-05-22T09:00:00Z', + permissioned_as: 'u/admin', + summary: 'old route', + description: 'keep route description' + } as any) + + await callGlobalTool('write_trigger', { + kind: 'http', + config: { + path: 'f/routes/api', + script_path: 'f/flows/new', + is_flow: true, + route_path: 'api/new', + http_method: 'get', + authentication_method: 'windmill', + is_static_website: false + } + }) + + const draft = UserDraft.get('trigger_http', 'f/routes/api', { workspace: WORKSPACE }) + expect(draft).toMatchObject({ + path: 'f/routes/api', + script_path: 'f/flows/new', + is_flow: true, + route_path: 'api/new', + http_method: 'get', + authentication_method: 'windmill', + extra_perms: { 'u/viewer': true }, + permissioned_as: 'u/admin', + summary: 'old route', + description: 'keep route description' + }) + expect(draft).not.toMatchObject({ + workspace_id: expect.anything(), + edited_by: expect.anything() + }) + }) + + it('seeds raw app draft metadata on first app write', async () => { + vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ + path: 'f/apps/report', + summary: 'deployed app', + versions: [3, 4], + draft_created_at: '2026-05-22T10:30:00Z', + value: { + files: { '/src/App.tsx': 'deployed content' }, + runnables: {}, + data: { tables: [] } + }, + policy: { execution_mode: 'publisher' }, + custom_path: 'report', + draft: { + summary: 'saved app draft', + value: { + files: { '/src/App.tsx': 'draft content' }, + runnables: { + main: { + type: 'inline', + inlineScript: { language: 'bun', content: 'export async function main() {}' } + } + }, + data: { tables: ['orders'], datatable: 'db', schema: 'public' } + }, + policy: { execution_mode: 'anonymous' } + } + } as any) + + await callGlobalTool('write_app_file', { + path: 'f/apps/report', + file_path: '/src/New.tsx', + content: 'export default function New() { return null }' + }) + + const draft = UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE }) + expect(draft).toMatchObject({ + summary: 'saved app draft', + files: { + '/src/App.tsx': 'draft content', + '/src/New.tsx': 'export default function New() { return null }' + }, + runnables: { + main: { + type: 'inline', + inlineScript: { language: 'bun', content: 'export async function main() {}' } + } + }, + data: { tables: ['orders'], datatable: 'db', schema: 'public' }, + policy: { execution_mode: 'anonymous' }, + custom_path: 'report' + }) + expect(UserDraft.getMeta('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toEqual({ + remoteRev: 4, + remoteDraftRev: '2026-05-22T10:30:00Z' + }) + }) + + it('summarizes local raw app drafts in read_workspace_item', async () => { + UserDraft.save( + 'raw_app', + 'f/apps/local', + { + summary: 'local app', + files: { '/src/App.tsx': 'const frontendSecret = "do-not-dump"' }, + runnables: { + main: { + type: 'inline', + inlineScript: { + language: 'bun', + content: 'const backendSecret = "do-not-dump"' + } + } + }, + data: { tables: ['orders'] } + }, + { workspace: WORKSPACE } + ) + + const raw = await callGlobalTool('read_workspace_item', { + type: 'app', + path: 'f/apps/local' + }) + const item = JSON.parse(raw) + + expect(raw).not.toContain('frontendSecret') + expect(raw).not.toContain('backendSecret') + expect(item).toMatchObject({ + type: 'app', + path: 'f/apps/local', + summary: 'local app', + isDraft: true, + value: { + frontend: [{ path: '/src/App.tsx', size: 'const frontendSecret = "do-not-dump"'.length }], + backend: [ + expect.objectContaining({ + key: 'main', + name: 'main', + type: 'inline', + language: 'bun', + contentSize: 'const backendSecret = "do-not-dump"'.length + }) + ], + data: { tables: ['orders'] } + } + }) + expect(item.value.backend[0]).not.toHaveProperty('content') + }) + + it('summarizes backend raw app drafts from the same source as file reads', async () => { + const appWithDraft = { + path: 'f/apps/report', + summary: 'deployed app', + versions: [5], + value: { + files: { '/src/App.tsx': 'deployed content' }, + runnables: {}, + data: { tables: ['deployed'] } + }, + draft: { + summary: 'saved app draft', + value: { + files: { + '/src/App.tsx': 'draft content', + '/src/DraftOnly.tsx': 'draft-only content' + }, + runnables: { + main: { + type: 'inline', + inlineScript: { + language: 'bun', + content: 'export async function main() { return "draft" }' + } + } + }, + data: { tables: ['draft'] } + } + } + } + vi.mocked(AppService.getAppByPathWithDraft) + .mockResolvedValueOnce(appWithDraft as any) + .mockResolvedValueOnce(appWithDraft as any) + + const raw = await callGlobalTool('read_workspace_item', { + type: 'app', + path: 'f/apps/report' + }) + const item = JSON.parse(raw) + + expect(raw).not.toContain('draft-only content') + expect(item).toMatchObject({ + type: 'app', + path: 'f/apps/report', + summary: 'saved app draft', + value: { + frontend: [ + { path: '/src/App.tsx', size: 'draft content'.length }, + { path: '/src/DraftOnly.tsx', size: 'draft-only content'.length } + ], + backend: [ + expect.objectContaining({ + key: 'main', + name: 'main', + type: 'inline', + language: 'bun', + contentSize: 'export async function main() { return "draft" }'.length + }) + ], + data: { tables: ['draft'] } + }, + isDraft: false + }) + + await expect( + callGlobalTool('read_app_file', { + path: 'f/apps/report', + file_path: '/src/DraftOnly.tsx' + }) + ).resolves.toBe('draft-only content') + expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + }) + + it('reads raw app files without creating a local draft', async () => { + vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ + path: 'f/apps/report', + summary: 'deployed app', + versions: [5], + value: { + files: { '/src/App.tsx': 'deployed content' }, + runnables: {}, + data: { tables: [] } + }, + draft: { + summary: 'saved app draft', + value: { + files: { '/src/App.tsx': 'draft content' }, + runnables: {}, + data: { tables: [] } + } + } + } as any) + + await expect( + callGlobalTool('read_app_file', { + path: 'f/apps/report', + file_path: '/src/App.tsx' + }) + ).resolves.toBe('draft content') + expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + }) + + it('does not persist a raw app draft when patch_app_file validation fails', async () => { + vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ + path: 'f/apps/report', + summary: 'deployed app', + versions: [5], + value: { + files: { '/src/App.tsx': 'deployed content' }, + runnables: {}, + data: { tables: [] } + } + } as any) + + await expect( + callGlobalTool('patch_app_file', { + path: 'f/apps/report', + file_path: '/src/App.tsx', + old_string: 'missing content', + new_string: 'replacement', + replace_all: false + }) + ).rejects.toThrow() + expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + }) + + it('does not persist a raw app draft when delete_app_file validation fails', async () => { + vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ + path: 'f/apps/report', + summary: 'deployed app', + versions: [5], + value: { + files: { '/src/App.tsx': 'deployed content' }, + runnables: {}, + data: { tables: [] } + } + } as any) + + await expect( + callGlobalTool('delete_app_file', { + path: 'f/apps/report', + file_path: '/src/Missing.tsx' + }) + ).rejects.toThrow('Frontend file "/src/Missing.tsx" not found in app "f/apps/report".') + expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + }) + + it('does not persist a raw app draft when delete_app_runnable validation fails', async () => { + vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ + path: 'f/apps/report', + summary: 'deployed app', + versions: [5], + value: { + files: { '/src/App.tsx': 'deployed content' }, + runnables: { + main: { + type: 'inline', + inlineScript: { language: 'bun', content: 'export async function main() {}' } + } + }, + data: { tables: [] } + } + } as any) + + await expect( + callGlobalTool('delete_app_runnable', { + path: 'f/apps/report', + key: 'missing' + }) + ).rejects.toThrow('Backend runnable "missing" not found in app "f/apps/report".') + expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + }) + it('fills an empty rawscript module through set_flow_module_code', async () => { await callGlobalTool('write_flow', { path: 'f/flows/empty-module', @@ -138,7 +1039,7 @@ describe('global AI tools', () => { module_id: 'empty_step', code }) - ).resolves.toContain('Updated AI draft flow') + ).resolves.toContain('Updated local draft flow') await expect( callGlobalTool('read_flow_module_code', { @@ -203,7 +1104,7 @@ describe('global AI tools', () => { expect(item.value.value).toBeUndefined() }) - it('asks the user a multiple-choice question and returns the selected answer', async () => { + it('asks the user a question and returns the selected answer', async () => { const callbacks: ToolCallbacks = { setToolStatus: vi.fn(), removeToolStatus: vi.fn(), @@ -237,6 +1138,109 @@ describe('global AI tools', () => { }) ) }) + + it('allows up to ten proposed answers', async () => { + const choices = Array.from({ length: 10 }, (_, index) => `choice-${index + 1}`) + const callbacks: ToolCallbacks = { + setToolStatus: vi.fn(), + removeToolStatus: vi.fn(), + requestUserQuestion: vi.fn(async (_toolId, question) => question.choices[9]) + } + + const raw = await callGlobalTool( + 'askUserQuestion', + { + question: 'Which option should be used?', + choices + }, + callbacks + ) + + expect(raw).toBe('choice-10') + expect(callbacks.requestUserQuestion).toHaveBeenCalledWith( + 'test-askUserQuestion', + expect.objectContaining({ + choices + }) + ) + }) + + it('rejects more than ten proposed answers', async () => { + const callbacks: ToolCallbacks = { + setToolStatus: vi.fn(), + removeToolStatus: vi.fn(), + requestUserQuestion: vi.fn() + } + + await expect( + callGlobalTool( + 'askUserQuestion', + { + question: 'Which option should be used?', + choices: Array.from({ length: 11 }, (_, index) => `choice-${index + 1}`) + }, + callbacks + ) + ).rejects.toThrow() + expect(callbacks.requestUserQuestion).not.toHaveBeenCalled() + }) + + it('returns a custom answer that is not one of the proposed answers', async () => { + const callbacks: ToolCallbacks = { + setToolStatus: vi.fn(), + removeToolStatus: vi.fn(), + requestUserQuestion: vi.fn(async () => 'use deno instead') + } + + const raw = await callGlobalTool( + 'askUserQuestion', + { + question: 'Which script language should be used?', + choices: ['bun', 'python3'] + }, + callbacks + ) + + expect(raw).toBe('use deno instead') + expect(callbacks.setToolStatus).toHaveBeenLastCalledWith( + 'test-askUserQuestion', + expect.objectContaining({ + content: 'User answered question: use deno instead', + result: 'use deno instead', + userQuestion: expect.objectContaining({ selectedChoice: 'use deno instead' }) + }) + ) + }) +}) + +describe('prepareGlobalSystemMessage', () => { + it('keeps global chat draft instructions concise and user-facing', () => { + const message = prepareGlobalSystemMessage() + const content = message.content + + expect(content).toContain('Draft tools create or update local drafts only') + expect(content).toContain( + 'Use discard_local_draft to remove an unsaved local draft, including the matching open editor draft' + ) + expect(content).not.toContain('AI draft') + expect(content).not.toContain('UserDraft') + expect(content).not.toContain('localStorage') + expect(content).not.toContain('frontend AI draft store') + }) + + it('exposes separate tools for discarding drafts and deleting workspace items', () => { + const discard = getGlobalTool('discard_local_draft') + const deleteItem = getGlobalTool('delete_workspace_item') + + expect(discard.def.function.description).toBe( + 'Discard a local draft only. Does not mutate deployed workspace items, but clears the matching open editor draft if one is mounted.' + ) + expect(deleteItem.def.function.description).toBe( + 'Delete a deployed workspace item. Mutates the workspace.' + ) + expect(discard.requiresConfirmation).toBe(true) + expect(deleteItem.requiresConfirmation).toBe(true) + }) }) describe('prepareGlobalUserMessage', () => { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 6beabd21d6..10d055938c 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -18,11 +18,16 @@ import { import { $ScriptLang } from '$lib/gen/schemas.gen' import type { AppWithLastVersion, + CreateResource, + CreateVariable, Flow, FlowValue, ListableApp, ListableResource, ListableVariable, + NewSchedule, + NewScript, + Resource, Schedule, Script, ScriptLang @@ -34,6 +39,7 @@ import { STARTER_RUNNABLE_KEY, type FrameworkKey } from '$lib/components/raw_apps/templates' +import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils' import { applyEditableFlowJsonToFlow, buildEditableFlowJson, @@ -41,12 +47,7 @@ import { validateEditableFlowJson } from '../flow/editableFlowJson' import { createInlineScriptSession } from '../flow/inlineScriptsUtils' -import { - getFlowPrompt, - getRawAppPrompt, - getResourcePrompt, - getScriptPrompt -} from '$system_prompts' +import { getFlowPrompt, getRawAppPrompt, getResourcePrompt, getScriptPrompt } from '$system_prompts' import type { ChatCompletionSystemMessageParam, ChatCompletionUserMessageParam @@ -61,6 +62,8 @@ import { type ToolDisplayAction } from '../shared' import type { ContextElement } from '../context' +import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte' +import { emptySchema } from '$lib/utils' import { resourceRequestSchema, scheduleRequestSchema, @@ -69,15 +72,28 @@ import { } from '../workspaceToolsZod.gen' import { getWorkspaceItemKey, - globalDraftStore, TRIGGER_KINDS, type AppDraftValue, type FlowDraftValue, + type ResourceDraftState, type TriggerKind, + type TriggerRequestBody, + type VariableDraftState, type WorkspaceItem, type WorkspaceItemType -} from './draftStore.svelte' +} from './workspaceItems' import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests' +import { + clearEphemeralSecretVariableDraftValue, + deleteGlobalDraft, + getEphemeralSecretVariableDraftValue, + getGlobalDraft, + getGlobalDraftStoragePath, + listGlobalDrafts, + saveGlobalAppDraft, + setEphemeralSecretVariableDraftValue, + triggerKindToUserDraftKind +} from './userDraftAdapter' const ITEM_TYPES = [ 'script', @@ -118,10 +134,10 @@ const askUserQuestionSchema = z.object({ .min(1) .describe('The concise question to show to the user before continuing.'), choices: z - .array(z.string().min(1).describe('Short answer text shown to the user and returned as-is.')) + .array(z.string().min(1).describe('Proposed answer text shown to the user and returned as-is.')) .min(2) - .max(6) - .describe('Two to six mutually exclusive answer strings.') + .max(10) + .describe('Two to ten mutually exclusive proposed answer strings.') }) const listWorkspaceItemsSchema = z.object({ @@ -154,9 +170,7 @@ const readWorkspaceItemSchema = z.object({ }) const writeScriptSchema = z.object({ - path: z - .string() - .describe('Workspace path of the script, e.g. f/folder/name or u/user/name.'), + path: z.string().describe('Workspace path of the script, e.g. f/folder/name or u/user/name.'), summary: z.string().optional().describe('Short human-readable summary.'), language: scriptLangSchema.describe('Script language.'), content: z.string().describe('Full script source code.') @@ -178,7 +192,7 @@ const setFlowModuleCodeSchema = z.object({ .describe( 'Module id whose inline rawscript content to overwrite. Must reference a module whose value.type is "rawscript". Use patch_flow_json for structural changes.' ), - code: z.string().describe('New script source. Replaces the module\'s value.content entirely.') + code: z.string().describe("New script source. Replaces the module's value.content entirely.") }) // Flow structure fields are taken as JSON strings rather than typed objects @@ -187,9 +201,7 @@ const setFlowModuleCodeSchema = z.object({ // rejects those keywords ("Unknown name $ref/$defs"). Same trick as // set_flow_json in chat/flow/core.ts. const writeFlowSchema = z.object({ - path: z - .string() - .describe('Workspace path of the flow, e.g. f/folder/name or u/user/name.'), + path: z.string().describe('Workspace path of the flow, e.g. f/folder/name or u/user/name.'), summary: z.string().optional().describe('Short human-readable summary.'), modules: z.string().describe('JSON string containing the complete flow modules array.'), schema: z @@ -300,6 +312,14 @@ const deleteWorkspaceItemSchema = z.object({ .describe('Required when type is trigger. Identifies which trigger service to call.') }) +const discardLocalDraftSchema = z.object({ + type: itemTypeSchema, + path: z.string().describe('Workspace path of the local draft to discard.'), + trigger_kind: triggerKindSchema + .optional() + .describe('Required when type is trigger. Must match the draft trigger kind.') +}) + const deployWorkspaceItemSchema = z.object({ type: itemTypeSchema, path: z.string().describe('Workspace path of the draft to deploy.'), @@ -436,7 +456,12 @@ const deleteAppRunnableSchema = z.object({ key: z.string().describe('Key of the backend runnable to remove.') }) -const FRAMEWORK_KEYS = ['react19', 'react18', 'svelte5', 'vue'] as const satisfies readonly FrameworkKey[] +const FRAMEWORK_KEYS = [ + 'react19', + 'react18', + 'svelte5', + 'vue' +] as const satisfies readonly FrameworkKey[] const initAppSchema = z.object({ path: z @@ -467,28 +492,31 @@ const initAppSchema = z.object({ const GLOBAL_SYSTEM_PROMPT = `You are Windmill's global workspace assistant. -You can inspect workspace scripts, flows, schedules, triggers, resources, variables, and apps, then create draft changes in the frontend AI draft store. +Use tools to inspect workspace items and create local drafts for scripts, flows, schedules, triggers, resources, variables, and raw apps. -Important rules: -- write_{script,flow,schedule,trigger,resource,variable} create or overwrite drafts. They do not save, deploy, or mutate workspace items. -- edit_script and patch_flow_json apply small exact-text edits and save the result as a draft. Prefer them for localized changes; use write_* for large rewrites. -- For flows specifically: read_workspace_item and patch_flow_json work on a COMPACT view where rawscript module bodies are replaced with the placeholder "inline_script.". Use read_flow_module_code / set_flow_module_code to inspect or overwrite an inline script body; use patch_flow_json for structural edits. -- deploy_workspace_item persists a draft to the workspace via the real backend create/update API and removes the draft. Requires user confirmation. Only call after the user has reviewed the draft and explicitly asked to deploy. -- delete_workspace_item permanently removes a workspace item (and any matching draft). Irreversible. Requires user confirmation. Only call when the user has explicitly asked to delete. -- Use list_workspace_items before broad reads. -- Use read_workspace_item before overwriting an existing item, unless the user already provided the complete current item. For triggers, pass trigger_kind. -- Variable values are NEVER returned by read_workspace_item or list_workspace_items — only metadata (path, description, is_secret). The model cannot read secret values, by design. -- For resources that need secrets, write a Variable first (with is_secret: true), then in the resource value reference it as "$var:path/to/variable". When deploying both, deploy the variable before the resource. -- Use search_resource_types before write_resource to discover the resource_type name and the JSON Schema its value must match. -- Use get_instructions before writing a script, flow, resource, or app. For scripts, pass the target language; when modifying, use the language from the item you read. -- Schedules, triggers, and variables do not need get_instructions — their tool schemas describe every field. -- When a required decision is ambiguous, use askUserQuestion with two to six clear answer strings instead of guessing. -- A workspace item is { type, path, summary?, language?, triggerKind?, value, isDraft }. For scripts, value is the source code string. For flows, read_workspace_item returns value as the compact flow object { modules, schema, preprocessor_module, failure_module, groups }; write_flow takes the same flow fields as top-level tool arguments plus path/summary. For schedules/triggers/resources/variables, value is the full request body for that type. For apps, value is { files, runnables, data?, policy?, custom_path? } with frontend file contents and backend runnable definitions. -- Apps (raw apps): use list_workspace_items with types: ['app'] to find them, read_workspace_item with type 'app' for a metadata summary (file paths + runnable list, no contents), then read_app_file to read individual files. Edit with write_app_file / patch_app_file / delete_app_file for frontend files and write_app_runnable / delete_app_runnable for backend runnables. Frontend file paths start with "/" (e.g. /index.tsx). Backend inline runnables are addressed as "backend//main.{ts|py}". /wmill.d.ts is generated and cannot be written. -- To create a new raw app, use init_app. Before calling it, confirm framework (react19 / react18 / svelte5 / vue), path, and summary with the user — do not silently default to react19, even though it is the recommended choice. -- Apps cannot be deployed from chat. The app editor bundles JS/CSS before save; tell the user to open the app editor to deploy app drafts. -- Keep context targeted. Do not read unrelated items. -- Be explicit with the user when you create or update a draft.` +Rules: +- Draft tools create or update local drafts only; they do not deploy or mutate deployed workspace items. +- Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind. +- If the user refers to the open editor, use the item marked isLiveDraft=true. +- Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a local draft to the workspace. +- Use discard_local_draft to remove an unsaved local draft, including the matching open editor draft. Use delete_workspace_item only to delete a deployed workspace item. +- Variable values are never readable. For secrets, create a secret variable and reference it from resources as "$var:path/to/variable". +- Use search_resource_types before write_resource. +- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language. +- When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. +- Keep context targeted. + +Flows: +- read_workspace_item returns compact flow JSON. Inline script bodies appear as "inline_script.". +- Use read_flow_module_code and set_flow_module_code for inline script bodies. +- Use patch_flow_json for structural flow edits and write_flow for full flow rewrites. + +Raw apps: +- read_workspace_item returns app metadata only. Use read_app_file for file and inline runnable contents. +- Use write_app_file, patch_app_file, and delete_app_file for frontend files. +- Use write_app_runnable and delete_app_runnable for backend runnables. +- Use init_app only after confirming framework, path, and summary with the user. +- Apps cannot be deployed from chat; tell the user to open the app editor.` const DEFAULT_LIST_TYPES = ['script', 'flow'] as const satisfies readonly WorkspaceItemType[] @@ -548,6 +576,16 @@ function serializeWorkspaceItemForRead(item: WorkspaceItem): unknown { } } + if (item.type === 'app' && item.value && typeof item.value === 'object' && 'files' in item.value) { + return { + type: 'app', + path: item.path, + summary: item.summary, + value: summarizeAppValue(item.value as AppDraftValue), + isDraft: item.isDraft + } + } + if (item.type !== 'flow' || !item.value) return item const flowDraft = item.value as FlowDraftValue const session = createInlineScriptSession() @@ -658,7 +696,7 @@ function buildPersistedRunnable( { type: 'static', value: v, fieldType: 'object' } ]) ) - : existing?.fields ?? {} + : (existing?.fields ?? {}) if (input.type === 'inline') { if (!input.inlineScript) { @@ -711,11 +749,18 @@ type AppMetadata = { data?: any } +type LoadedAppDraftValue = { + value: AppDraftValue + meta?: UserDraftMeta +} + function summarizeAppValue(value: AppDraftValue): AppMetadata { - const frontend: AppFrontendFileMetadata[] = Object.entries(value.files).map(([path, content]) => ({ - path, - size: typeof content === 'string' ? content.length : 0 - })) + const frontend: AppFrontendFileMetadata[] = Object.entries(value.files).map( + ([path, content]) => ({ + path, + size: typeof content === 'string' ? content.length : 0 + }) + ) const backend: AppBackendRunnableMetadata[] = Object.entries(value.runnables).map( ([key, runnable]) => { const converted = convertPersistedToBackendRunnable(runnable as PersistedRunnable, key) @@ -815,32 +860,73 @@ function getInlineRunnableContent( return { content: runnable.inlineScript?.content ?? '', runnable } } -async function loadAppDraftValue(path: string, workspace: string): Promise { - const draft = globalDraftStore.getDraft(workspace, 'app', path) +function normalizeRawAppData(value: Record): AppDraftValue['data'] { + if (value.data?.creation) { + return { + tables: value.data.tables ?? [], + datatable: value.data.creation.datatable, + schema: value.data.creation.schema + } + } + if (value.data) { + return value.data + } + if (value.datatables) { + return { ...DEFAULT_RAW_APP_DATA, tables: value.datatables } + } + if (value.dataTableRefs) { + return { ...DEFAULT_RAW_APP_DATA, tables: value.dataTableRefs } + } + return { ...DEFAULT_RAW_APP_DATA } +} + +function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue { + const value = (app.value ?? {}) as Record + return { + summary: app.summary ?? '', + files: { ...(value.files ?? {}) }, + runnables: { ...(value.runnables ?? {}) }, + data: normalizeRawAppData(value), + policy: app.policy ?? fallback?.policy, + custom_path: app.custom_path ?? fallback?.custom_path + } +} + +function appDraftMeta(app: { versions?: number[]; draft_created_at?: string }): UserDraftMeta { + return { + remoteRev: app.versions ? app.versions[app.versions.length - 1] : undefined, + remoteDraftRev: app.draft_created_at + } +} + +async function loadAppValueForRead(path: string, workspace: string): Promise { + const draft = getGlobalDraft(workspace, 'app', path) if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) { return draft.value as AppDraftValue } - const app = await AppService.getAppByPath({ workspace, path }) - const value = (app.value ?? {}) as Partial - return { - summary: app.summary, - files: { ...(value.files ?? {}) }, - runnables: { ...(value.runnables ?? {}) }, - data: value.data, - policy: app.policy as any, - custom_path: app.custom_path - } + const app = await AppService.getAppByPathWithDraft({ workspace, path }) + return appSourceToDraftValue(app.draft ?? app, app) } -function saveAppDraft(workspace: string, path: string, value: AppDraftValue): WorkspaceItem { - return globalDraftStore.setDraft(workspace, { - type: 'app', - path, - summary: value.summary, - value, - isDraft: true - }) +async function loadAppDraftValue(path: string, workspace: string): Promise { + const draft = getGlobalDraft(workspace, 'app', path) + if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) { + return { value: draft.value as AppDraftValue } + } + + const app = await AppService.getAppByPathWithDraft({ workspace, path }) + const value = appSourceToDraftValue(app.draft ?? app, app) + return { value, meta: appDraftMeta(app) } +} + +function saveAppDraft( + workspace: string, + path: string, + value: AppDraftValue, + meta?: UserDraftMeta +): WorkspaceItem { + return saveGlobalAppDraft(workspace, path, value, meta) } type TriggerLike = { path: string; summary?: string | null } @@ -863,11 +949,7 @@ function triggerToItem( type TriggerService = { exists(args: { workspace: string; path: string }): Promise get(args: { workspace: string; path: string }): Promise - list(args: { - workspace: string - pathStart?: string - perPage?: number - }): Promise + list(args: { workspace: string; pathStart?: string; perPage?: number }): Promise create(args: { workspace: string; requestBody: any }): Promise update(args: { workspace: string; path: string; requestBody: any }): Promise delete(args: { workspace: string; path: string }): Promise @@ -948,31 +1030,6 @@ const triggerServices: Record = { } } -async function workspaceItemExists( - type: WorkspaceItemType, - path: string, - workspace: string, - triggerKind?: TriggerKind -): Promise { - switch (type) { - case 'script': - return ScriptService.existsScriptByPath({ workspace, path }) - case 'flow': - return FlowService.existsFlowByPath({ workspace, path }) - case 'schedule': - return ScheduleService.existsSchedule({ workspace, path }) - case 'trigger': - if (!triggerKind) return false - return triggerServices[triggerKind].exists({ workspace, path }) - case 'resource': - return ResourceService.existsResource({ workspace, path }) - case 'variable': - return VariableService.existsVariable({ workspace, path }) - case 'app': - return AppService.existsApp({ workspace, path }) - } -} - async function readWorkspaceItem( type: WorkspaceItemType, path: string, @@ -997,7 +1054,7 @@ async function readWorkspaceItem( ) case 'resource': return resourceToItem( - await ResourceService.getResource({ workspace, path }) as ListableResource, + (await ResourceService.getResource({ workspace, path })) as ListableResource, true ) case 'variable': @@ -1008,18 +1065,13 @@ async function readWorkspaceItem( ) case 'app': { // Returns lightweight metadata only — file/runnable contents come via read_app_file. - const app = await AppService.getAppByPath({ workspace, path }) - const value = (app.value ?? {}) as Partial - const metadata = summarizeAppValue({ - summary: app.summary, - files: value.files ?? {}, - runnables: value.runnables ?? {}, - data: value.data - }) + const app = await AppService.getAppByPathWithDraft({ workspace, path }) + const value = appSourceToDraftValue(app.draft ?? app) + const metadata = summarizeAppValue(value) return { type: 'app', path: app.path, - summary: app.summary, + summary: value.summary, value: metadata as unknown as AppDraftValue, isDraft: false } @@ -1141,7 +1193,7 @@ function getFlowInstructions(): string { - \`read_workspace_item\` and \`patch_flow_json\` operate on a **compact view** of the flow: every rawscript module's \`value.content\` is replaced with the placeholder \`"inline_script."\` so inline script bodies don't bloat tool I/O. Schema, groups, preprocessor_module and failure_module are all shown in this view. - Inline rawscript content is **not** part of the JSON \`patch_flow_json\` sees. Edits to inline bodies happen via dedicated tools: - \`read_flow_module_code(path, module_id)\` — returns the raw inline script content for one module. - - \`set_flow_module_code(path, module_id, code)\` — overwrites that module's inline script content; saves to the AI draft. + - \`set_flow_module_code(path, module_id, code)\` — overwrites that module's inline script content; saves to the local draft. - Use \`patch_flow_json\` for *structural* edits: module ids, paths, input_transforms, branch arrangement, summaries, preprocessor/failure swaps, schema/groups. Use \`set_flow_module_code\` for changes inside a specific rawscript body. - \`write_flow\` is for full overwrites / create-from-scratch. Its \`modules\`, \`preprocessor_module\`, and \`failure_module\` arguments use **non-compact** flow modules (rawscript content is the actual code, not a placeholder). @@ -1207,7 +1259,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( getInstructionsSchema, 'get_instructions', - 'Get Windmill authoring instructions for scripts, flows, resources, or apps. For scripts, pass the target language.' + 'Get authoring guidance for scripts, flows, resources, or apps.' ), fn: async ({ args, toolId, toolCallbacks }) => { const parsed = getInstructionsSchema.parse(args) @@ -1223,7 +1275,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( askUserQuestionSchema, 'askUserQuestion', - 'Ask the user a multiple-choice question and wait for their selection before continuing.' + 'Ask the user a question with proposed answers and wait for their selected or custom answer before continuing.' ), fn: async ({ args, toolId, toolCallbacks }) => { const parsed = askUserQuestionSchema.parse(args) @@ -1277,7 +1329,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( listWorkspaceItemsSchema, 'list_workspace_items', - 'List workspace items (scripts, flows, schedules, triggers, resources, variables, apps) and AI drafts. Returns metadata only (no value). Defaults to scripts and flows.' + 'List workspace items and local drafts. Returns metadata only.' ), fn: async ({ args, workspace, toolId, toolCallbacks }) => { const parsed = listWorkspaceItemsSchema.parse(args) @@ -1296,8 +1348,9 @@ export const globalTools: Tool<{}>[] = [ byKey.set(getWorkspaceItemKey(item.type, item.path, item.triggerKind), item) } - for (const draft of globalDraftStore.listDrafts(workspace)) { + for (const draft of listGlobalDrafts(workspace)) { if (!types.includes(draft.type)) continue + if (parsed.path_prefix && !draft.path.startsWith(parsed.path_prefix)) continue byKey.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), { ...draft, value: undefined @@ -1318,7 +1371,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( readWorkspaceItemSchema, 'read_workspace_item', - 'Read one workspace item or AI draft by type and path. Returns the full workspace item including value.' + 'Read one workspace item or local draft.' ), fn: async ({ args, workspace, toolId, toolCallbacks }) => { const parsed = readWorkspaceItemSchema.parse(args) @@ -1327,15 +1380,10 @@ export const globalTools: Tool<{}>[] = [ toolCallbacks.setToolStatus(toolId, { content: message, error: message }) return JSON.stringify({ success: false, error: message }) } - const draft = globalDraftStore.getDraft( - workspace, - parsed.type, - parsed.path, - parsed.trigger_kind - ) + const draft = getGlobalDraft(workspace, parsed.type, parsed.path, parsed.trigger_kind) if (draft) { toolCallbacks.setToolStatus(toolId, { - content: `Read AI draft ${parsed.type} "${parsed.path}"` + content: `Read local draft ${parsed.type} "${parsed.path}"` }) return JSON.stringify(serializeWorkspaceItemForRead(draft), null, 2) } @@ -1343,12 +1391,7 @@ export const globalTools: Tool<{}>[] = [ toolCallbacks.setToolStatus(toolId, { content: `Reading ${parsed.type} "${parsed.path}"...` }) - const item = await readWorkspaceItem( - parsed.type, - parsed.path, - workspace, - parsed.trigger_kind - ) + const item = await readWorkspaceItem(parsed.type, parsed.path, workspace, parsed.trigger_kind) toolCallbacks.setToolStatus(toolId, { content: `Read ${parsed.type} "${parsed.path}"` }) return JSON.stringify(serializeWorkspaceItemForRead(item), null, 2) } @@ -1357,32 +1400,18 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( writeScriptSchema, 'write_script', - 'Create or overwrite an AI draft script. Does not save or deploy. Read the existing script first when overwriting.' + 'Create or overwrite a local draft script.' ), showDetails: true, streamArguments: true, showFade: true, fn: async (ctx) => { const parsed = writeScriptSchema.parse(ctx.args) - return writeDraft( - { - type: 'script', - path: parsed.path, - summary: parsed.summary, - language: parsed.language, - value: parsed.content, - isDraft: true - }, - ctx - ) + return writeScriptDraft(parsed, ctx) } }, { - def: createToolDef( - writeFlowSchema, - 'write_flow', - 'Create or overwrite an AI draft flow. Does not save or deploy. Read the existing flow first when overwriting. Uses the same flow-structure arguments as set_flow_json plus path and summary.' - ), + def: createToolDef(writeFlowSchema, 'write_flow', 'Create or overwrite a local draft flow.'), showDetails: true, streamArguments: true, showFade: true, @@ -1398,13 +1427,11 @@ export const globalTools: Tool<{}>[] = [ failure_module: parseOptionalJsonArg(parsed.failure_module, 'failure_module'), groups: parseOptionalJsonArg(parsed.groups, 'groups') }) - return writeDraft( + return writeFlowDraft( { - type: 'flow', path: parsed.path, summary: parsed.summary, - value: editableFlowToDraftValue(editable), - isDraft: true + flow: editableFlowToDraftValue(editable) }, ctx ) @@ -1414,7 +1441,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( writeScheduleSchema, 'write_schedule', - 'Create or overwrite an AI draft schedule. Does not save or deploy. Provide script_path and is_flow to point to the runnable.', + 'Create or overwrite a local draft schedule.', { strict: false } ), showDetails: true, @@ -1422,23 +1449,14 @@ export const globalTools: Tool<{}>[] = [ showFade: true, fn: async (ctx) => { const parsed = writeScheduleSchema.parse(ctx.args) - return writeDraft( - { - type: 'schedule', - path: parsed.path, - summary: parsed.summary ?? undefined, - value: parsed, - isDraft: true - }, - ctx - ) + return writeScheduleDraft(parsed, ctx) } }, { def: createToolDef( writeTriggerSchema, 'write_trigger', - 'Create or overwrite an AI draft trigger. Does not save or deploy. Provide kind plus the kind-specific config (including path, script_path, is_flow).', + 'Create or overwrite a local draft trigger.', { strict: false } ), showDetails: true, @@ -1446,25 +1464,14 @@ export const globalTools: Tool<{}>[] = [ showFade: true, fn: async (ctx) => { const parsed = writeTriggerSchema.parse(ctx.args) - const config = parsed.config as { path: string; summary?: string | null } - return writeDraft( - { - type: 'trigger', - triggerKind: parsed.kind, - path: config.path, - summary: config.summary ?? undefined, - value: parsed.config, - isDraft: true - }, - ctx - ) + return writeTriggerDraft(parsed, ctx) } }, { def: createToolDef( editScriptSchema, 'edit_script', - 'Find/replace exact text in a script. Edits the existing draft if one exists, otherwise reads the workspace script and saves the result as a new draft.' + 'Find/replace exact text in a script and save a local draft.' ), showDetails: true, streamArguments: true, @@ -1478,7 +1485,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( patchFlowJsonSchema, 'patch_flow_json', - 'Find/replace exact text in a flow value (compact JSON). Edits the existing draft if one exists, otherwise reads the workspace flow and saves the result as a new draft. Use write_flow for larger structural rewrites.' + 'Find/replace exact text in compact flow JSON and save a local draft.' ), showDetails: true, streamArguments: true, @@ -1492,13 +1499,13 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( deployWorkspaceItemSchema, 'deploy_workspace_item', - 'Persist an AI draft to the workspace by calling the real backend create/update API. This MUTATES the workspace. Requires user confirmation.', + 'Deploy a local draft to the workspace. Mutates the workspace.', { strict: false } ), showDetails: true, showFade: true, requiresConfirmation: true, - confirmationMessage: 'Deploy AI draft to workspace', + confirmationMessage: 'Deploy local draft to workspace', fn: async (ctx) => { const parsed = deployWorkspaceItemSchema.parse(ctx.args) return deployDraft(parsed, ctx) @@ -1508,7 +1515,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( deleteWorkspaceItemSchema, 'delete_workspace_item', - 'Permanently delete a workspace item by path. This MUTATES the workspace and is irreversible. Also clears any matching AI draft. Requires user confirmation.' + 'Delete a deployed workspace item. Mutates the workspace.' ), showDetails: true, showFade: true, @@ -1519,11 +1526,26 @@ export const globalTools: Tool<{}>[] = [ return deleteWorkspaceItem(parsed, ctx) } }, + { + def: createToolDef( + discardLocalDraftSchema, + 'discard_local_draft', + 'Discard a local draft only. Does not mutate deployed workspace items, but clears the matching open editor draft if one is mounted.' + ), + showDetails: true, + showFade: true, + requiresConfirmation: true, + confirmationMessage: 'Discard local draft', + fn: async (ctx) => { + const parsed = discardLocalDraftSchema.parse(ctx.args) + return discardLocalDraft(parsed, ctx) + } + }, { def: createToolDef( writeResourceSchema, 'write_resource', - 'Create or overwrite an AI draft resource. Does not save or deploy. Reference secret values via $var:path/to/variable; create the variable separately with write_variable.', + 'Create or overwrite a local draft resource.', { strict: false } ), showDetails: true, @@ -1531,23 +1553,14 @@ export const globalTools: Tool<{}>[] = [ showFade: true, fn: async (ctx) => { const parsed = writeResourceSchema.parse(ctx.args) - return writeDraft( - { - type: 'resource', - path: parsed.path, - summary: parsed.description, - value: parsed, - isDraft: true - }, - ctx - ) + return writeResourceDraft(parsed, ctx) } }, { def: createToolDef( writeVariableSchema, 'write_variable', - 'Create or overwrite an AI draft variable. Does not save or deploy. Use is_secret: true for secret values. After deploy, reference from a resource as $var:path/to/variable.', + 'Create or overwrite a local draft variable.', { strict: false } ), showDetails: true, @@ -1555,23 +1568,14 @@ export const globalTools: Tool<{}>[] = [ showFade: true, fn: async (ctx) => { const parsed = writeVariableSchema.parse(ctx.args) - return writeDraft( - { - type: 'variable', - path: parsed.path, - summary: parsed.description, - value: parsed, - isDraft: true - }, - ctx - ) + return writeVariableDraft(parsed, ctx) } }, { def: createToolDef( searchResourceTypesSchema, 'search_resource_types', - 'Search for resource types in the workspace by substring. Returns names, descriptions, and JSON Schemas — use this before write_resource to know what shape value should have.' + 'Search workspace resource types and schemas.' ), fn: async ({ args, workspace, toolId, toolCallbacks }) => { const parsed = searchResourceTypesSchema.parse(args) @@ -1600,7 +1604,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( readFlowModuleCodeSchema, 'read_flow_module_code', - 'Read the inline rawscript content of one flow module by id. Reads from the AI draft when one exists, otherwise from the workspace flow. Use this instead of patch_flow_json when you only need to inspect an inline script body.' + 'Read inline script code from one flow module.' ), fn: async (ctx) => { const parsed = readFlowModuleCodeSchema.parse(ctx.args) @@ -1611,7 +1615,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( setFlowModuleCodeSchema, 'set_flow_module_code', - 'Overwrite the inline rawscript content of one flow module by id. Saves to the AI draft only — does not deploy. Use this for inline script body changes; structural changes (module ids, paths, input_transforms, branches) go through patch_flow_json.' + 'Overwrite inline script code in one flow module and save a local draft.' ), showDetails: true, streamArguments: true, @@ -1625,7 +1629,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( initAppSchema, 'init_app', - 'Initialize a new raw app draft from a framework template. Errors if an app already exists at the path or a draft is already in flight. Confirm framework, path, and summary with the user before calling — do not silently default to react19.', + 'Initialize a local draft raw app from a framework template.', { strict: false } ), showDetails: true, @@ -1639,7 +1643,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( readAppFileSchema, 'read_app_file', - 'Read one frontend file or inline backend runnable script from a raw app. Use file_path "/foo.tsx" for frontend files and "backend//main.{ts|py}" for inline runnables. Prefers the AI draft when one exists.' + 'Read one raw app frontend file or inline backend runnable.' ), fn: async (ctx) => { const parsed = readAppFileSchema.parse(ctx.args) @@ -1650,7 +1654,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( writeAppFileSchema, 'write_app_file', - 'Create or overwrite a frontend file in an app draft. Saves to the AI draft only — does not deploy. First write snapshots the workspace app onto the draft.' + 'Create or overwrite a frontend file in a local app draft.' ), showDetails: true, streamArguments: true, @@ -1664,7 +1668,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( deleteAppFileSchema, 'delete_app_file', - 'Remove a frontend file from an app draft. Saves to the AI draft only — does not deploy.' + 'Remove a frontend file from a local app draft.' ), fn: async (ctx) => { const parsed = deleteAppFileSchema.parse(ctx.args) @@ -1675,7 +1679,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( patchAppFileSchema, 'patch_app_file', - 'Find/replace exact text in a frontend file or inline backend runnable script. Saves the result to the AI draft.' + 'Find/replace exact text in a raw app file and save a local draft.' ), showDetails: true, streamArguments: true, @@ -1689,7 +1693,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( writeAppRunnableSchema, 'write_app_runnable', - 'Create or overwrite a backend runnable in an app draft. Saves to the AI draft only — does not deploy. Re-derives the app policy after the change.', + 'Create or overwrite a backend runnable in a local app draft.', { strict: false } ), showDetails: true, @@ -1704,7 +1708,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( deleteAppRunnableSchema, 'delete_app_runnable', - 'Remove a backend runnable from an app draft. Saves to the AI draft only — does not deploy. Re-derives the app policy after the change.' + 'Remove a backend runnable from a local app draft.' ), fn: async (ctx) => { const parsed = deleteAppRunnableSchema.parse(ctx.args) @@ -1719,11 +1723,433 @@ type WriteDraftCtx = { toolCallbacks: ToolCallbacks } +type DraftConfig = Record +type ScheduleDraftConfig = NewSchedule & DraftConfig +type TriggerDraftConfig = TriggerRequestBody & DraftConfig & { path: string } + +function stripBackendMetadata(value: T): T { + const draft = structuredClone(value) + delete draft.workspace_id + delete draft.edited_by + delete draft.edited_at + delete draft.email + delete draft.error + return draft +} + +function mergeDraftConfig( + base: T | undefined, + overrides: DraftConfig, + path: string +): T { + return { + ...(base ? stripBackendMetadata(base) : {}), + ...structuredClone(overrides), + path + } as unknown as T +} + +function resourceToDraftState(resource: Resource): ResourceDraftState { + return { + path: resource.path, + description: resource.description ?? '', + args: structuredClone((resource.value ?? {}) as Record), + labels: resource.labels ?? undefined, + wsSpecific: resource.ws_specific ?? false, + resource_type: resource.resource_type + } +} + +function createResourceToDraftState( + args: CreateResource, + base?: ResourceDraftState +): ResourceDraftState { + return { + ...base, + path: args.path, + description: args.description ?? base?.description ?? '', + args: structuredClone((args.value ?? base?.args ?? {}) as Record), + labels: args.labels ?? base?.labels, + wsSpecific: args.ws_specific ?? base?.wsSpecific ?? false, + resource_type: args.resource_type ?? base?.resource_type + } +} + +function variableToDraftState(variable: ListableVariable): VariableDraftState { + return { + path: variable.path, + variable: { + value: variable.value ?? '', + is_secret: variable.is_secret, + description: variable.description ?? '' + }, + labels: variable.labels ?? undefined, + wsSpecific: variable.ws_specific ?? false, + account: variable.account, + is_oauth: variable.is_oauth, + expires_at: variable.expires_at + } +} + +function createVariableToDraftState( + args: CreateVariable, + base?: VariableDraftState +): VariableDraftState { + return { + ...base, + path: args.path, + variable: { + value: args.is_secret ? '' : args.value, + is_secret: args.is_secret, + description: args.description + }, + labels: args.labels ?? base?.labels, + wsSpecific: args.ws_specific ?? base?.wsSpecific ?? false, + account: args.account ?? base?.account, + is_oauth: args.is_oauth ?? base?.is_oauth, + expires_at: args.expires_at ?? base?.expires_at + } +} + +function syncEphemeralSecretVariableDraftValue(workspace: string, args: CreateVariable): void { + const storagePath = getGlobalDraftStoragePath(workspace, 'variable', args.path) + if (args.is_secret) { + setEphemeralSecretVariableDraftValue(workspace, storagePath, args.value) + } else { + clearEphemeralSecretVariableDraftValue(workspace, storagePath) + } +} + +function buildVariableDeployRequestBody( + workspace: string, + path: string, + draftValue: CreateVariable +): CreateVariable { + const requestBody = structuredClone(draftValue) + if (!requestBody.is_secret) return requestBody + + const storagePath = getGlobalDraftStoragePath(workspace, 'variable', path) + const secretValue = getEphemeralSecretVariableDraftValue(workspace, storagePath) + if (secretValue === undefined) { + throw new Error( + `Secret value for local draft variable "${path}" is no longer available because secret draft values are kept only in memory. Run write_variable again before deploying this secret.` + ) + } + + return { ...requestBody, value: secretValue } +} + +function startDraftWrite(ctx: WriteDraftCtx, type: WorkspaceItemType, path: string): void { + ctx.toolCallbacks.setToolStatus(ctx.toolId, { + content: `Writing draft ${type} "${path}"...` + }) +} + +function getRequiredGlobalDraft( + workspace: string, + type: WorkspaceItemType, + path: string, + triggerKind?: TriggerKind +): WorkspaceItem { + const draft = getGlobalDraft(workspace, type, path, triggerKind) + if (!draft) { + throw new Error(`Could not read written draft ${type} "${path}".`) + } + return draft +} + +function finishDraftWrite(stored: WorkspaceItem, existed: boolean, ctx: WriteDraftCtx): string { + const verb = existed ? 'Updated' : 'Created' + const serializedItem = + stored.type === 'variable' || stored.type === 'flow' + ? serializeWorkspaceItemForRead(stored) + : stored + + ctx.toolCallbacks.setToolStatus(ctx.toolId, { + content: `${verb} local draft ${stored.type} "${stored.path}"`, + result: `Draft ${verb.toLowerCase()}` + }) + return JSON.stringify( + { + success: true, + message: `${verb} local draft ${stored.type} "${stored.path}". The workspace was not saved or deployed.`, + item: serializedItem + }, + null, + 2 + ) +} + +async function writeScriptDraft( + args: { path: string; summary?: string; language: ScriptLang; content: string }, + ctx: WriteDraftCtx +): Promise { + const { workspace } = ctx + startDraftWrite(ctx, 'script', args.path) + const storagePath = getGlobalDraftStoragePath(workspace, 'script', args.path) + + const existingDraft = UserDraft.get('script', storagePath, { workspace }) + const backendExists = existingDraft + ? false + : await ScriptService.existsScriptByPath({ workspace, path: args.path }) + + if (existingDraft) { + const draft: NewScript = { + ...structuredClone(existingDraft), + path: args.path, + summary: args.summary ?? existingDraft.summary, + content: args.content, + language: args.language + } + UserDraft.save('script', storagePath, draft, { workspace }) + } else if (backendExists) { + const existing = await ScriptService.getScriptByPathWithDraft({ + workspace, + path: args.path + }) + const base = (existing.draft ?? existing) as NewScript + const draft: NewScript = { + ...structuredClone(base), + parent_hash: existing.hash, + path: args.path, + summary: args.summary ?? base.summary, + content: args.content, + language: args.language + } + UserDraft.setDraftAndMeta( + 'script', + storagePath, + draft, + { remoteRev: existing.hash, remoteDraftRev: existing.draft_created_at }, + { workspace } + ) + } else { + const draft: NewScript = { + path: args.path, + summary: args.summary ?? '', + description: '', + content: args.content, + schema: emptySchema(), + is_template: false, + language: args.language, + kind: 'script' + } + UserDraft.save('script', storagePath, draft, { workspace }) + } + + return finishDraftWrite( + getRequiredGlobalDraft(workspace, 'script', args.path), + existingDraft !== undefined || backendExists, + ctx + ) +} + +async function writeFlowDraft( + args: { path: string; summary?: string; flow: FlowDraftValue }, + ctx: WriteDraftCtx +): Promise { + const { workspace } = ctx + startDraftWrite(ctx, 'flow', args.path) + const storagePath = getGlobalDraftStoragePath(workspace, 'flow', args.path) + + const draftValue = args.flow + const value = structuredClone(draftValue.value) + if (draftValue.groups !== undefined && draftValue.groups !== null) { + value.groups = structuredClone(draftValue.groups) + } + + const existingDraft = UserDraft.get('flow', storagePath, { workspace }) + const backendExists = existingDraft + ? false + : await FlowService.existsFlowByPath({ workspace, path: args.path }) + + if (existingDraft) { + const draft: Flow = { + ...structuredClone(existingDraft), + path: args.path, + summary: args.summary ?? existingDraft.summary, + value, + schema: draftValue.schema ?? existingDraft.schema + } + UserDraft.save('flow', storagePath, draft, { workspace }) + } else if (backendExists) { + const [existing, latestVersion] = await Promise.all([ + FlowService.getFlowByPathWithDraft({ workspace, path: args.path }), + FlowService.getFlowLatestVersion({ workspace, path: args.path }) + ]) + const base = (existing.draft ?? existing) as Flow + const draft: Flow = { + ...structuredClone(base), + path: args.path, + summary: args.summary ?? base.summary, + value, + schema: draftValue.schema ?? base.schema + } + UserDraft.setDraftAndMeta( + 'flow', + storagePath, + draft, + { remoteRev: latestVersion.id, remoteDraftRev: existing.draft_created_at }, + { workspace } + ) + } else { + const draft: Flow = { + path: args.path, + summary: args.summary ?? '', + value, + schema: draftValue.schema ?? emptySchema(), + edited_by: '', + edited_at: '', + archived: false, + extra_perms: {} + } + UserDraft.save('flow', storagePath, draft, { workspace }) + } + + return finishDraftWrite( + getRequiredGlobalDraft(workspace, 'flow', args.path), + existingDraft !== undefined || backendExists, + ctx + ) +} + +async function writeScheduleDraft(args: NewSchedule, ctx: WriteDraftCtx): Promise { + const { workspace } = ctx + startDraftWrite(ctx, 'schedule', args.path) + + const existingDraft = UserDraft.get('trigger_schedule', args.path, { + workspace + }) + const backendExists = existingDraft + ? false + : await ScheduleService.existsSchedule({ workspace, path: args.path }) + + const base = existingDraft + ? existingDraft + : backendExists + ? ((await ScheduleService.getSchedule({ + workspace, + path: args.path + })) as ScheduleDraftConfig) + : undefined + const draft = mergeDraftConfig(base, args as DraftConfig, args.path) + + UserDraft.save('trigger_schedule', args.path, draft, { workspace }) + + return finishDraftWrite( + getRequiredGlobalDraft(workspace, 'schedule', args.path), + existingDraft !== undefined || backendExists, + ctx + ) +} + +async function writeTriggerDraft( + args: { kind: TriggerKind; config: unknown }, + ctx: WriteDraftCtx +): Promise { + const { workspace } = ctx + const config = args.config as TriggerDraftConfig + const path = config.path + const itemKind = triggerKindToUserDraftKind(args.kind) + startDraftWrite(ctx, 'trigger', path) + + const existingDraft = UserDraft.get(itemKind, path, { workspace }) + const backendExists = existingDraft + ? false + : await triggerServices[args.kind].exists({ workspace, path }) + + const base = existingDraft + ? existingDraft + : backendExists + ? ((await triggerServices[args.kind].get({ workspace, path })) as TriggerDraftConfig) + : undefined + const draft = mergeDraftConfig(base, config, path) + + UserDraft.save(itemKind, path, draft, { workspace }) + + return finishDraftWrite( + getRequiredGlobalDraft(workspace, 'trigger', path, args.kind), + existingDraft !== undefined || backendExists, + ctx + ) +} + +async function writeResourceDraft(args: CreateResource, ctx: WriteDraftCtx): Promise { + const { workspace } = ctx + startDraftWrite(ctx, 'resource', args.path) + + const existingDraft = UserDraft.get('resource', args.path, { workspace }) + const backendExists = existingDraft + ? false + : await ResourceService.existsResource({ workspace, path: args.path }) + + if (existingDraft) { + UserDraft.save('resource', args.path, createResourceToDraftState(args, existingDraft), { + workspace + }) + } else if (backendExists) { + const existing = await ResourceService.getResource({ workspace, path: args.path }) + UserDraft.setDraftAndMeta( + 'resource', + args.path, + createResourceToDraftState(args, resourceToDraftState(existing)), + { remoteRev: existing.edited_at }, + { workspace } + ) + } else { + UserDraft.save('resource', args.path, createResourceToDraftState(args), { workspace }) + } + + return finishDraftWrite( + getRequiredGlobalDraft(workspace, 'resource', args.path), + existingDraft !== undefined || backendExists, + ctx + ) +} + +async function writeVariableDraft(args: CreateVariable, ctx: WriteDraftCtx): Promise { + const { workspace } = ctx + startDraftWrite(ctx, 'variable', args.path) + + const existingDraft = UserDraft.get('variable', args.path, { workspace }) + const backendExists = existingDraft + ? false + : await VariableService.existsVariable({ workspace, path: args.path }) + + if (existingDraft) { + UserDraft.save('variable', args.path, createVariableToDraftState(args, existingDraft), { + workspace + }) + } else if (backendExists) { + const existing = await VariableService.getVariable({ + workspace, + path: args.path, + decryptSecret: false + }) + UserDraft.setDraftAndMeta( + 'variable', + args.path, + createVariableToDraftState(args, variableToDraftState(existing)), + { remoteRev: existing.edited_at }, + { workspace } + ) + } else { + UserDraft.save('variable', args.path, createVariableToDraftState(args), { workspace }) + } + syncEphemeralSecretVariableDraftValue(workspace, args) + + return finishDraftWrite( + getRequiredGlobalDraft(workspace, 'variable', args.path), + existingDraft !== undefined || backendExists, + ctx + ) +} + async function loadScriptForEdit( path: string, workspace: string ): Promise<{ content: string; language: ScriptLang; summary?: string }> { - const draft = globalDraftStore.getDraft(workspace, 'script', path) + const draft = getGlobalDraft(workspace, 'script', path) if (draft) { if (typeof draft.value !== 'string' || !draft.language) { throw new Error(`Draft script "${path}" is missing content or language.`) @@ -1743,14 +2169,12 @@ async function editScript( const base = await loadScriptForEdit(path, ctx.workspace) const updated = findAndReplace(base.content, oldString, newString, replaceAll, 'script source') - return writeDraft( + return writeScriptDraft( { - type: 'script', path, summary: base.summary, language: base.language, - value: updated, - isDraft: true + content: updated }, ctx ) @@ -1760,7 +2184,7 @@ async function loadFlowDraftValue( path: string, workspace: string ): Promise<{ flow: FlowDraftValue; summary?: string }> { - const draft = globalDraftStore.getDraft(workspace, 'flow', path) + const draft = getGlobalDraft(workspace, 'flow', path) if (draft) { if (draft.value === undefined || typeof draft.value === 'string') { throw new Error(`Draft flow "${path}" has no value.`) @@ -1807,18 +2231,16 @@ async function patchFlowJson( const patchedEditable = validateEditableFlowJson(parsedValue) const newFlowValue = applyEditableFlowJsonToFlow(base.flow.value, patchedEditable, session) - return writeDraft( + return writeFlowDraft( { - type: 'flow', path, summary: base.summary, - value: { + flow: { ...base.flow, value: newFlowValue, schema: patchedEditable.schema, groups: patchedEditable.groups - }, - isDraft: true + } }, ctx ) @@ -1865,13 +2287,11 @@ async function setFlowModuleCode( } session.set(args.module_id, args.code) const newFlowValue = applyEditableFlowJsonToFlow(base.flow.value, editable, session) - return writeDraft( + return writeFlowDraft( { - type: 'flow', path: args.path, summary: base.summary, - value: { ...base.flow, value: newFlowValue }, - isDraft: true + flow: { ...base.flow, value: newFlowValue } }, ctx ) @@ -1889,9 +2309,9 @@ async function initApp( const { workspace, toolId, toolCallbacks } = ctx const { path, summary, framework, data } = args - if (globalDraftStore.getDraft(workspace, 'app', path)) { + if (getGlobalDraft(workspace, 'app', path)) { throw new Error( - `An AI draft for app "${path}" already exists. Use write_app_file / write_app_runnable to modify it, or delete the existing draft first.` + `A local draft for app "${path}" already exists. Use write_app_file / write_app_runnable to modify it, or delete the existing draft first.` ) } if (await AppService.existsApp({ workspace, path })) { @@ -1927,7 +2347,7 @@ async function initApp( return JSON.stringify( { success: true, - message: `Initialized AI draft app "${path}" from the ${framework} template with a starter runnable "${STARTER_RUNNABLE_KEY}". Use write_app_file / write_app_runnable to evolve the draft.`, + message: `Initialized local draft app "${path}" from the ${framework} template with a starter runnable "${STARTER_RUNNABLE_KEY}". Use write_app_file / write_app_runnable to evolve the draft.`, item: stored }, null, @@ -1945,7 +2365,7 @@ async function readAppFile( content: `Reading ${target.filePath} from app "${args.path}"...` }) - const value = await loadAppDraftValue(args.path, workspace) + const value = await loadAppValueForRead(args.path, workspace) if (target.kind === 'frontend') { const content = value.files[target.filePath] @@ -1978,9 +2398,9 @@ async function writeAppFile( content: `Writing ${target.filePath} to app "${args.path}"...` }) - const value = await loadAppDraftValue(args.path, workspace) + const { value, meta } = await loadAppDraftValue(args.path, workspace) value.files = { ...value.files, [target.filePath]: args.content } - const stored = saveAppDraft(workspace, args.path, value) + const stored = saveAppDraft(workspace, args.path, value, meta) toolCallbacks.setToolStatus(toolId, { content: `Updated ${target.filePath} in app "${args.path}"`, @@ -1989,7 +2409,7 @@ async function writeAppFile( return JSON.stringify( { success: true, - message: `Updated AI draft app "${args.path}" with frontend file "${target.filePath}".`, + message: `Updated local draft app "${args.path}" with frontend file "${target.filePath}".`, item: stored }, null, @@ -2014,13 +2434,13 @@ async function deleteAppFile( content: `Deleting ${target.filePath} from app "${args.path}"...` }) - const value = await loadAppDraftValue(args.path, workspace) + const { value, meta } = await loadAppDraftValue(args.path, workspace) if (!(target.filePath in value.files)) { throw new Error(`Frontend file "${target.filePath}" not found in app "${args.path}".`) } const { [target.filePath]: _removed, ...remaining } = value.files value.files = remaining - const stored = saveAppDraft(workspace, args.path, value) + const stored = saveAppDraft(workspace, args.path, value, meta) toolCallbacks.setToolStatus(toolId, { content: `Removed ${target.filePath} from app "${args.path}"`, @@ -2029,7 +2449,7 @@ async function deleteAppFile( return JSON.stringify( { success: true, - message: `Removed "${target.filePath}" from AI draft app "${args.path}".`, + message: `Removed "${target.filePath}" from local draft app "${args.path}".`, item: stored }, null, @@ -2048,15 +2468,23 @@ async function patchAppFile( ctx: WriteDraftCtx ): Promise { const { workspace, toolId, toolCallbacks } = ctx - const { path, file_path: filePath, old_string: oldString, new_string: newString, replace_all: replaceAll } = args + const { + path, + file_path: filePath, + old_string: oldString, + new_string: newString, + replace_all: replaceAll + } = args const target = resolveAppFileTarget(filePath) if (target.kind === 'frontend') { assertNotGeneratedAppFile(target.filePath) } - toolCallbacks.setToolStatus(toolId, { content: `Patching ${target.filePath} in app "${path}"...` }) + toolCallbacks.setToolStatus(toolId, { + content: `Patching ${target.filePath} in app "${path}"...` + }) - const value = await loadAppDraftValue(path, workspace) + const { value, meta } = await loadAppDraftValue(path, workspace) let currentContent: string let runnable: PersistedRunnable | undefined @@ -2088,14 +2516,15 @@ async function patchAppFile( [target.key]: { ...runnable!, inlineScript: { - language: runnable!.inlineScript?.language ?? (target.extension === 'py' ? 'python3' : 'bun'), + language: + runnable!.inlineScript?.language ?? (target.extension === 'py' ? 'python3' : 'bun'), content: updated } } } } - const stored = saveAppDraft(workspace, path, value) + const stored = saveAppDraft(workspace, path, value, meta) toolCallbacks.setToolStatus(toolId, { content: `Patched ${target.filePath} in app "${path}"`, result: 'Draft updated' @@ -2103,7 +2532,7 @@ async function patchAppFile( return JSON.stringify( { success: true, - message: `Patched "${target.filePath}" in AI draft app "${path}".`, + message: `Patched "${target.filePath}" in local draft app "${path}".`, item: stored }, null, @@ -2112,10 +2541,7 @@ async function patchAppFile( } async function recomputeAppPolicy(value: AppDraftValue): Promise { - value.policy = (await updateRawAppPolicy( - value.runnables as any, - value.policy as any - )) as any + value.policy = (await updateRawAppPolicy(value.runnables as any, value.policy as any)) as any } async function writeAppRunnable( @@ -2128,12 +2554,12 @@ async function writeAppRunnable( content: `Writing runnable "${key}" to app "${path}"...` }) - const value = await loadAppDraftValue(path, workspace) + const { value, meta } = await loadAppDraftValue(path, workspace) const existing = value.runnables[key] as PersistedRunnable | undefined const persisted = buildPersistedRunnable(input, existing) value.runnables = { ...value.runnables, [key]: persisted } await recomputeAppPolicy(value) - const stored = saveAppDraft(workspace, path, value) + const stored = saveAppDraft(workspace, path, value, meta) toolCallbacks.setToolStatus(toolId, { content: `Updated runnable "${key}" in app "${path}"`, @@ -2142,7 +2568,7 @@ async function writeAppRunnable( return JSON.stringify( { success: true, - message: `Updated AI draft app "${path}" with runnable "${key}".`, + message: `Updated local draft app "${path}" with runnable "${key}".`, item: stored }, null, @@ -2160,14 +2586,14 @@ async function deleteAppRunnable( content: `Removing runnable "${key}" from app "${path}"...` }) - const value = await loadAppDraftValue(path, workspace) + const { value, meta } = await loadAppDraftValue(path, workspace) if (!(key in value.runnables)) { throw new Error(`Backend runnable "${key}" not found in app "${path}".`) } const { [key]: _removed, ...remaining } = value.runnables value.runnables = remaining await recomputeAppPolicy(value) - const stored = saveAppDraft(workspace, path, value) + const stored = saveAppDraft(workspace, path, value, meta) toolCallbacks.setToolStatus(toolId, { content: `Removed runnable "${key}" from app "${path}"`, @@ -2176,7 +2602,7 @@ async function deleteAppRunnable( return JSON.stringify( { success: true, - message: `Removed runnable "${key}" from AI draft app "${path}".`, + message: `Removed runnable "${key}" from local draft app "${path}".`, item: stored }, null, @@ -2196,10 +2622,7 @@ const triggerLabels: Record = { azure: 'Azure Event Grid trigger' } -function createOpenScheduleAction( - path: string, - targetKind: 'script' | 'flow' -): ToolDisplayAction { +function createOpenScheduleAction(path: string, targetKind: 'script' | 'flow'): ToolDisplayAction { return { id: `open-deployed-schedule:${path}`, type: 'open_created_resource', @@ -2246,6 +2669,41 @@ function createOpenVariableAction(path: string): ToolDisplayAction { } } +async function discardLocalDraft( + args: { type: WorkspaceItemType; path: string; trigger_kind?: TriggerKind }, + ctx: WriteDraftCtx +): Promise { + const { workspace, toolId, toolCallbacks } = ctx + const { type, path, trigger_kind: triggerKind } = args + + if (type === 'trigger' && !triggerKind) { + throw new Error('trigger_kind is required when discarding a trigger draft.') + } + + const draft = getGlobalDraft(workspace, type, path, triggerKind) + if (!draft) { + throw new Error(`No local draft found for ${type} "${path}".`) + } + + deleteGlobalDraft(workspace, type, path, triggerKind) + + toolCallbacks.setToolStatus(toolId, { + content: `Discarded local draft ${type} "${path}"`, + result: 'Draft discarded' + }) + return JSON.stringify( + { + success: true, + message: `Discarded local draft ${type} "${path}". The deployed workspace item was not changed.`, + type, + path, + triggerKind + }, + null, + 2 + ) +} + async function deployDraft( args: { type: WorkspaceItemType @@ -2268,9 +2726,9 @@ async function deployDraft( throw new Error('trigger_kind is required when deploying a trigger.') } - const draft = globalDraftStore.getDraft(workspace, type, path, triggerKind) + const draft = getGlobalDraft(workspace, type, path, triggerKind) if (!draft) { - throw new Error(`No AI draft found for ${type} "${path}".`) + throw new Error(`No local draft found for ${type} "${path}".`) } if (draft.value === undefined) { throw new Error(`Draft ${type} "${path}" has no value to deploy.`) @@ -2346,7 +2804,11 @@ async function deployDraft( break } case 'variable': { - const requestBody = draft.value as any + const requestBody = buildVariableDeployRequestBody( + workspace, + path, + draft.value as CreateVariable + ) if (await VariableService.existsVariable({ workspace, path })) { await VariableService.updateVariable({ workspace, path, requestBody }) } else { @@ -2357,7 +2819,7 @@ async function deployDraft( } } - globalDraftStore.deleteDraft(workspace, type, path, triggerKind) + deleteGlobalDraft(workspace, type, path, triggerKind, { preserveLiveDraft: true }) toolCallbacks.setToolStatus(toolId, { content: `Deployed ${type} "${path}"`, @@ -2367,7 +2829,7 @@ async function deployDraft( return JSON.stringify( { success: true, - message: `Deployed AI draft ${type} "${path}" to the workspace. Draft removed from the AI draft store.`, + message: `Deployed local draft ${type} "${path}" to the workspace. Draft removed from the local draft system.`, type, path, triggerKind @@ -2416,7 +2878,7 @@ async function deleteWorkspaceItem( break } - globalDraftStore.deleteDraft(workspace, type, path, triggerKind) + deleteGlobalDraft(workspace, type, path, triggerKind) toolCallbacks.setToolStatus(toolId, { content: `Deleted ${type} "${path}"`, @@ -2425,7 +2887,7 @@ async function deleteWorkspaceItem( return JSON.stringify( { success: true, - message: `Deleted ${type} "${path}" from the workspace. Any matching AI draft was also cleared.`, + message: `Deleted ${type} "${path}" from the workspace. Any matching local draft was also cleared.`, type, path, triggerKind @@ -2435,38 +2897,6 @@ async function deleteWorkspaceItem( ) } -async function writeDraft(item: WorkspaceItem, ctx: WriteDraftCtx): Promise { - const { workspace, toolId, toolCallbacks } = ctx - toolCallbacks.setToolStatus(toolId, { - content: `Writing draft ${item.type} "${item.path}"...` - }) - - const exists = - globalDraftStore.getDraft(workspace, item.type, item.path, item.triggerKind) !== undefined || - (await workspaceItemExists(item.type, item.path, workspace, item.triggerKind)) - - const stored = globalDraftStore.setDraft(workspace, item) - const serializedItem = - stored.type === 'variable' || stored.type === 'flow' - ? serializeWorkspaceItemForRead(stored) - : stored - - const verb = exists ? 'Updated' : 'Created' - toolCallbacks.setToolStatus(toolId, { - content: `${verb} AI draft ${item.type} "${item.path}"`, - result: `Draft ${verb.toLowerCase()}` - }) - return JSON.stringify( - { - success: true, - message: `${verb} AI draft ${item.type} "${item.path}". The workspace was not saved or deployed.`, - item: serializedItem - }, - null, - 2 - ) -} - export function prepareGlobalSystemMessage( customPrompt?: string ): ChatCompletionSystemMessageParam { diff --git a/frontend/src/lib/components/copilot/chat/global/deployRequests.test.ts b/frontend/src/lib/components/copilot/chat/global/deployRequests.test.ts index f79bbd2a4e..88cbafe205 100644 --- a/frontend/src/lib/components/copilot/chat/global/deployRequests.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/deployRequests.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { Flow, NewScript, Script } from '$lib/gen/types.gen' import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests' -import type { WorkspaceItem } from './draftStore.svelte' +import type { WorkspaceItem } from './workspaceItems' describe('global AI deploy request builders', () => { it('preserves existing script metadata while replacing draft-controlled fields', () => { diff --git a/frontend/src/lib/components/copilot/chat/global/deployRequests.ts b/frontend/src/lib/components/copilot/chat/global/deployRequests.ts index c9b2630c51..9e779779f6 100644 --- a/frontend/src/lib/components/copilot/chat/global/deployRequests.ts +++ b/frontend/src/lib/components/copilot/chat/global/deployRequests.ts @@ -1,5 +1,5 @@ import type { Flow, NewScript, OpenFlowWPath, Script } from '$lib/gen/types.gen' -import type { FlowDraftValue, WorkspaceItem } from './draftStore.svelte' +import type { FlowDraftValue, WorkspaceItem } from './workspaceItems' type ScriptWithDeployMetadata = Script & Partial> diff --git a/frontend/src/lib/components/copilot/chat/global/draftStore.svelte.ts b/frontend/src/lib/components/copilot/chat/global/draftStore.svelte.ts deleted file mode 100644 index c0fb8985f5..0000000000 --- a/frontend/src/lib/components/copilot/chat/global/draftStore.svelte.ts +++ /dev/null @@ -1,192 +0,0 @@ -import type { - AzureTriggerData, - CreateResource, - CreateVariable, - FlowValue, - GcpTriggerData, - NewHttpTrigger, - NewKafkaTrigger, - NewMqttTrigger, - NewNatsTrigger, - NewPostgresTrigger, - NewSchedule, - NewSqsTrigger, - NewWebsocketTrigger, - Policy, - ScriptLang -} from '$lib/gen/types.gen' - -/** - * Flow draft value. Mirrors what the backend's create/update flow API expects - * — the OpenFlow value, plus the inputs schema and (optional) groups. - * - * Schema and groups are split out from FlowValue intentionally so that - * deploy_workspace_item can preserve them through the draft → workspace - * round-trip; an earlier version dropped them on every deploy. - */ -export type FlowDraftValue = { - value: FlowValue - schema?: Record | null - groups?: NonNullable | null -} - -export const TRIGGER_KINDS = [ - 'http', - 'websocket', - 'kafka', - 'nats', - 'postgres', - 'mqtt', - 'sqs', - 'gcp', - 'azure' -] as const - -export type TriggerKind = (typeof TRIGGER_KINDS)[number] - -export type TriggerRequestBody = - | NewHttpTrigger - | NewWebsocketTrigger - | NewKafkaTrigger - | NewNatsTrigger - | NewPostgresTrigger - | NewMqttTrigger - | NewSqsTrigger - | GcpTriggerData - | AzureTriggerData - -export type WorkspaceItemType = - | 'script' - | 'flow' - | 'schedule' - | 'trigger' - | 'resource' - | 'variable' - | 'app' - -export type AppDraftValue = { - summary?: string - files: Record - runnables: Record - data?: any - policy?: Policy - custom_path?: string -} - -export type WorkspaceItem = { - type: WorkspaceItemType - path: string - summary?: string - language?: ScriptLang - triggerKind?: TriggerKind - value?: - | string - | FlowDraftValue - | NewSchedule - | TriggerRequestBody - | CreateResource - | CreateVariable - | AppDraftValue - isDraft: boolean -} - -export function getWorkspaceItemKey( - type: WorkspaceItemType, - path: string, - triggerKind?: TriggerKind -): string { - if (type === 'trigger') { - return `trigger:${triggerKind ?? ''}:${path}` - } - return `${type}:${path}` -} - -function clone(value: T): T { - return structuredClone($state.snapshot(value)) as T -} - -class GlobalDraftStore { - private drafts = $state>>({}) - - private getWorkspaceDrafts(workspace: string): Record { - return this.drafts[workspace] ?? {} - } - - private ensureWorkspaceDrafts(workspace: string): Record { - if (!this.drafts[workspace]) { - this.drafts[workspace] = {} - } - return this.drafts[workspace] - } - - listDrafts(workspace: string): WorkspaceItem[] { - return Object.values(this.getWorkspaceDrafts(workspace)).map(clone) - } - - getDraft( - workspace: string, - type: WorkspaceItemType, - path: string, - triggerKind?: TriggerKind - ): WorkspaceItem | undefined { - const draft = this.getWorkspaceDrafts(workspace)[getWorkspaceItemKey(type, path, triggerKind)] - return draft ? clone(draft) : undefined - } - - setDraft(workspace: string, item: WorkspaceItem): WorkspaceItem { - const stored: WorkspaceItem = { ...clone(item), isDraft: true } - this.ensureWorkspaceDrafts(workspace)[ - getWorkspaceItemKey(item.type, item.path, item.triggerKind) - ] = stored - return clone(stored) - } - - deleteDraft( - workspace: string, - type: WorkspaceItemType, - path: string, - triggerKind?: TriggerKind - ): void { - const drafts = this.drafts[workspace] - if (!drafts) return - - delete drafts[getWorkspaceItemKey(type, path, triggerKind)] - if (Object.keys(drafts).length === 0) { - delete this.drafts[workspace] - } - } - - clearDrafts(workspace: string): void { - delete this.drafts[workspace] - } - - getScriptDraft(workspace: string, path: string): WorkspaceItem | undefined { - return this.getDraft(workspace, 'script', path) - } - - getFlowDraft(workspace: string, path: string): WorkspaceItem | undefined { - return this.getDraft(workspace, 'flow', path) - } - - getScheduleDraft(workspace: string, path: string): WorkspaceItem | undefined { - return this.getDraft(workspace, 'schedule', path) - } - - getTriggerDraft(workspace: string, kind: TriggerKind, path: string): WorkspaceItem | undefined { - return this.getDraft(workspace, 'trigger', path, kind) - } - - getResourceDraft(workspace: string, path: string): WorkspaceItem | undefined { - return this.getDraft(workspace, 'resource', path) - } - - getVariableDraft(workspace: string, path: string): WorkspaceItem | undefined { - return this.getDraft(workspace, 'variable', path) - } - - getAppDraft(workspace: string, path: string): WorkspaceItem | undefined { - return this.getDraft(workspace, 'app', path) - } -} - -export const globalDraftStore = new GlobalDraftStore() diff --git a/frontend/src/lib/components/copilot/chat/global/draftStore.test.ts b/frontend/src/lib/components/copilot/chat/global/draftStore.test.ts deleted file mode 100644 index a05126a568..0000000000 --- a/frontend/src/lib/components/copilot/chat/global/draftStore.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest' -import { globalDraftStore } from './draftStore.svelte' - -const WORKSPACE_A = 'draft-store-test-a' -const WORKSPACE_B = 'draft-store-test-b' - -function clearTestDrafts() { - globalDraftStore.clearDrafts(WORKSPACE_A) - globalDraftStore.clearDrafts(WORKSPACE_B) -} - -describe('globalDraftStore', () => { - beforeEach(clearTestDrafts) - - it('lists and reads drafts only from the requested workspace', () => { - globalDraftStore.setDraft(WORKSPACE_A, { - type: 'script', - path: 'f/shared/path', - language: 'bun', - value: 'export async function main() {}', - isDraft: true - }) - - expect(globalDraftStore.getDraft(WORKSPACE_A, 'script', 'f/shared/path')?.value).toBe( - 'export async function main() {}' - ) - expect(globalDraftStore.getDraft(WORKSPACE_B, 'script', 'f/shared/path')).toBeUndefined() - expect(globalDraftStore.listDrafts(WORKSPACE_A)).toHaveLength(1) - expect(globalDraftStore.listDrafts(WORKSPACE_B)).toHaveLength(0) - }) - - it('deletes and clears drafts only from the requested workspace', () => { - globalDraftStore.setDraft(WORKSPACE_A, { - type: 'flow', - path: 'f/shared/path', - value: { value: { modules: [] }, schema: null, groups: null }, - isDraft: true - }) - globalDraftStore.setDraft(WORKSPACE_B, { - type: 'flow', - path: 'f/shared/path', - value: { value: { modules: [] }, schema: { workspace: WORKSPACE_B }, groups: null }, - isDraft: true - }) - - globalDraftStore.deleteDraft(WORKSPACE_A, 'flow', 'f/shared/path') - - expect(globalDraftStore.getDraft(WORKSPACE_A, 'flow', 'f/shared/path')).toBeUndefined() - expect(globalDraftStore.getDraft(WORKSPACE_B, 'flow', 'f/shared/path')).toBeDefined() - - globalDraftStore.clearDrafts(WORKSPACE_B) - - expect(globalDraftStore.listDrafts(WORKSPACE_A)).toHaveLength(0) - expect(globalDraftStore.listDrafts(WORKSPACE_B)).toHaveLength(0) - }) -}) diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts new file mode 100644 index 0000000000..9a6d9125f4 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts @@ -0,0 +1,395 @@ +import type { Flow, NewSchedule, NewScript } from '$lib/gen/types.gen' +import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils' +import { + UserDraft, + type UserDraftEntry, + type UserDraftItemKind, + type UserDraftMeta +} from '$lib/userDraft.svelte' +import { + getWorkspaceItemKey, + type AppDraftValue, + type ResourceDraftState, + type TriggerKind, + type TriggerRequestBody, + type VariableDraftState, + type WorkspaceItem, + type WorkspaceItemType +} from './workspaceItems' + +const TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND = { + http: 'trigger_http', + websocket: 'trigger_websocket', + kafka: 'trigger_kafka', + nats: 'trigger_nats', + postgres: 'trigger_postgres', + mqtt: 'trigger_mqtt', + sqs: 'trigger_sqs', + gcp: 'trigger_gcp', + azure: 'trigger_azure' +} as const satisfies Record + +const TRIGGER_KIND_BY_DRAFT_KIND = Object.fromEntries( + Object.entries(TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND).map(([triggerKind, draftKind]) => [ + draftKind, + triggerKind + ]) +) as Partial> + +const GLOBAL_DRAFT_KINDS = [ + 'script', + 'flow', + 'raw_app', + 'trigger_schedule', + 'trigger_http', + 'trigger_websocket', + 'trigger_kafka', + 'trigger_nats', + 'trigger_postgres', + 'trigger_mqtt', + 'trigger_sqs', + 'trigger_gcp', + 'trigger_azure', + 'resource', + 'variable' +] as const satisfies UserDraftItemKind[] + +const secretVariableDraftValues = new Map>() + +function clone(value: T): T { + return structuredClone(value) as T +} + +function normalizeAppDraftValue(value: AppDraftValue): AppDraftValue { + return { + summary: value.summary, + files: { ...(value.files ?? {}) }, + runnables: { ...(value.runnables ?? {}) }, + data: value.data ?? { ...DEFAULT_RAW_APP_DATA }, + policy: value.policy === undefined ? undefined : clone(value.policy), + custom_path: value.custom_path + } +} + +function getItemSummary(value: unknown): string | undefined { + return ((value as { summary?: string | null } | undefined)?.summary ?? undefined) || undefined +} + +export function setEphemeralSecretVariableDraftValue( + workspace: string, + path: string, + value: string +): void { + let workspaceValues = secretVariableDraftValues.get(workspace) + if (!workspaceValues) { + workspaceValues = new Map() + secretVariableDraftValues.set(workspace, workspaceValues) + } + workspaceValues.set(path, value) +} + +export function getEphemeralSecretVariableDraftValue( + workspace: string, + path: string +): string | undefined { + return secretVariableDraftValues.get(workspace)?.get(path) +} + +export function clearEphemeralSecretVariableDraftValue(workspace: string, path: string): void { + const workspaceValues = secretVariableDraftValues.get(workspace) + if (!workspaceValues) return + workspaceValues.delete(path) + if (workspaceValues.size === 0) secretVariableDraftValues.delete(workspace) +} + +function clearEphemeralSecretVariableDraftValues(workspace: string): void { + secretVariableDraftValues.delete(workspace) +} + +function itemKindFor( + type: WorkspaceItemType, + triggerKind?: TriggerKind +): UserDraftItemKind | undefined { + switch (type) { + case 'script': + case 'flow': + case 'resource': + case 'variable': + return type + case 'app': + return 'raw_app' + case 'schedule': + return 'trigger_schedule' + case 'trigger': + return triggerKind ? TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[triggerKind] : undefined + } +} + +export function triggerKindToUserDraftKind(kind: TriggerKind): UserDraftItemKind { + return TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[kind] +} + +function scriptDraftToWorkspaceItem(path: string, draft: NewScript): WorkspaceItem { + return { + type: 'script', + path, + summary: draft.summary, + language: draft.language, + value: draft.content, + isDraft: true + } +} + +function flowDraftToWorkspaceItem(path: string, draft: Flow): WorkspaceItem { + return { + type: 'flow', + path, + summary: draft.summary, + value: { + value: draft.value, + schema: draft.schema ?? null, + groups: draft.value.groups ?? null + }, + isDraft: true + } +} + +function appDraftToWorkspaceItem(path: string, draft: AppDraftValue): WorkspaceItem { + const value = normalizeAppDraftValue(draft) + return { + type: 'app', + path, + summary: value.summary, + value, + isDraft: true + } +} + +function scheduleDraftToWorkspaceItem(path: string, draft: NewSchedule): WorkspaceItem { + return { + type: 'schedule', + path, + summary: draft.summary ?? undefined, + value: clone(draft), + isDraft: true + } +} + +function triggerDraftToWorkspaceItem( + kind: TriggerKind, + path: string, + draft: TriggerRequestBody +): WorkspaceItem { + return { + type: 'trigger', + triggerKind: kind, + path, + summary: getItemSummary(draft), + value: clone(draft), + isDraft: true + } +} + +function resourceDraftToWorkspaceItem(path: string, draft: ResourceDraftState): WorkspaceItem { + return { + type: 'resource', + path, + summary: draft.description || undefined, + value: { + path, + value: clone(draft.args), + description: draft.description, + resource_type: draft.resource_type ?? '', + labels: draft.labels, + ws_specific: draft.wsSpecific + }, + isDraft: true + } +} + +function variableDraftToWorkspaceItem(path: string, draft: VariableDraftState): WorkspaceItem { + return { + type: 'variable', + path, + summary: draft.variable.description || undefined, + value: { + path, + value: draft.variable.value, + is_secret: draft.variable.is_secret, + description: draft.variable.description, + account: draft.account, + is_oauth: draft.is_oauth, + expires_at: draft.expires_at, + labels: draft.labels, + ws_specific: draft.wsSpecific + }, + isDraft: true + } +} + +function userDraftEntryToWorkspaceItem( + entry: UserDraftEntry, + path = entry.path, + isLiveDraft = false +): WorkspaceItem | undefined { + let item: WorkspaceItem | undefined + switch (entry.itemKind) { + case 'script': + item = scriptDraftToWorkspaceItem(path, entry.value as NewScript) + break + case 'flow': + item = flowDraftToWorkspaceItem(path, entry.value as Flow) + break + case 'raw_app': + item = appDraftToWorkspaceItem(path, entry.value as AppDraftValue) + break + case 'trigger_schedule': + item = scheduleDraftToWorkspaceItem(path, entry.value as NewSchedule) + break + case 'resource': + item = resourceDraftToWorkspaceItem(path, entry.value as ResourceDraftState) + break + case 'variable': + item = variableDraftToWorkspaceItem(path, entry.value as VariableDraftState) + break + default: { + const triggerKind = TRIGGER_KIND_BY_DRAFT_KIND[entry.itemKind] + item = triggerKind + ? triggerDraftToWorkspaceItem(triggerKind, path, entry.value as TriggerRequestBody) + : undefined + } + } + return item && isLiveDraft ? { ...item, isLiveDraft: true } : item +} + +function liveDisplayPath( + workspace: string, + itemKind: UserDraftItemKind, + storagePath: string +): { displayPath: string; isLiveDraft: boolean } { + const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace }) + if (liveDraft?.storagePath !== storagePath) { + return { displayPath: storagePath, isLiveDraft: false } + } + return { + displayPath: liveDraft.effectivePath || storagePath, + isLiveDraft: true + } +} + +function resolveDraftStoragePath( + workspace: string, + itemKind: UserDraftItemKind, + path: string +): string { + const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace }) + if (!liveDraft) return path + if (path === liveDraft.storagePath || path === liveDraft.effectivePath) + return liveDraft.storagePath + return path +} + +export function getGlobalDraftStoragePath( + workspace: string, + type: WorkspaceItemType, + path: string, + triggerKind?: TriggerKind +): string { + const itemKind = itemKindFor(type, triggerKind) + return itemKind ? resolveDraftStoragePath(workspace, itemKind, path) : path +} + +function getGlobalDraftSlot( + workspace: string, + type: WorkspaceItemType, + path: string, + triggerKind?: TriggerKind +) { + const itemKind = itemKindFor(type, triggerKind) + if (!itemKind) return undefined + const storagePath = resolveDraftStoragePath(workspace, itemKind, path) + const draft = UserDraft.get(itemKind, storagePath, { workspace }) + if (draft === undefined) return undefined + + const { displayPath, isLiveDraft } = liveDisplayPath(workspace, itemKind, storagePath) + const entry = { + workspace, + itemKind, + path: storagePath, + value: draft, + meta: {}, + persisted: false, + live: false + } + const item = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft) + if (!item) return undefined + return { itemKind, storagePath, displayPath, item } +} + +export function getGlobalDraft( + workspace: string, + type: WorkspaceItemType, + path: string, + triggerKind?: TriggerKind +): WorkspaceItem | undefined { + return getGlobalDraftSlot(workspace, type, path, triggerKind)?.item +} + +export function listGlobalDrafts(workspace: string): WorkspaceItem[] { + const drafts = new Map() + for (const entry of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) { + const { displayPath, isLiveDraft } = liveDisplayPath(workspace, entry.itemKind, entry.path) + const draft = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft) + if (!draft) continue + drafts.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), draft) + } + return Array.from(drafts.values()) +} + +export function saveGlobalAppDraft( + workspace: string, + path: string, + value: AppDraftValue, + meta?: UserDraftMeta +): WorkspaceItem { + const storagePath = resolveDraftStoragePath(workspace, 'raw_app', path) + const normalized = normalizeAppDraftValue(value) + if (meta) { + UserDraft.setDraftAndMeta('raw_app', storagePath, normalized, meta, { workspace }) + } else { + UserDraft.save('raw_app', storagePath, normalized, { workspace }) + } + const stored = getGlobalDraft(workspace, 'app', path) + if (!stored) throw new Error(`Could not read written app draft "${path}".`) + return stored +} + +type DeleteGlobalDraftOptions = { + preserveLiveDraft?: boolean +} + +export function deleteGlobalDraft( + workspace: string, + type: WorkspaceItemType, + path: string, + triggerKind?: TriggerKind, + options: DeleteGlobalDraftOptions = {} +): void { + const itemKind = itemKindFor(type, triggerKind) + if (!itemKind) return + const storagePath = resolveDraftStoragePath(workspace, itemKind, path) + const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace }) + if (options.preserveLiveDraft && liveDraft?.storagePath === storagePath) { + UserDraft.remove(itemKind, storagePath, { workspace }) + } else { + UserDraft.clear(itemKind, storagePath, { workspace }) + } + if (type === 'variable') clearEphemeralSecretVariableDraftValue(workspace, storagePath) +} + +export function clearGlobalDrafts(workspace: string): void { + for (const draft of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) { + UserDraft.clear(draft.itemKind, draft.path, { workspace }) + } + clearEphemeralSecretVariableDraftValues(workspace) +} diff --git a/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts b/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts new file mode 100644 index 0000000000..acc2a5721a --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts @@ -0,0 +1,122 @@ +import type { + AzureTriggerData, + CreateResource, + CreateVariable, + FlowValue, + GcpTriggerData, + NewHttpTrigger, + NewKafkaTrigger, + NewMqttTrigger, + NewNatsTrigger, + NewPostgresTrigger, + NewSchedule, + NewSqsTrigger, + NewWebsocketTrigger, + Policy, + ScriptLang +} from '$lib/gen/types.gen' + +/** + * Flow draft value. Mirrors what the backend's create/update flow API expects + * -- the OpenFlow value, plus the inputs schema and (optional) groups. + * + * Schema and groups are split out from FlowValue intentionally so that + * deploy_workspace_item can preserve them through the draft -> workspace + * round-trip; an earlier version dropped them on every deploy. + */ +export type FlowDraftValue = { + value: FlowValue + schema?: Record | null + groups?: NonNullable | null +} + +export const TRIGGER_KINDS = [ + 'http', + 'websocket', + 'kafka', + 'nats', + 'postgres', + 'mqtt', + 'sqs', + 'gcp', + 'azure' +] as const + +export type TriggerKind = (typeof TRIGGER_KINDS)[number] + +export type TriggerRequestBody = + | NewHttpTrigger + | NewWebsocketTrigger + | NewKafkaTrigger + | NewNatsTrigger + | NewPostgresTrigger + | NewMqttTrigger + | NewSqsTrigger + | GcpTriggerData + | AzureTriggerData + +export type WorkspaceItemType = + | 'script' + | 'flow' + | 'schedule' + | 'trigger' + | 'resource' + | 'variable' + | 'app' + +export type AppDraftValue = { + summary?: string + files: Record + runnables: Record + data?: any + policy?: Policy + custom_path?: string +} + +export type ResourceDraftState = { + path: string + description: string + args: Record + labels: string[] | undefined + wsSpecific: boolean + resource_type?: string +} + +export type VariableDraftState = { + path: string + variable: { value: string; is_secret: boolean; description: string } + labels: string[] | undefined + wsSpecific: boolean + account?: number + is_oauth?: boolean + expires_at?: string +} + +export type WorkspaceItem = { + type: WorkspaceItemType + path: string + summary?: string + language?: ScriptLang + triggerKind?: TriggerKind + value?: + | string + | FlowDraftValue + | NewSchedule + | TriggerRequestBody + | CreateResource + | CreateVariable + | AppDraftValue + isDraft: boolean + isLiveDraft?: boolean +} + +export function getWorkspaceItemKey( + type: WorkspaceItemType, + path: string, + triggerKind?: TriggerKind +): string { + if (type === 'trigger') { + return `trigger:${triggerKind ?? ''}:${path}` + } + return `${type}:${path}` +} diff --git a/frontend/src/lib/components/flow_builder.ts b/frontend/src/lib/components/flow_builder.ts index 427fb9a9fd..e91a06e430 100644 --- a/frontend/src/lib/components/flow_builder.ts +++ b/frontend/src/lib/components/flow_builder.ts @@ -33,6 +33,7 @@ export type FlowBuilderProps = { stepsState: Record } noInitial?: boolean + liveEditorDraftStoragePath?: string onSaveInitial?: ({ path, id }: { path: string; id: string }) => void onSaveDraft?: ({ path, diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index a127027dac..a5829bb2a2 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -303,6 +303,54 @@ export const settings: Record = { storage: 'setting', ee_only: 'You can only adjust this setting to above 30 days in the EE version', cloudonly: false + }, + { + label: 'Workspace fairness — enabled', + description: + 'Multi-tenant safeguard against a single workspace dominating the shared worker pool. Only relevant on instances where multiple workspaces share one worker group — single-tenant deployments do not need this. When a workspace accounts for at least Workspace fairness — max percent of cluster activity over the last Workspace fairness — duration seconds, each worker pull stochastically excludes that workspace so its share converges to the cap without on/off oscillation. Idle workers always fall back to running its jobs, so capping never starves the queue.', + key: 'workspace_fairness_enabled', + fieldType: 'boolean', + storage: 'setting', + cloudonly: false, + ee_only: + 'Workspace fairness is an Enterprise feature — only useful on multi-tenant clusters where one noisy workspace would otherwise degrade QoS for other workspaces sharing the same worker pool.', + hideInQuickSetup: true + }, + { + label: 'Workspace fairness — max percent', + description: + 'Maximum share of cluster activity any single workspace may sustain before being stochastically throttled by the pull query. The admitted probability for capped workspaces is set just above this value so the cap is statistically stable rather than oscillating. Default 50.', + key: 'workspace_fairness_max_percent', + fieldType: 'number', + placeholder: '50', + storage: 'setting', + cloudonly: false, + ee_only: 'Workspace fairness is an Enterprise feature.', + hideInQuickSetup: true + }, + { + label: 'Workspace fairness — duration (seconds)', + description: + 'Rolling window used to measure workspace share. Activity = currently running jobs ∪ jobs completed in the last N seconds. Default 10.', + key: 'workspace_fairness_duration_secs', + fieldType: 'seconds', + placeholder: '10', + storage: 'setting', + cloudonly: false, + ee_only: 'Workspace fairness is an Enterprise feature.', + hideInQuickSetup: true + }, + { + label: 'Workspace fairness — minimum total jobs', + description: + 'Cap is only applied when cluster-wide activity exceeds this floor. Prevents over-eager capping on small clusters or quiet periods. Default 4.', + key: 'workspace_fairness_min_total_jobs', + fieldType: 'number', + placeholder: '4', + storage: 'setting', + cloudonly: false, + ee_only: 'Workspace fairness is an Enterprise feature.', + hideInQuickSetup: true } ], 'Object Storage': [ diff --git a/frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte b/frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte index e94e56be0c..86e3038fdf 100644 --- a/frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte +++ b/frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte @@ -1,6 +1,12 @@
@@ -185,4 +311,121 @@ bind:value={$values['github_enterprise_app'].private_key} >
+ + {#if assignmentsReady} +
+
+

Workspace assignments

+ +
+

+ Assign installations of the configured GitHub App to specific workspaces so workspace users + don't need GitHub permissions to set up sync. Click Refresh to load installations + the App can see (save the config above first if you haven't). +

+ + {#if discoveryError} +

{discoveryError}

+ {:else if loadingDiscovery && discovered.length === 0} +
+ {:else if discovered.length === 0} +

+ The configured GitHub App has no installations yet. Install it on a GitHub account, then + click Refresh. +

+ {:else} + + + + + + + + + + + {#each discovered as install (install.installation_id)} + + + + + + + {/each} + +
+ GitHub account + + The GitHub organization or user the App is installed on (e.g. + windmill-labs). A GitHub App installation is always scoped to exactly + one account. + + Installation IDAssigned to
{install.account_id}{install.installation_id} + {#if install.assigned_workspaces.length === 0} + + {:else} +
+ {#each install.assigned_workspaces as assignment (assignment.workspace_id)} + + {assignment.workspace_id} + + + {/each} +
+ {/if} +
+
+
+
+ {/if} +
+ {/if}
diff --git a/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte b/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte index 7ecec72dfd..00f86920cc 100644 --- a/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte +++ b/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte @@ -145,17 +145,62 @@ } } + // Toast messages render via {@html} in Toast.svelte, so any backend-supplied + // string interpolated here must be HTML-escaped to prevent stored XSS. + // We deliberately do NOT escape '/' so the toast's path-highlight regex + // (which matches u/.../... and f/.../...) still picks up workspace paths. + function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + } + + function formatMigrationFailures( + failures: Array<{ workspace_id: string; path: string; error: string }> | undefined + ): string { + if (!failures || failures.length === 0) return '' + const maxShown = 5 + const shown = failures + .slice(0, maxShown) + .map((f) => `• ${escapeHtml(f.workspace_id)}/${escapeHtml(f.path)}: ${escapeHtml(f.error)}`) + .join('
') + const extra = + failures.length > maxShown + ? `
…and ${failures.length - maxShown} more (see backend logs)` + : '' + return `
Failures:
${shown}${extra}` + } + + function reportMigrationFailures( + context: string, + report: { + migrated_count: number + total_secrets: number + failed_count: number + failures?: Array<{ workspace_id: string; path: string; error: string }> + } + ) { + console.error(`${context} failures:`, report.failures) + sendUserToast( + `Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed.${formatMigrationFailures(report.failures)}`, + true, + undefined, + undefined, + 15000 + ) + } + async function migrateSecretsToVault() { if (!$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') return migratingToVault = true try { const report = await SettingService.migrateSecretsToVault({ requestBody: getVaultSettings() }) - if (report.failed_count > 0) - sendUserToast( - `Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, - true - ) - else + if (report.failed_count > 0) { + reportMigrationFailures('Vault migration', report) + } else sendUserToast(`Migrated ${report.migrated_count}/${report.total_secrets} secrets to Vault`) } catch (error: any) { sendUserToast('Failed: ' + error.message, true) @@ -172,12 +217,9 @@ const report = await SettingService.migrateSecretsToDatabase({ requestBody: getVaultSettings() }) - if (report.failed_count > 0) - sendUserToast( - `Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, - true - ) - else + if (report.failed_count > 0) { + reportMigrationFailures('Vault->DB migration', report) + } else sendUserToast( `Migrated ${report.migrated_count}/${report.total_secrets} secrets to database` ) @@ -229,12 +271,9 @@ const report = await SettingService.migrateSecretsToAzureKv({ requestBody: getAzureKvSettings() }) - if (report.failed_count > 0) - sendUserToast( - `Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, - true - ) - else + if (report.failed_count > 0) { + reportMigrationFailures('Azure KV migration', report) + } else sendUserToast( `Migrated ${report.migrated_count}/${report.total_secrets} secrets to Azure Key Vault` ) @@ -253,12 +292,9 @@ const report = await SettingService.migrateSecretsFromAzureKv({ requestBody: getAzureKvSettings() }) - if (report.failed_count > 0) - sendUserToast( - `Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, - true - ) - else + if (report.failed_count > 0) { + reportMigrationFailures('Azure KV->DB migration', report) + } else sendUserToast( `Migrated ${report.migrated_count}/${report.total_secrets} secrets to database` ) @@ -308,12 +344,9 @@ migratingToAwsSm = true try { const report = await SettingService.migrateSecretsToAwsSm({ requestBody: getAwsSmSettings() }) - if (report.failed_count > 0) - sendUserToast( - `Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, - true - ) - else + if (report.failed_count > 0) { + reportMigrationFailures('AWS SM migration', report) + } else sendUserToast( `Migrated ${report.migrated_count}/${report.total_secrets} secrets to AWS Secrets Manager` ) @@ -332,12 +365,9 @@ const report = await SettingService.migrateSecretsFromAwsSm({ requestBody: getAwsSmSettings() }) - if (report.failed_count > 0) - sendUserToast( - `Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, - true - ) - else + if (report.failed_count > 0) { + reportMigrationFailures('AWS SM->DB migration', report) + } else sendUserToast( `Migrated ${report.migrated_count}/${report.total_secrets} secrets to database` ) diff --git a/frontend/src/lib/components/propertyPicker/PropPicker.svelte b/frontend/src/lib/components/propertyPicker/PropPicker.svelte index e48afe0bc9..ae02462942 100644 --- a/frontend/src/lib/components/propertyPicker/PropPicker.svelte +++ b/frontend/src/lib/components/propertyPicker/PropPicker.svelte @@ -375,7 +375,7 @@ {/if} {#if displayContext} - {#if $inputMatches?.some((match) => match.word === 'variable')} + {#if !$inputMatches?.length || $inputMatches?.some((match) => match.word === 'variable')} Variables
{#if displayVariable} @@ -412,7 +412,7 @@ {/if}
{/if} - {#if $inputMatches?.some((match) => match.word === 'resource')} + {#if !$inputMatches?.length || $inputMatches?.some((match) => match.word === 'resource')} Resources
{#if displayResources} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 46edb5d230..7249f0ae05 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -1,10 +1,12 @@ @@ -41,9 +65,9 @@
-

Global AI drafts

+

Global local drafts

- Dev-only inspector for the in-memory global draft store. + Dev-only inspector for global local drafts.

+
+ +
workspace: {$workspaceStore ?? '(unset)'}
+user: {$userStore?.username ?? '(unset)'}
+onChange: {JSON.stringify(lastChange, null, 2)}
+ + {#if !$workspaceStore || !$userStore} +
+ Waiting for workspace/user to load. If this never resolves, log into the app at /user/login + first so the workspace cookie/store is set. +
+ {:else} + {#key `${newResource}|${resource_type}|${path}`} + { + console.log('onChange', e) + lastChange = e + }} + /> + {/key} + {/if} +
diff --git a/lsp/Pipfile b/lsp/Pipfile index 9379176973..3761b3018f 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.706.1" +wmill = ">=1.709.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 5d5e9eca9c..e11611b06d 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.706.1 + version: 1.709.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 2c9cda4590..4d3a51dc66 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.706.1' + ModuleVersion = '1.709.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 931a5f9a53..4cb1cc0568 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.706.1" +version = "1.709.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/system_prompts/auto-generated/prompts.d.ts b/system_prompts/auto-generated/prompts.d.ts index d187631f5f..32ddcdc4bd 100644 --- a/system_prompts/auto-generated/prompts.d.ts +++ b/system_prompts/auto-generated/prompts.d.ts @@ -11,7 +11,7 @@ export declare const WAC_SDK_PYTHON = "## Python Workflow-as-Code API (wmill)\n\ export declare const DATATABLE_SDK_TYPESCRIPT = "## TypeScript Datatable API (windmill-client)\n\nImport: `import * as wmill from 'windmill-client'`\n\nSQL statement object with query content, arguments, and execution methods\n```typescript\ntype SqlStatement = {\n /** Raw SQL content with formatted arguments */\n content: string;\n\n /** Argument values keyed by parameter name */\n args: Record;\n\n /**\n * Execute the SQL query and return results\n * @param params - Optional parameters including result collection mode\n * @returns Query results based on the result collection mode\n */\n fetch(\n params?: FetchParams // The union is for auto-completion\n ): Promise>;\n\n /**\n * Execute the SQL query and return only the first row\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOne(\n params?: Omit, \"resultCollection\">\n ): Promise>;\n\n /**\n * Execute the SQL query and return only the first row as a scalar value\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOneScalar(\n params?: Omit<\n FetchParams<\"last_statement_first_row_scalar\">,\n \"resultCollection\"\n >\n ): Promise>;\n\n /**\n * Execute the SQL query without fetching rows\n * @param params - Optional parameters\n */\n execute(\n params?: Omit, \"resultCollection\">\n ): Promise;\n};\n```\n\n```typescript\n// Template tag function: sql`SELECT * FROM table WHERE id = ${id}`.fetch()\ninterface DatatableSqlTemplateFunction {\n // Tagged template usage:\n (strings: TemplateStringsArray, ...values: any[]): SqlStatement;\n query(sql: string, ...params: any[]): SqlStatement;\n};\n```\n\nCreate a SQL template function for PostgreSQL/datatable queries\n@param name - Database/datatable name (default: \"main\")\n@returns SQL template function for building parameterized queries\n@example\nlet sql = wmill.datatable()\nlet name = 'Robin'\nlet age = 21\nawait sql`\n SELECT * FROM friends\n WHERE name = ${name} AND age = ${age}::int\n`.fetch()\n```typescript\nfunction datatable(name: string = \"main\"): DatatableSqlTemplateFunction\n```\n"; export declare const DATATABLE_SDK_PYTHON = "## Python Datatable API (wmill)\n\nImport: `import wmill`\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main') -> DataTableClient\n\n# Client for executing SQL queries against Windmill DataTables.\nclass DataTableClient:\n # Initialize DataTableClient.\n # \n # Args:\n # client: Windmill client instance\n # name: DataTable name\n def __init__(client: Windmill, name: str)\n\n # Execute a SQL query against the DataTable.\n # \n # Args:\n # sql: SQL query string with $1, $2, etc. placeholders\n # *args: Positional arguments to bind to query placeholders\n # \n # Returns:\n # SqlQuery instance for fetching results\n def query(sql: str, *args) -> SqlQuery\n\n\n# Query result handler for DataTable and DuckLake queries.\nclass SqlQuery:\n # Initialize SqlQuery.\n # \n # Args:\n # sql: SQL query string\n # fetch_fn: Function to execute the query\n def __init__(sql: str, fetch_fn)\n\n # Execute query and fetch results.\n # \n # Args:\n # result_collection: Optional result collection mode\n # \n # Returns:\n # Query results\n def fetch(result_collection: str | None = None)\n\n # Execute query and fetch first row of results.\n # \n # Returns:\n # First row of query results\n def fetch_one()\n\n # Execute query and fetch first row of results. Return result as a scalar value.\n # \n # Returns:\n # First row of query result as a scalar value\n def fetch_one_scalar()\n\n # Execute query and don't return any results.\n # \n def execute()\n\n\n"; export declare const OPENFLOW_SCHEMA = "## OpenFlow Schema\n\n{\"OpenFlow\":{\"type\":\"object\",\"description\":\"Top-level flow definition containing metadata, configuration, and the flow structure\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this flow does\"},\"description\":{\"type\":\"string\",\"description\":\"Detailed documentation for this flow\"},\"value\":{\"$ref\":\"#/components/schemas/FlowValue\"},\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')\"},\"on_behalf_of_email\":{\"type\":\"string\",\"description\":\"The flow will be run with the permissions of the user with this email.\"}},\"required\":[\"summary\",\"value\"]},\"FlowValue\":{\"type\":\"object\",\"description\":\"The flow structure containing modules and optional preprocessor/failure handlers\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"failure_module\":{\"description\":\"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types\",\"$ref\":\"#/components/schemas/FlowModule\"},\"preprocessor_module\":{\"description\":\"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results\",\"$ref\":\"#/components/schemas/FlowModule\"},\"same_worker\":{\"type\":\"boolean\",\"description\":\"If true, all steps run on the same worker for better performance\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum number of concurrent executions of this flow\"},\"concurrency_key\":{\"type\":\"string\",\"description\":\"Expression to group concurrent executions (e.g., by user ID)\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window in seconds for concurrent_limit\"},\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce flow executions\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds that a job can be debounced\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of times a job can be debounced\"},\"skip_expr\":{\"type\":\"string\",\"description\":\"JavaScript expression to conditionally skip the entire flow\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for flow results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"delete_after_secs\":{\"type\":\"integer\",\"description\":\"If set, delete the flow job's args, result and logs after this many seconds following job completion\"},\"flow_env\":{\"type\":\"object\",\"description\":\"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).\",\"additionalProperties\":{}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority (higher numbers run first)\"},\"early_return\":{\"type\":\"string\",\"description\":\"JavaScript expression to return early from the flow\"},\"chat_input_enabled\":{\"type\":\"boolean\",\"description\":\"Whether this flow accepts chat-style input\"},\"notes\":{\"type\":\"array\",\"description\":\"Sticky notes attached to the flow\",\"items\":{\"$ref\":\"#/components/schemas/FlowNote\"}},\"groups\":{\"type\":\"array\",\"description\":\"Semantic groups of modules for organizational purposes\",\"items\":{\"$ref\":\"#/components/schemas/FlowGroup\"}}},\"required\":[\"modules\"]},\"Retry\":{\"type\":\"object\",\"description\":\"Retry configuration for failed module executions\",\"properties\":{\"constant\":{\"type\":\"object\",\"description\":\"Retry with constant delay between attempts\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"seconds\":{\"type\":\"integer\",\"description\":\"Seconds to wait between retries\"}}},\"exponential\":{\"type\":\"object\",\"description\":\"Retry with exponential backoff (delay doubles each time)\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"multiplier\":{\"type\":\"integer\",\"description\":\"Multiplier for exponential backoff\"},\"seconds\":{\"type\":\"integer\",\"minimum\":1,\"description\":\"Initial delay in seconds\"},\"random_factor\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":100,\"description\":\"Random jitter percentage (0-100) to avoid thundering herd\"}}},\"retry_if\":{\"$ref\":\"#/components/schemas/RetryIf\"}}},\"FlowNote\":{\"type\":\"object\",\"description\":\"A sticky note attached to a flow for documentation and annotation\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for the note\"},\"text\":{\"type\":\"string\",\"description\":\"Content of the note\"},\"position\":{\"type\":\"object\",\"description\":\"Position of the note in the flow editor\",\"properties\":{\"x\":{\"type\":\"number\",\"description\":\"X coordinate\"},\"y\":{\"type\":\"number\",\"description\":\"Y coordinate\"}},\"required\":[\"x\",\"y\"]},\"size\":{\"type\":\"object\",\"description\":\"Size of the note in the flow editor\",\"properties\":{\"width\":{\"type\":\"number\",\"description\":\"Width in pixels\"},\"height\":{\"type\":\"number\",\"description\":\"Height in pixels\"}},\"required\":[\"width\",\"height\"]},\"color\":{\"type\":\"string\",\"description\":\"Color of the note (e.g., \\\"yellow\\\", \\\"#ffff00\\\")\"},\"type\":{\"type\":\"string\",\"enum\":[\"free\",\"group\"],\"description\":\"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes\"},\"locked\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Whether the note is locked and cannot be edited or moved\"},\"contained_node_ids\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"For group notes, the IDs of nodes contained within this group\"}},\"required\":[\"id\",\"text\",\"color\",\"type\"]},\"FlowGroup\":{\"type\":\"object\",\"description\":\"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Display name for this group\"},\"note\":{\"type\":\"string\",\"description\":\"Markdown note shown below the group header\"},\"autocollapse\":{\"type\":\"boolean\",\"default\":false,\"description\":\"If true, this group is collapsed by default in the flow editor. UI hint only.\"},\"start_id\":{\"type\":\"string\",\"description\":\"ID of the first flow module in this group (topological entry point)\"},\"end_id\":{\"type\":\"string\",\"description\":\"ID of the last flow module in this group (topological exit point)\"},\"color\":{\"type\":\"string\",\"description\":\"Color for the group in the flow editor\"}},\"required\":[\"start_id\",\"end_id\"]},\"RetryIf\":{\"type\":\"object\",\"description\":\"Conditional retry based on error or result\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\"}},\"required\":[\"expr\"]},\"StopAfterIf\":{\"type\":\"object\",\"description\":\"Early termination condition for a module\",\"properties\":{\"skip_if_stopped\":{\"type\":\"boolean\",\"description\":\"If true, following steps are skipped when this condition triggers\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop\"},\"error_message\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.\"}},\"required\":[\"expr\"]},\"FlowModule\":{\"type\":\"object\",\"description\":\"A single step in a flow. Can be a script, subflow, loop, or branch\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)\"},\"value\":{\"$ref\":\"#/components/schemas/FlowModuleValue\"},\"stop_after_if\":{\"description\":\"Early termination condition evaluated after this step completes\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"stop_after_all_iters_if\":{\"description\":\"For loops only - early termination condition evaluated after all iterations complete\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"skip_if\":{\"type\":\"object\",\"description\":\"Conditionally skip this step based on previous results or flow inputs\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'\"}},\"required\":[\"expr\"]},\"sleep\":{\"description\":\"Delay before executing this step (in seconds or as expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for this step's results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"timeout\":{\"description\":\"Maximum execution time in seconds (static value or expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"delete_after_secs\":{\"type\":\"integer\",\"description\":\"If set, delete the step's args, result and logs after this many seconds following job completion\"},\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this step does\"},\"mock\":{\"type\":\"object\",\"description\":\"Mock configuration for testing without executing the actual step\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"description\":\"If true, return mock value instead of executing\"},\"return_value\":{\"description\":\"Value to return when mocked\"}}},\"suspend\":{\"type\":\"object\",\"description\":\"Configuration for approval/resume steps that wait for user input\",\"properties\":{\"required_events\":{\"type\":\"integer\",\"description\":\"Number of approvals required before continuing\"},\"timeout\":{\"type\":\"integer\",\"description\":\"Timeout in seconds before auto-continuing or canceling\"},\"resume_form\":{\"type\":\"object\",\"description\":\"Form schema for collecting input when resuming\",\"properties\":{\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for the resume form\"}}},\"user_auth_required\":{\"type\":\"boolean\",\"description\":\"If true, only authenticated users can approve\"},\"user_groups_required\":{\"description\":\"Expression or list of groups that can approve\",\"$ref\":\"#/components/schemas/InputTransform\"},\"self_approval_disabled\":{\"type\":\"boolean\",\"description\":\"If true, the user who started the flow cannot approve\"},\"hide_cancel\":{\"type\":\"boolean\",\"description\":\"If true, hide the cancel button on the approval form\"},\"continue_on_disapprove_timeout\":{\"type\":\"boolean\",\"description\":\"If true, continue flow on timeout instead of canceling\"}}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority for this step (higher numbers run first)\"},\"continue_on_error\":{\"type\":\"boolean\",\"description\":\"If true, flow continues even if this step fails\"},\"retry\":{\"description\":\"Retry configuration if this step fails\",\"$ref\":\"#/components/schemas/Retry\"},\"debouncing\":{\"description\":\"Debounce configuration for this step (EE only)\",\"type\":\"object\",\"properties\":{\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce this step's executions across flow runs\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Array-type arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds before forced execution\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of debounces before forced execution\"}}}},\"required\":[\"value\",\"id\"]},\"InputTransform\":{\"description\":\"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"StaticTransform\":{\"type\":\"object\",\"description\":\"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'\",\"properties\":{\"value\":{\"description\":\"The static value. For resources, use format '$res:path/to/resource'\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\"]},\"JavascriptTransform\":{\"type\":\"object\",\"description\":\"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)\"},\"type\":{\"type\":\"string\",\"enum\":[\"javascript\"]}},\"required\":[\"expr\",\"type\"]},\"AiTransform\":{\"type\":\"object\",\"description\":\"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"ai\"]}},\"required\":[\"type\"]},\"AIProviderKind\":{\"type\":\"string\",\"description\":\"Supported AI provider types\",\"enum\":[\"openai\",\"azure_openai\",\"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')\"}},\"required\":[\"kind\",\"resource\",\"model\"]},\"StaticProviderTransform\":{\"type\":\"object\",\"description\":\"Static provider configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/ProviderConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"ProviderTransform\":{\"description\":\"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticProviderTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticProviderTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"MemoryOff\":{\"type\":\"object\",\"description\":\"No conversation memory/context\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"off\"]}},\"required\":[\"kind\"]},\"MemoryAuto\":{\"type\":\"object\",\"description\":\"Automatic context management\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"auto\"]},\"context_length\":{\"type\":\"integer\",\"description\":\"Maximum number of messages to retain in context\"},\"memory_id\":{\"type\":\"string\",\"description\":\"Identifier for persistent memory across agent invocations\"}},\"required\":[\"kind\"]},\"MemoryMessage\":{\"type\":\"object\",\"description\":\"A single message in conversation history\",\"properties\":{\"role\":{\"type\":\"string\",\"enum\":[\"user\",\"assistant\",\"system\"]},\"content\":{\"type\":\"string\"}},\"required\":[\"role\",\"content\"]},\"MemoryManual\":{\"type\":\"object\",\"description\":\"Explicit message history\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"manual\"]},\"messages\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/MemoryMessage\"}}},\"required\":[\"kind\",\"messages\"]},\"MemoryConfig\":{\"description\":\"Conversation memory configuration\",\"oneOf\":[{\"$ref\":\"#/components/schemas/MemoryOff\"},{\"$ref\":\"#/components/schemas/MemoryAuto\"},{\"$ref\":\"#/components/schemas/MemoryManual\"}],\"discriminator\":{\"propertyName\":\"kind\",\"mapping\":{\"off\":\"#/components/schemas/MemoryOff\",\"auto\":\"#/components/schemas/MemoryAuto\",\"manual\":\"#/components/schemas/MemoryManual\"}}},\"StaticMemoryTransform\":{\"type\":\"object\",\"description\":\"Static memory configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/MemoryConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"MemoryTransform\":{\"description\":\"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticMemoryTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticMemoryTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"FlowModuleValue\":{\"description\":\"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type\",\"oneOf\":[{\"$ref\":\"#/components/schemas/RawScript\"},{\"$ref\":\"#/components/schemas/PathScript\"},{\"$ref\":\"#/components/schemas/PathFlow\"},{\"$ref\":\"#/components/schemas/ForloopFlow\"},{\"$ref\":\"#/components/schemas/WhileloopFlow\"},{\"$ref\":\"#/components/schemas/BranchOne\"},{\"$ref\":\"#/components/schemas/BranchAll\"},{\"$ref\":\"#/components/schemas/Identity\"},{\"$ref\":\"#/components/schemas/AiAgent\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"rawscript\":\"#/components/schemas/RawScript\",\"script\":\"#/components/schemas/PathScript\",\"flow\":\"#/components/schemas/PathFlow\",\"forloopflow\":\"#/components/schemas/ForloopFlow\",\"whileloopflow\":\"#/components/schemas/WhileloopFlow\",\"branchone\":\"#/components/schemas/BranchOne\",\"branchall\":\"#/components/schemas/BranchAll\",\"identity\":\"#/components/schemas/Identity\",\"aiagent\":\"#/components/schemas/AiAgent\"}}},\"RawScript\":{\"type\":\"object\",\"description\":\"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"content\":{\"type\":\"string\",\"description\":\"The script source code. Should export a 'main' function\"},\"language\":{\"type\":\"string\",\"description\":\"Programming language for this script\",\"enum\":[\"deno\",\"bun\",\"python3\",\"go\",\"bash\",\"powershell\",\"postgresql\",\"mysql\",\"bigquery\",\"snowflake\",\"mssql\",\"oracledb\",\"graphql\",\"nativets\",\"php\",\"rust\",\"ansible\",\"csharp\",\"nu\",\"java\",\"ruby\",\"rlang\",\"duckdb\"]},\"path\":{\"type\":\"string\",\"description\":\"Optional path for saving this script\"},\"lock\":{\"type\":\"string\",\"description\":\"Lock file content for dependencies\"},\"type\":{\"type\":\"string\",\"enum\":[\"rawscript\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum concurrent executions of this script\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window for concurrent_limit\"},\"custom_concurrency_key\":{\"type\":\"string\",\"description\":\"Custom key for grouping concurrent executions\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"},\"assets\":{\"type\":\"array\",\"description\":\"External resources this script accesses (S3 objects, resources, etc.)\",\"items\":{\"type\":\"object\",\"required\":[\"path\",\"kind\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the asset\"},\"kind\":{\"type\":\"string\",\"description\":\"Type of asset\",\"enum\":[\"s3object\",\"resource\",\"ducklake\",\"datatable\",\"volume\"]},\"access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Access level for this asset\",\"enum\":[\"r\",\"w\",\"rw\"]},\"alt_access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Alternative access level\",\"enum\":[\"r\",\"w\",\"rw\"]}}}}},\"required\":[\"type\",\"content\",\"language\",\"input_transforms\"]},\"PathScript\":{\"type\":\"object\",\"description\":\"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the script in the workspace (e.g., 'f/scripts/send_email')\"},\"hash\":{\"type\":\"string\",\"description\":\"Optional specific version hash of the script to use\"},\"type\":{\"type\":\"string\",\"enum\":[\"script\"]},\"tag_override\":{\"type\":\"string\",\"description\":\"Override the script's default worker group tag\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"PathFlow\":{\"type\":\"object\",\"description\":\"Reference to an existing flow by path. Use this to call another flow as a subflow\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the flow in the workspace (e.g., 'f/flows/process_user')\"},\"type\":{\"type\":\"string\",\"enum\":[\"flow\"]}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"ForloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"iterator\":{\"description\":\"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'\",\"$ref\":\"#/components/schemas/InputTransform\"},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"forloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"iterator\",\"skip_failures\",\"type\"]},\"WhileloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in each iteration. Use stop_after_if to control when the loop ends\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"whileloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (use with caution in while loops)\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"skip_failures\",\"type\"]},\"BranchOne\":{\"type\":\"object\",\"description\":\"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches to evaluate in order. The first branch with expr evaluating to true executes\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch condition\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute if this branch's expr is true\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\",\"expr\"]}},\"default\":{\"type\":\"array\",\"description\":\"Steps to execute if no branch expressions match\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"type\":{\"type\":\"string\",\"enum\":[\"branchone\"]}},\"required\":[\"branches\",\"default\",\"type\"]},\"BranchAll\":{\"type\":\"object\",\"description\":\"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches that all execute (either in parallel or sequentially)\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch's purpose\"},\"skip_failure\":{\"type\":\"boolean\",\"description\":\"If true, failure in this branch doesn't fail the entire flow\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in this branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\"]}},\"type\":{\"type\":\"string\",\"enum\":[\"branchall\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, all branches execute concurrently. If false, they execute sequentially\"}},\"required\":[\"branches\",\"type\"]},\"AgentTool\":{\"type\":\"object\",\"description\":\"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')\"},\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this tool does (shown to the AI)\"},\"value\":{\"$ref\":\"#/components/schemas/ToolValue\"}},\"required\":[\"id\",\"value\"]},\"ToolValue\":{\"description\":\"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference\",\"oneOf\":[{\"$ref\":\"#/components/schemas/FlowModuleTool\"},{\"$ref\":\"#/components/schemas/McpToolValue\"},{\"$ref\":\"#/components/schemas/WebsearchToolValue\"}],\"discriminator\":{\"propertyName\":\"tool_type\",\"mapping\":{\"flowmodule\":\"#/components/schemas/FlowModuleTool\",\"mcp\":\"#/components/schemas/McpToolValue\",\"websearch\":\"#/components/schemas/WebsearchToolValue\"}}},\"FlowModuleTool\":{\"description\":\"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module\",\"allOf\":[{\"type\":\"object\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"flowmodule\"]}},\"required\":[\"tool_type\"]},{\"$ref\":\"#/components/schemas/FlowModuleValue\"}]},\"WebsearchToolValue\":{\"type\":\"object\",\"description\":\"A tool implemented as a websearch tool. The AI can call this like any other websearch tool\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"websearch\"]}},\"required\":[\"tool_type\"]},\"McpToolValue\":{\"type\":\"object\",\"description\":\"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"mcp\"]},\"resource_path\":{\"type\":\"string\",\"description\":\"Path to the MCP resource/server configuration\"},\"include_tools\":{\"type\":\"array\",\"description\":\"Whitelist of specific tools to include from this MCP server\",\"items\":{\"type\":\"string\"}},\"exclude_tools\":{\"type\":\"array\",\"description\":\"Blacklist of tools to exclude from this MCP server\",\"items\":{\"type\":\"string\"}}},\"required\":[\"tool_type\",\"resource_path\"]},\"AiAgent\":{\"type\":\"object\",\"description\":\"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Input parameters for the AI agent mapped to their values\",\"properties\":{\"provider\":{\"$ref\":\"#/components/schemas/ProviderTransform\"},\"output_type\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n\"},\"user_message\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax.\"},\"system_prompt\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"System instructions that guide the AI's behavior, persona, and response style. Optional.\"},\"streaming\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n\"},\"memory\":{\"$ref\":\"#/components/schemas/MemoryTransform\"},\"output_schema\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n\"},\"user_attachments\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n\"},\"max_completion_tokens\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n\"},\"temperature\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n\"},\"max_iterations\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n\"}},\"required\":[\"provider\",\"user_message\",\"output_type\"]},\"tools\":{\"type\":\"array\",\"description\":\"Array of tools the agent can use. The agent decides which tools to call based on the task\",\"items\":{\"$ref\":\"#/components/schemas/AgentTool\"}},\"type\":{\"type\":\"string\",\"enum\":[\"aiagent\"]},\"omit_output_from_conversation\":{\"type\":\"boolean\",\"default\":false,\"description\":\"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.\"},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, the agent can execute multiple tool calls in parallel\"}},\"required\":[\"tools\",\"type\",\"input_transforms\"]},\"Identity\":{\"type\":\"object\",\"description\":\"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"identity\"]},\"flow\":{\"type\":\"boolean\",\"description\":\"If true, marks this as a flow identity (special handling)\"}},\"required\":[\"type\"]},\"FlowStatus\":{\"type\":\"object\",\"properties\":{\"step\":{\"type\":\"integer\"},\"modules\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/FlowStatusModule\"}},\"user_states\":{\"additionalProperties\":true},\"preprocessor_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"}]},\"failure_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"},{\"type\":\"object\",\"properties\":{\"parent_module\":{\"type\":\"string\"}}}]},\"retry\":{\"type\":\"object\",\"properties\":{\"fail_count\":{\"type\":\"integer\"},\"failed_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}}}}},\"required\":[\"step\",\"modules\",\"failure_module\"]},\"FlowStatusModule\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"WaitingForPriorSteps\",\"WaitingForEvents\",\"WaitingForExecutor\",\"InProgress\",\"Success\",\"Failure\"]},\"id\":{\"type\":\"string\"},\"job\":{\"type\":\"string\",\"format\":\"uuid\"},\"count\":{\"type\":\"integer\"},\"progress\":{\"type\":\"integer\"},\"iterator\":{\"type\":\"object\",\"properties\":{\"index\":{\"type\":\"integer\"},\"itered\":{\"type\":\"array\",\"items\":{}},\"itered_len\":{\"type\":\"integer\"},\"args\":{}}},\"flow_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"flow_jobs_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}},\"flow_jobs_duration\":{\"type\":\"object\",\"properties\":{\"started_at\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"duration_ms\":{\"type\":\"array\",\"items\":{\"type\":\"integer\"}}}},\"branch_chosen\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"branch\",\"default\"]},\"branch\":{\"type\":\"integer\"}},\"required\":[\"type\"]},\"branchall\":{\"type\":\"object\",\"properties\":{\"branch\":{\"type\":\"integer\"},\"len\":{\"type\":\"integer\"}},\"required\":[\"branch\",\"len\"]},\"approvers\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"resume_id\":{\"type\":\"integer\"},\"approver\":{\"type\":\"string\"}},\"required\":[\"resume_id\",\"approver\"]}},\"failed_retries\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}},\"skipped\":{\"type\":\"boolean\"},\"agent_actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"oneOf\":[{\"type\":\"object\",\"properties\":{\"job_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"tool_call\"]},\"module_id\":{\"type\":\"string\"}},\"required\":[\"job_id\",\"function_name\",\"type\",\"module_id\"]},{\"type\":\"object\",\"properties\":{\"call_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"resource_path\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"mcp_tool_call\"]},\"arguments\":{\"type\":\"object\"}},\"required\":[\"call_id\",\"function_name\",\"resource_path\",\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"web_search\"]}},\"required\":[\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"message\"]}},\"required\":[\"content\",\"type\"]}]}},\"agent_actions_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}}},\"required\":[\"type\"]}}"; -export declare const CLI_COMMANDS = "# Windmill CLI Commands\n\nThe Windmill CLI (`wmill`) provides commands for managing scripts, flows, apps, and other resources.\n\n## Global Options\n\n- `--workspace ` - Specify the target workspace. This overrides the default workspace.\n- `--debug --verbose` - Show debug/verbose logs\n- `--show-diffs` - Show diff informations when syncing (may show sensitive informations)\n- `--token ` - Specify an API token. This will override any stored token.\n- `--base-url ` - Specify the base URL of the API. If used, --token and --workspace are required and no local remote/workspace already set will be used.\n- `--config-dir ` - Specify a custom config directory. Overrides WMILL_CONFIG_DIR environment variable and default ~/.config location.\n\n## Commands\n\n### app\n\napp related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `app list` - list all apps\n - `--json` - Output as JSON (for piping to jq)\n- `app get ` - get an app's details\n - `--json` - Output as JSON (for piping to jq)\n- `app push [file_path:string] [remote_path:string]` - push a local app. With no args, infers the app from the current directory and the remote path from its location relative to wmill.yaml.\n- `app dev [app_folder:string]` - Start a development server for building apps with live reload and hot module replacement\n - `--port ` - Port to run the dev server on (will find next available port if occupied)\n - `--host ` - Host to bind the dev server to\n - `--entry ` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise)\n - `--no-open` - Don't automatically open the browser\n- `app lint [app_folder:string]` - Lint a raw app folder to validate structure and buildability\n - `--fix` - Attempt to fix common issues (not implemented yet)\n- `app new` - create a new raw app from a template\n - `--summary ` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode.\n - `--path ` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.\n - `--framework ` - Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode.\n - `--datatable ` - Datatable to wire up. Without this flag in non-interactive mode, no datatable is configured.\n - `--schema ` - Schema to use with --datatable. Created (CREATE SCHEMA IF NOT EXISTS) if it doesn't already exist.\n - `--overwrite` - Overwrite the target directory if it already exists, without prompting.\n - `--no-open-in-desktop` - Do not prompt to open the new app in Claude Desktop.\n- `app generate-agents [app_folder:string]` - regenerate AGENTS.md and DATATABLES.md from remote workspace\n- `app set-permissioned-as ` - Set the on_behalf_of_email for an app (requires admin or wm_deployers group)\n\n### audit\n\nView audit logs (requires admin)\n\n**Subcommands:**\n\n- `audit list` - List audit log entries\n- `audit get ` - Get a specific audit log entry\n - `--json` - Output as JSON (for piping to jq)\n\n### config\n\nShow all available wmill.yaml configuration options\n\n**Options:**\n- `--json` - Output as JSON for programmatic consumption\n\n**Subcommands:**\n\n- `config migrate` - Migrate wmill.yaml from gitBranches/environments to workspaces format\n\n### datatable\n\ndatatable related commands\n\n**Subcommands:**\n\n- `datatable list` - list all datatables in the workspace\n - `--json` - Output as JSON (for piping to jq)\n- `datatable run ` - run a SQL query on a datatable\n - `-n --name ` - Datatable name (default: main)\n - `-s --silent` - Output only the final result as JSON. Useful for scripting.\n- `datatable serve` - Serve all datatables as a Postgres-wire endpoint (psql, DBeaver, pgAdmin); the client picks the datatable via the database name in its connection string\n - `--port ` - Port to listen on (default: first free port in 5433-5500)\n - `--host ` - Bind address (default: 127.0.0.1)\n - `--password ` - Password for Postgres clients (default: generate a random password at startup)\n- `datatable psql` - Start a serve listener and launch psql connected to it\n - `-n --name ` - Datatable to connect psql to (default: main)\n - `--port ` - Port the proxy listens on (default: first free port in 5433-5500)\n - `--host ` - Bind address for the proxy (default: 127.0.0.1)\n - `--password ` - Password for the temporary Postgres proxy (default: generate a random password at startup)\n\n### dependencies\n\nworkspace dependencies related commands\n\n**Alias:** `deps`\n\n**Subcommands:**\n\n- `dependencies push ` - Push workspace dependencies from a local file\n\n### dev\n\nWatch local file changes and live-reload the dev page for preview. Does NOT deploy to the remote workspace \u2014 use wmill sync push for that.\n\n**Options:**\n- `--includes ` - Filter paths given a glob pattern or path\n- `--proxy-port ` - Port for a localhost reverse proxy to the remote Windmill server\n- `--path ` - Watch a specific windmill path (e.g., u/admin/my_script or f/my_flow)\n- `--no-open` - Do not open the browser automatically\n\n### docs\n\nSearch Windmill documentation.\n\n**Arguments:** ``\n\n**Options:**\n- `--json` - Output results as JSON.\n\n### ducklake\n\nducklake related commands\n\n**Subcommands:**\n\n- `ducklake list` - list all ducklakes in the workspace\n - `--json` - Output as JSON (for piping to jq)\n- `ducklake run ` - run a SQL query on a ducklake\n - `-n --name ` - Ducklake name (default: main)\n - `-s --silent` - Output only the final result as JSON. Useful for scripting.\n\n### flow\n\nflow related commands\n\n**Options:**\n- `--show-archived` - Enable archived flows in output\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `flow list` - list all flows\n - `--show-archived` - Enable archived flows in output\n - `--json` - Output as JSON (for piping to jq)\n- `flow get ` - get a flow's details\n - `--json` - Output as JSON (for piping to jq)\n- `flow push ` - push a local flow spec. This overrides any remote versions.\n - `--message ` - Deployment message\n- `flow run ` - run a flow by path.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not ouput anything other then the final output. Useful for scripting.\n- `flow preview ` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other then the final output. Useful for scripting.\n - `--remote` - Use deployed workspace scripts for PathScript steps instead of local files.\n- `flow new ` - create a new empty flow\n - `--summary ` - flow summary\n - `--description ` - flow description\n- `flow bootstrap ` - create a new empty flow (alias for new)\n - `--summary ` - flow summary\n - `--description ` - flow description\n- `flow history ` - Show version history for a flow\n - `--json` - Output as JSON (for piping to jq)\n- `flow show-version ` - Show a specific version of a flow\n - `--json` - Output as JSON (for piping to jq)\n- `flow set-permissioned-as ` - Set the on_behalf_of_email for a flow (requires admin or wm_deployers group)\n\n### folder\n\nfolder related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `folder list` - list all folders\n - `--json` - Output as JSON (for piping to jq)\n- `folder get ` - get a folder's details\n - `--json` - Output as JSON (for piping to jq)\n- `folder new ` - create a new folder locally\n - `--summary ` - folder summary\n- `folder push ` - push a local folder to the remote by name. This overrides any remote versions.\n- `folder add-missing` - create default folder.meta.yaml for all subdirectories of f/ that are missing one\n - `-y, --yes` - skip confirmation prompt\n- `folder show-rules ` - Show default_permissioned_as rules for a folder. Use --test-path to see which rule matches a given item path.\n - `--test-path ` - Test which rule matches this item path (e.g. f/prod/jobs/my_script)\n - `--json` - Output as JSON\n\n### generate-metadata\n\nGenerate metadata (locks, schemas) for all scripts, flows, and apps\n\n**Arguments:** `[folder:string]`\n\n**Options:**\n- `--yes` - Skip confirmation prompt\n- `--dry-run` - Show what would be updated without making changes\n- `--lock-only` - Re-generate only the lock files\n- `--schema-only` - Re-generate only script schemas (skips flows and apps)\n- `--skip-scripts` - Skip processing scripts\n- `--skip-flows` - Skip processing flows\n- `--skip-apps` - Skip processing apps\n- `--strict-folder-boundaries` - Only update items inside the specified folder (requires folder argument)\n- `--parallel ` - Number of items to process in parallel\n- `-i --includes ` - Comma separated patterns to specify which files to include\n- `-e --excludes ` - Comma separated patterns to specify which files to exclude\n\n**Subcommands:**\n\n- `generate-metadata rehash [folder:string]`\n - `--skip-scripts` - Skip processing scripts\n - `--skip-flows` - Skip processing flows\n - `--skip-apps` - Skip processing apps\n - `--parallel ` - Number of items to process in parallel\n - `-i --includes ` - Comma separated patterns to specify which files to include\n - `-e --excludes ` - Comma separated patterns to specify which files to exclude\n\n### gitsync-settings\n\nManage git-sync settings between local wmill.yaml and Windmill backend\n\n**Subcommands:**\n\n- `gitsync-settings pull` - Pull git-sync settings from Windmill backend to local wmill.yaml\n - `--repository ` - Specify repository path (e.g., u/user/repo)\n - `--default` - Write settings to top-level defaults instead of overrides\n - `--replace` - Replace existing settings (non-interactive mode)\n - `--override` - Add branch-specific override (non-interactive mode)\n - `--diff` - Show differences without applying changes\n - `--json-output` - Output in JSON format\n - `--with-backend-settings ` - Use provided JSON settings instead of querying backend (for testing)\n - `--yes` - Skip interactive prompts and use default behavior\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n- `gitsync-settings push` - Push git-sync settings from local wmill.yaml to Windmill backend\n - `--repository ` - Specify repository path (e.g., u/user/repo)\n - `--diff` - Show what would be pushed without applying changes\n - `--json-output` - Output in JSON format\n - `--with-backend-settings ` - Use provided JSON settings instead of querying backend (for testing)\n - `--yes` - Skip interactive prompts and use default behavior\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n\n### group\n\nManage workspace groups\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `group list` - List all groups in the workspace\n - `--json` - Output as JSON (for piping to jq)\n- `group get ` - Get group details and members\n - `--json` - Output as JSON (for piping to jq)\n- `group create ` - Create a new group\n - `--summary ` - Group summary/description\n- `group delete ` - Delete a group\n- `group add-user ` - Add a user to a group\n- `group remove-user ` - Remove a user from a group\n\n### hub\n\nHub related commands. EXPERIMENTAL. INTERNAL USE ONLY.\n\n**Subcommands:**\n\n- `hub pull` - pull any supported definitions. EXPERIMENTAL.\n\n### init\n\nBootstrap a windmill project with a wmill.yaml file\n\n**Options:**\n- `--use-default` - Use default settings without checking backend\n- `--use-backend` - Use backend git-sync settings if available\n- `--repository ` - Specify repository path (e.g., u/user/repo) when using backend settings\n- `--bind-profile` - Automatically bind active workspace profile to current Git branch\n- `--no-bind-profile` - Skip workspace profile binding prompt\n\n### instance\n\nsync local with a remote instance or the opposite (push or pull)\n\n**Subcommands:**\n\n- `instance add [instance_name:string] [remote:string] [token:string]` - Add a new instance\n- `instance remove ` - Remove an instance\n- `instance switch ` - Switch the current instance\n- `instance pull` - Pull instance settings, users, configs, instance groups and overwrite local\n - `--yes` - Pull without needing confirmation\n - `--dry-run` - Perform a dry run without making changes\n - `--skip-users` - Skip pulling users\n - `--skip-settings` - Skip pulling settings\n - `--skip-configs` - Skip pulling configs (worker groups)\n - `--skip-groups` - Skip pulling instance groups\n - `--include-workspaces` - Also pull workspaces\n - `--folder-per-instance` - Create a folder per instance\n - `--instance ` - Name of the instance to pull from, override the active instance\n - `--prefix ` - Prefix of the local workspaces to pull, used to create the folders when using --include-workspaces\n - `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance\n- `instance push` - Push instance settings, users, configs, group and overwrite remote\n - `--yes` - Push without needing confirmation\n - `--dry-run` - Perform a dry run without making changes\n - `--skip-users` - Skip pushing users\n - `--skip-settings` - Skip pushing settings\n - `--skip-configs` - Skip pushing configs (worker groups)\n - `--skip-groups` - Skip pushing instance groups\n - `--include-workspaces` - Also push workspaces\n - `--folder-per-instance` - Create a folder per instance\n - `--instance ` - Name of the instance to push to, override the active instance\n - `--prefix ` - Prefix of the local workspaces folders to push\n - `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance\n- `instance whoami` - Display information about the currently logged-in user\n- `instance get-config` - Dump the current instance config (global settings + worker configs) as YAML\n - `-o, --output-file ` - Write YAML to a file instead of stdout\n - `--show-secrets` - Include sensitive fields (license key, JWT secret) without prompting\n - `--instance ` - Name of the instance, override the active instance\n- `instance connect-slack`\n - `--bot-token ` - Slack bot token (xoxb-...)\n - `--team-id ` - Slack team id\n - `--team-name ` - Slack team name\n - `--instance ` - Instance profile to connect against (defaults to the active instance)\n\n### job\n\nManage jobs (list, inspect, cancel)\n\n**Subcommands:**\n\n- `job list` - List recent jobs\n- `job get ` - Get job details. For flows: shows step tree with sub-job IDs\n - `--json` - Output as JSON (for piping to jq)\n- `job result ` - Get the result of a completed job (machine-friendly)\n- `job logs ` - Get job logs. For flows: aggregates all step logs\n- `job cancel ` - Cancel a running or queued job\n - `--reason ` - Reason for cancellation\n\n### jobs\n\nPull completed and queued jobs from workspace\n\n**Arguments:** `[workspace:string]`\n\n**Options:**\n- `-c, --completed-output ` - Completed jobs output file (default: completed_jobs.json)\n- `-q, --queued-output ` - Queued jobs output file (default: queued_jobs.json)\n- `--skip-worker-check` - Skip checking for active workers before export\n\n**Subcommands:**\n\n- `jobs pull`\n- `jobs push`\n\n### lint\n\nValidate Windmill flow, schedule, and trigger YAML files in a directory\n\n**Arguments:** `[directory:string]`\n\n**Options:**\n- `--json` - Output results in JSON format\n- `--fail-on-warn` - Exit with code 1 when warnings are emitted\n- `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks\n- `-w, --watch` - Watch for file changes and re-lint automatically\n\n### protection-rules\n\n**Subcommands:**\n\n- `protection-rules pull [workspace:string]` - Pull protection rules from Windmill into protection-rules.yaml for a workspace\n - `--all` - Pull every workspace defined in wmill.yaml\n - `--dry-run` - Show what would change without writing the file\n - `--json-output` - Output in JSON format\n- `protection-rules push [workspace:string]` - Push protection rules from protection-rules.yaml to Windmill for a workspace (full reconcile: creates, updates, and deletes)\n - `--all` - Push every workspace defined in protection-rules.yaml\n - `--dry-run` - Show what would change without applying\n - `--json-output` - Output in JSON format\n - `--yes` - Skip the confirmation prompt (including deletions)\n\n### queues\n\nList all queues with their metrics\n\n**Arguments:** `[workspace:string] the optional workspace to filter by (default to all workspaces)`\n\n**Options:**\n- `--instance [instance]` - Name of the instance to push to, override the active instance\n- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n\n### refresh\n\nRefresh wmill-managed project files (AGENTS.cli.md and skills)\n\n**Subcommands:**\n\n- `refresh prompts` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.\n - `--yes` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include.\n\n### resource\n\nresource related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `resource list` - list all resources\n - `--json` - Output as JSON (for piping to jq)\n- `resource get ` - get a resource's details\n - `--json` - Output as JSON (for piping to jq)\n- `resource new ` - create a new resource locally\n- `resource push ` - push a local resource spec. This overrides any remote versions.\n\n### resource-type\n\nresource type related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `resource-type list` - list all resource types\n - `--schema` - Show schema in the output\n - `--json` - Output as JSON (for piping to jq)\n- `resource-type get ` - get a resource type's details\n - `--json` - Output as JSON (for piping to jq)\n- `resource-type new ` - create a new resource type locally\n- `resource-type push ` - push a local resource spec. This overrides any remote versions.\n- `resource-type generate-namespace` - Create a TypeScript definition file with the RT namespace generated from the resource types\n\n### schedule\n\nschedule related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `schedule list` - list all schedules\n - `--json` - Output as JSON (for piping to jq)\n- `schedule get ` - get a schedule's details\n - `--json` - Output as JSON (for piping to jq)\n- `schedule new ` - create a new schedule locally\n- `schedule push ` - push a local schedule spec. This overrides any remote versions.\n- `schedule enable ` - Enable a schedule\n - `--force` - Bypass the fork-conflict warning when the parent workspace has the same schedule (acknowledges that both crons will fire)\n- `schedule disable ` - Disable a schedule\n- `schedule set-permissioned-as ` - Set the email (run-as user) for a schedule (requires admin or wm_deployers group)\n\n### script\n\nscript related commands\n\n**Options:**\n- `--show-archived` - Show archived scripts instead of active ones\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `script list` - list all scripts\n - `--show-archived` - Show archived scripts instead of active ones\n - `--json` - Output as JSON (for piping to jq)\n- `script push ` - push a local script spec. This overrides any remote versions. Use the script file (.ts, .js, .py, .sh)\n - `--message ` - Deployment message\n- `script get ` - get a script's details\n - `--json` - Output as JSON (for piping to jq)\n- `script show ` - show a script's content (alias for get)\n- `script run ` - run a script by path\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other then the final output. Useful for scripting.\n- `script preview ` - preview a local script without deploying it. Supports both regular and codebase scripts.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other than the final output. Useful for scripting.\n- `script new ` - create a new script\n - `--summary ` - script summary\n - `--description ` - script description\n- `script bootstrap ` - create a new script (alias for new)\n - `--summary ` - script summary\n - `--description ` - script description\n- `script set-permissioned-as ` - Set the on_behalf_of_email for a script (requires admin or wm_deployers group)\n- `script history ` - show version history for a script\n - `--json` - Output as JSON (for piping to jq)\n\n### sync\n\nsync local with a remote workspaces or the opposite (push or pull)\n\n**Subcommands:**\n\n- `sync pull` - Pull any remote changes and apply them locally.\n - `--yes` - Pull without needing confirmation\n - `--dry-run` - Show changes that would be pulled without actually pushing\n - `--plain-secrets` - Pull secrets as plain text\n - `--json` - Use JSON instead of YAML\n - `--skip-variables` - Skip syncing variables (including secrets)\n - `--skip-secrets` - Skip syncing only secrets variables\n - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml)\n - `--skip-resources` - Skip syncing resources\n - `--skip-resource-types` - Skip syncing resource types\n - `--skip-scripts` - Skip syncing scripts\n - `--skip-flows` - Skip syncing flows\n - `--skip-apps` - Skip syncing apps\n - `--skip-folders` - Skip syncing folders\n - `--skip-workspace-dependencies` - Skip syncing workspace dependencies\n - `--skip-scripts-metadata` - Skip syncing scripts metadata, focus solely on logic\n - `--include-schedules` - Include syncing schedules\n - `--include-triggers` - Include syncing triggers\n - `--include-users` - Include syncing users\n - `--include-groups` - Include syncing groups\n - `--include-settings` - Include syncing workspace settings\n - `--include-key` - Include workspace encryption key\n - `--skip-branch-validation` - Skip git branch validation and prompts\n - `--json-output` - Output results in JSON format\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Overrides wmill.yaml includes\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account. Overrides wmill.yaml excludes\n - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy\n - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment\n- `sync push` - Push any local changes and apply them remotely.\n - `--yes` - Push without needing confirmation\n - `--dry-run` - Show changes that would be pushed without actually pushing\n - `--plain-secrets` - Push secrets as plain text\n - `--json` - Use JSON instead of YAML\n - `--skip-variables` - Skip syncing variables (including secrets)\n - `--skip-secrets` - Skip syncing only secrets variables\n - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml)\n - `--skip-resources` - Skip syncing resources\n - `--skip-resource-types` - Skip syncing resource types\n - `--skip-scripts` - Skip syncing scripts\n - `--skip-flows` - Skip syncing flows\n - `--skip-apps` - Skip syncing apps\n - `--skip-folders` - Skip syncing folders\n - `--skip-workspace-dependencies` - Skip syncing workspace dependencies\n - `--skip-scripts-metadata` - Skip syncing scripts metadata, focus solely on logic\n - `--include-schedules` - Include syncing schedules\n - `--include-triggers` - Include syncing triggers\n - `--include-users` - Include syncing users\n - `--include-groups` - Include syncing groups\n - `--include-settings` - Include syncing workspace settings\n - `--include-key` - Include workspace encryption key\n - `--skip-branch-validation` - Skip git branch validation and prompts\n - `--json-output` - Output results in JSON format\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account.\n - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy\n - `--message ` - Include a message that will be added to all scripts/flows/apps updated during this push\n - `--parallel ` - Number of changes to process in parallel\n - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist\n - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment\n - `--lint` - Run lint validation before pushing\n - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks\n - `--auto-metadata` - Automatically regenerate stale metadata (locks and schemas) before pushing\n - `--accept-overriding-permissioned-as-with-self` - Accept that items with a different permissioned_as will be updated with your own user\n\n### token\n\nManage API tokens\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `token list` - List API tokens\n - `--json` - Output as JSON (for piping to jq)\n- `token create` - Create a new API token\n - `--label ` - Token label\n - `--expiration ` - Token expiration (ISO 8601 timestamp)\n- `token delete ` - Delete a token by its prefix\n\n### trigger\n\ntrigger related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `trigger list` - list all triggers\n - `--json` - Output as JSON (for piping to jq)\n- `trigger get ` - get a trigger's details\n - `--json` - Output as JSON (for piping to jq)\n - `--kind ` - Trigger kind (http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email). Recommended for faster lookup\n- `trigger new ` - create a new trigger locally\n - `--kind ` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email)\n- `trigger push ` - push a local trigger spec. This overrides any remote versions.\n- `trigger set-permissioned-as ` - Set the email (run-as user) for a trigger (requires admin or wm_deployers group)\n - `--kind ` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email)\n\n### user\n\nuser related commands\n\n**Subcommands:**\n\n- `user add [password:string]` - Create a user\n - `--superadmin` - Specify to make the new user superadmin.\n - `--company ` - Specify to set the company of the new user.\n - `--name ` - Specify to set the name of the new user.\n- `user remove ` - Delete a user\n- `user create-token` - Create a new API token for the authenticated user\n - `--email ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.\n - `--password ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.\n\n### variable\n\nvariable related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `variable list` - list all variables\n - `--json` - Output as JSON (for piping to jq)\n- `variable get ` - get a variable's details\n - `--json` - Output as JSON (for piping to jq)\n- `variable new ` - create a new variable locally\n- `variable push ` - Push a local variable spec. This overrides any remote versions.\n - `--plain-secrets` - Push secrets as plain text\n- `variable add ` - Create a new variable on the remote. This will update the variable if it already exists.\n - `--plain-secrets` - Push secrets as plain text\n - `--public` - Legacy option, use --plain-secrets instead\n\n### version\n\nShow version information\n\n### worker-groups\n\ndisplay worker groups, pull and push worker groups configs\n\n**Subcommands:**\n\n- `worker-groups pull` - Pull worker groups (similar to `wmill instance pull --skip-users --skip-settings --skip-groups`)\n - `--instance` - Name of the instance to push to, override the active instance\n - `--base-url` - Base url to be passed to the instance settings instead of the local one\n - `--yes` - Pull without needing confirmation\n- `worker-groups push` - Push worker groups (similar to `wmill instance push --skip-users --skip-settings --skip-groups`)\n - `--instance [instance]` - Name of the instance to push to, override the active instance\n - `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n - `--yes` - Push without needing confirmation\n\n### workers\n\nList all workers grouped by worker groups\n\n**Options:**\n- `--instance [instance]` - Name of the instance to push to, override the active instance\n- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n\n### workspace\n\nworkspace related commands\n\n**Alias:** `profile`\n\n**Subcommands:**\n\n- `workspace switch ` - Switch to another workspace\n- `workspace add [workspace_name:string] [workspace_id:string] [remote:string]` - Add a workspace\n - `-c --create` - Create the workspace if it does not exist\n - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.\n - `--create-username ` - Specify your own username in the newly created workspace. Ignored if --create is not specified, the workspace already exists or automatic username creation is enabled on the instance.\n- `workspace remove ` - Remove a workspace\n- `workspace whoami` - Show the currently active user\n- `workspace list` - List local workspace profiles\n- `workspace list-remote` - List workspaces on the remote server that you have access to\n - `--as-superadmin` - List ALL workspaces on the instance (requires the token to belong to a superadmin/devops user)\n- `workspace list-forks` - List forked workspaces on the remote server\n- `workspace bind` - Create or update a workspace entry in wmill.yaml from the active profile\n - `--workspace ` - Workspace name (default: current branch or workspaceId)\n - `--branch ` - Git branch to associate (default: workspace name)\n- `workspace unbind` - Remove baseUrl and workspaceId from a workspace entry\n - `--workspace ` - Workspace to unbind\n- `workspace fork [workspace_name:string] [workspace_id:string]` - Create a forked workspace\n - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.\n - `--color ` - Workspace color (hex code, e.g. #ff0000)\n - `--datatable-behavior ` - How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt)\n - `-y --yes` - Skip interactive prompts (defaults datatable behavior to 'skip')\n- `workspace delete-fork ` - Delete a forked workspace and git branch\n - `-y --yes` - Skip confirmation prompt\n- `workspace merge` - Compare and deploy changes between a fork and its parent workspace\n - `--direction ` - Deploy direction: to-parent or to-fork\n - `--all` - Deploy all changed items including conflicts\n - `--skip-conflicts` - Skip items modified in both workspaces\n - `--include ` - Comma-separated kind:path items to include (e.g. script:f/test/main,flow:f/my/flow)\n - `--exclude ` - Comma-separated kind:path items to exclude\n - `--preserve-on-behalf-of` - Preserve original on_behalf_of/permissioned_as values\n - `-y --yes` - Non-interactive mode (deploy without prompts)\n- `workspace connect-slack` - Non-interactively connect Slack to the active workspace using a pre-minted bot token (xoxb-...). Produces the same artifacts as the UI OAuth flow: workspace_settings fields, g/slack group, f/slack_bot folder, and the encrypted bot token variable + resource at f/slack_bot/bot_token.\n - `--bot-token ` - Slack bot token (xoxb-...)\n - `--team-id ` - Slack team id\n - `--team-name ` - Slack team name\n- `workspace disconnect-slack`\n\n"; +export declare const CLI_COMMANDS = "# Windmill CLI Commands\n\nThe Windmill CLI (`wmill`) provides commands for managing scripts, flows, apps, and other resources.\n\n## Global Options\n\n- `--workspace ` - Specify the target workspace. This overrides the default workspace.\n- `--debug --verbose` - Show debug/verbose logs\n- `--show-diffs` - Show diff informations when syncing (may show sensitive informations)\n- `--token ` - Specify an API token. This will override any stored token.\n- `--base-url ` - Specify the base URL of the API. If used, --token and --workspace are required and no local remote/workspace already set will be used.\n- `--config-dir ` - Specify a custom config directory. Overrides WMILL_CONFIG_DIR environment variable and default ~/.config location.\n\n## Commands\n\n### app\n\napp related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `app list` - list all apps\n - `--json` - Output as JSON (for piping to jq)\n- `app get ` - get an app's details\n - `--json` - Output as JSON (for piping to jq)\n- `app push [file_path:string] [remote_path:string]` - push a local app. With no args, infers the app from the current directory and the remote path from its location relative to wmill.yaml.\n- `app dev [app_folder:string]` - Start a development server for building apps with live reload and hot module replacement\n - `--port ` - Port to run the dev server on (will find next available port if occupied)\n - `--host ` - Host to bind the dev server to\n - `--entry ` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise)\n - `--no-open` - Don't automatically open the browser\n- `app lint [app_folder:string]` - Lint a raw app folder to validate structure and buildability\n - `--fix` - Attempt to fix common issues (not implemented yet)\n- `app new` - create a new raw app from a template\n - `--summary ` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode.\n - `--path ` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.\n - `--framework ` - Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode.\n - `--datatable ` - Datatable to wire up. Without this flag in non-interactive mode, no datatable is configured.\n - `--schema ` - Schema to use with --datatable. Created (CREATE SCHEMA IF NOT EXISTS) if it doesn't already exist.\n - `--overwrite` - Overwrite the target directory if it already exists, without prompting.\n - `--no-open-in-desktop` - Do not prompt to open the new app in Claude Desktop.\n- `app generate-agents [app_folder:string]` - regenerate AGENTS.md and DATATABLES.md from remote workspace\n- `app set-permissioned-as ` - Set the on_behalf_of_email for an app (requires admin or wm_deployers group)\n\n### audit\n\nView audit logs (requires admin)\n\n**Subcommands:**\n\n- `audit list` - List audit log entries\n- `audit get ` - Get a specific audit log entry\n - `--json` - Output as JSON (for piping to jq)\n\n### config\n\nShow all available wmill.yaml configuration options\n\n**Options:**\n- `--json` - Output as JSON for programmatic consumption\n\n**Subcommands:**\n\n- `config migrate` - Migrate wmill.yaml from gitBranches/environments to workspaces format\n\n### datatable\n\ndatatable related commands\n\n**Subcommands:**\n\n- `datatable list` - list all datatables in the workspace\n - `--json` - Output as JSON (for piping to jq)\n- `datatable run ` - run a SQL query on a datatable\n - `-n --name ` - Datatable name (default: main)\n - `-s --silent` - Output only the final result as JSON. Useful for scripting.\n- `datatable serve` - Serve all datatables as a Postgres-wire endpoint (psql, DBeaver, pgAdmin); the client picks the datatable via the database name in its connection string\n - `--port ` - Port to listen on (default: first free port in 5433-5500)\n - `--host ` - Bind address (default: 127.0.0.1)\n - `--password ` - Password for Postgres clients (default: generate a random password at startup)\n- `datatable psql` - Start a serve listener and launch psql connected to it\n - `-n --name ` - Datatable to connect psql to (default: main)\n - `--port ` - Port the proxy listens on (default: first free port in 5433-5500)\n - `--host ` - Bind address for the proxy (default: 127.0.0.1)\n - `--password ` - Password for the temporary Postgres proxy (default: generate a random password at startup)\n\n### dependencies\n\nworkspace dependencies related commands\n\n**Alias:** `deps`\n\n**Subcommands:**\n\n- `dependencies push ` - Push workspace dependencies from a local file\n\n### dev\n\nWatch local file changes and live-reload the dev page for preview. Does NOT deploy to the remote workspace \u2014 use wmill sync push for that.\n\n**Options:**\n- `--includes ` - Filter paths given a glob pattern or path\n- `--proxy-port ` - Port for a localhost reverse proxy to the remote Windmill server\n- `--path ` - Watch a specific windmill path (e.g., u/admin/my_script or f/my_flow)\n- `--no-open` - Do not open the browser automatically\n\n### docs\n\nSearch Windmill documentation.\n\n**Arguments:** ``\n\n**Options:**\n- `--json` - Output results as JSON.\n\n### ducklake\n\nducklake related commands\n\n**Subcommands:**\n\n- `ducklake list` - list all ducklakes in the workspace\n - `--json` - Output as JSON (for piping to jq)\n- `ducklake run ` - run a SQL query on a ducklake\n - `-n --name ` - Ducklake name (default: main)\n - `-s --silent` - Output only the final result as JSON. Useful for scripting.\n\n### flow\n\nflow related commands\n\n**Options:**\n- `--show-archived` - Enable archived flows in output\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `flow list` - list all flows\n - `--show-archived` - Enable archived flows in output\n - `--json` - Output as JSON (for piping to jq)\n- `flow get ` - get a flow's details\n - `--json` - Output as JSON (for piping to jq)\n- `flow push ` - push a local flow spec. This overrides any remote versions.\n - `--message ` - Deployment message\n- `flow run ` - run a flow by path.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not ouput anything other then the final output. Useful for scripting.\n- `flow preview ` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other then the final output. Useful for scripting.\n - `--remote` - Use deployed workspace scripts for PathScript steps instead of local files.\n- `flow new ` - create a new empty flow\n - `--summary ` - flow summary\n - `--description ` - flow description\n- `flow bootstrap ` - create a new empty flow (alias for new)\n - `--summary ` - flow summary\n - `--description ` - flow description\n- `flow history ` - Show version history for a flow\n - `--json` - Output as JSON (for piping to jq)\n- `flow show-version ` - Show a specific version of a flow\n - `--json` - Output as JSON (for piping to jq)\n- `flow set-permissioned-as ` - Set the on_behalf_of_email for a flow (requires admin or wm_deployers group)\n\n### folder\n\nfolder related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `folder list` - list all folders\n - `--json` - Output as JSON (for piping to jq)\n- `folder get ` - get a folder's details\n - `--json` - Output as JSON (for piping to jq)\n- `folder new ` - create a new folder locally\n - `--summary ` - folder summary\n- `folder push ` - push a local folder to the remote by name. This overrides any remote versions.\n- `folder add-missing` - create default folder.meta.yaml for all subdirectories of f/ that are missing one\n - `-y, --yes` - skip confirmation prompt\n- `folder show-rules ` - Show default_permissioned_as rules for a folder. Use --test-path to see which rule matches a given item path.\n - `--test-path ` - Test which rule matches this item path (e.g. f/prod/jobs/my_script)\n - `--json` - Output as JSON\n\n### generate-metadata\n\nGenerate metadata (locks, schemas) for all scripts, flows, and apps\n\n**Arguments:** `[folder:string]`\n\n**Options:**\n- `--yes` - Skip confirmation prompt\n- `--dry-run` - Show what would be updated without making changes\n- `--lock-only` - Re-generate only the lock files\n- `--schema-only` - Re-generate only script schemas (skips flows and apps)\n- `--skip-scripts` - Skip processing scripts\n- `--skip-flows` - Skip processing flows\n- `--skip-apps` - Skip processing apps\n- `--strict-folder-boundaries` - Only update items inside the specified folder (requires folder argument)\n- `--parallel ` - Number of items to process in parallel\n- `-i --includes ` - Comma separated patterns to specify which files to include\n- `-e --excludes ` - Comma separated patterns to specify which files to exclude\n\n**Subcommands:**\n\n- `generate-metadata rehash [folder:string]`\n - `--skip-scripts` - Skip processing scripts\n - `--skip-flows` - Skip processing flows\n - `--skip-apps` - Skip processing apps\n - `--parallel ` - Number of items to process in parallel\n - `-i --includes ` - Comma separated patterns to specify which files to include\n - `-e --excludes ` - Comma separated patterns to specify which files to exclude\n\n### gitsync-settings\n\nManage git-sync settings between local wmill.yaml and Windmill backend\n\n**Subcommands:**\n\n- `gitsync-settings pull` - Pull git-sync settings from Windmill backend to local wmill.yaml\n - `--repository ` - Specify repository path (e.g., u/user/repo)\n - `--default` - Write settings to top-level defaults instead of overrides\n - `--replace` - Replace existing settings (non-interactive mode)\n - `--override` - Add branch-specific override (non-interactive mode)\n - `--diff` - Show differences without applying changes\n - `--json-output` - Output in JSON format\n - `--with-backend-settings ` - Use provided JSON settings instead of querying backend (for testing)\n - `--yes` - Skip interactive prompts and use default behavior\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n- `gitsync-settings push` - Push git-sync settings from local wmill.yaml to Windmill backend\n - `--repository ` - Specify repository path (e.g., u/user/repo)\n - `--diff` - Show what would be pushed without applying changes\n - `--json-output` - Output in JSON format\n - `--with-backend-settings ` - Use provided JSON settings instead of querying backend (for testing)\n - `--yes` - Skip interactive prompts and use default behavior\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n\n### group\n\nManage workspace groups\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `group list` - List all groups in the workspace\n - `--json` - Output as JSON (for piping to jq)\n- `group get ` - Get group details and members\n - `--json` - Output as JSON (for piping to jq)\n- `group create ` - Create a new group\n - `--summary ` - Group summary/description\n- `group delete ` - Delete a group\n- `group add-user ` - Add a user to a group\n- `group remove-user ` - Remove a user from a group\n\n### hub\n\nHub related commands. EXPERIMENTAL. INTERNAL USE ONLY.\n\n**Subcommands:**\n\n- `hub pull` - pull any supported definitions. EXPERIMENTAL.\n\n### init\n\nBootstrap a windmill project with a wmill.yaml file\n\n**Options:**\n- `--use-default` - Use default settings without checking backend\n- `--use-backend` - Use backend git-sync settings if available\n- `--repository ` - Specify repository path (e.g., u/user/repo) when using backend settings\n- `--bind-profile` - Automatically bind active workspace profile to current Git branch\n- `--no-bind-profile` - Skip workspace profile binding prompt\n\n### instance\n\nsync local with a remote instance or the opposite (push or pull)\n\n**Subcommands:**\n\n- `instance add [instance_name:string] [remote:string] [token:string]` - Add a new instance\n- `instance remove ` - Remove an instance\n- `instance switch ` - Switch the current instance\n- `instance pull` - Pull instance settings, users, configs, instance groups and overwrite local\n - `--yes` - Pull without needing confirmation\n - `--dry-run` - Perform a dry run without making changes\n - `--skip-users` - Skip pulling users\n - `--skip-settings` - Skip pulling settings\n - `--skip-configs` - Skip pulling configs (worker groups)\n - `--skip-groups` - Skip pulling instance groups\n - `--include-workspaces` - Also pull workspaces\n - `--folder-per-instance` - Create a folder per instance\n - `--instance ` - Name of the instance to pull from, override the active instance\n - `--prefix ` - Prefix of the local workspaces to pull, used to create the folders when using --include-workspaces\n - `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance\n- `instance push` - Push instance settings, users, configs, group and overwrite remote\n - `--yes` - Push without needing confirmation\n - `--dry-run` - Perform a dry run without making changes\n - `--skip-users` - Skip pushing users\n - `--skip-settings` - Skip pushing settings\n - `--skip-configs` - Skip pushing configs (worker groups)\n - `--skip-groups` - Skip pushing instance groups\n - `--include-workspaces` - Also push workspaces\n - `--folder-per-instance` - Create a folder per instance\n - `--instance ` - Name of the instance to push to, override the active instance\n - `--prefix ` - Prefix of the local workspaces folders to push\n - `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance\n- `instance whoami` - Display information about the currently logged-in user\n- `instance get-config` - Dump the current instance config (global settings + worker configs) as YAML\n - `-o, --output-file ` - Write YAML to a file instead of stdout\n - `--show-secrets` - Include sensitive fields (license key, JWT secret) without prompting\n - `--instance ` - Name of the instance, override the active instance\n- `instance connect-slack`\n - `--bot-token ` - Slack bot token (xoxb-...)\n - `--team-id ` - Slack team id\n - `--team-name ` - Slack team name\n - `--instance ` - Instance profile to connect against (defaults to the active instance)\n\n### job\n\nManage jobs (list, inspect, cancel)\n\n**Subcommands:**\n\n- `job list` - List recent jobs\n- `job get ` - Get job details. For flows: shows step tree with sub-job IDs\n - `--json` - Output as JSON (for piping to jq)\n- `job result ` - Get the result of a completed job (machine-friendly)\n- `job logs ` - Get job logs. For flows: aggregates all step logs\n- `job cancel ` - Cancel a running or queued job\n - `--reason ` - Reason for cancellation\n- `job rerun ` - Re-run a completed job with the same args. Prints the new job UUID on stdout.\n- `job restart ` - Restart a completed flow at a given top-level step. Prints the new flow job UUID on stdout.\n - `--step ` - Top-level step id to restart the flow from\n - `--iteration ` - For a top-level branchall or for-loop step, the iteration to restart at\n\n### jobs\n\nPull completed and queued jobs from workspace\n\n**Arguments:** `[workspace:string]`\n\n**Options:**\n- `-c, --completed-output ` - Completed jobs output file (default: completed_jobs.json)\n- `-q, --queued-output ` - Queued jobs output file (default: queued_jobs.json)\n- `--skip-worker-check` - Skip checking for active workers before export\n\n**Subcommands:**\n\n- `jobs pull`\n- `jobs push`\n\n### lint\n\nValidate Windmill flow, schedule, and trigger YAML files in a directory\n\n**Arguments:** `[directory:string]`\n\n**Options:**\n- `--json` - Output results in JSON format\n- `--fail-on-warn` - Exit with code 1 when warnings are emitted\n- `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks\n- `-w, --watch` - Watch for file changes and re-lint automatically\n\n### protection-rules\n\n**Subcommands:**\n\n- `protection-rules pull [workspace:string]` - Pull protection rules from Windmill into protection-rules.yaml for a workspace\n - `--all` - Pull every workspace defined in wmill.yaml\n - `--dry-run` - Show what would change without writing the file\n - `--json-output` - Output in JSON format\n- `protection-rules push [workspace:string]` - Push protection rules from protection-rules.yaml to Windmill for a workspace (full reconcile: creates, updates, and deletes)\n - `--all` - Push every workspace defined in protection-rules.yaml\n - `--dry-run` - Show what would change without applying\n - `--json-output` - Output in JSON format\n - `--yes` - Skip the confirmation prompt (including deletions)\n\n### queues\n\nList all queues with their metrics\n\n**Arguments:** `[workspace:string] the optional workspace to filter by (default to all workspaces)`\n\n**Options:**\n- `--instance [instance]` - Name of the instance to push to, override the active instance\n- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n\n### refresh\n\nRefresh wmill-managed project files (AGENTS.cli.md and skills)\n\n**Subcommands:**\n\n- `refresh prompts` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.\n - `--yes` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include.\n\n### resource\n\nresource related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `resource list` - list all resources\n - `--json` - Output as JSON (for piping to jq)\n- `resource get ` - get a resource's details\n - `--json` - Output as JSON (for piping to jq)\n- `resource new ` - create a new resource locally\n- `resource push ` - push a local resource spec. This overrides any remote versions.\n\n### resource-type\n\nresource type related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `resource-type list` - list all resource types\n - `--schema` - Show schema in the output\n - `--json` - Output as JSON (for piping to jq)\n- `resource-type get ` - get a resource type's details\n - `--json` - Output as JSON (for piping to jq)\n- `resource-type new ` - create a new resource type locally\n- `resource-type push ` - push a local resource spec. This overrides any remote versions.\n- `resource-type generate-namespace` - Create a TypeScript definition file with the RT namespace generated from the resource types\n\n### schedule\n\nschedule related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `schedule list` - list all schedules\n - `--json` - Output as JSON (for piping to jq)\n- `schedule get ` - get a schedule's details\n - `--json` - Output as JSON (for piping to jq)\n- `schedule new ` - create a new schedule locally\n- `schedule push ` - push a local schedule spec. This overrides any remote versions.\n- `schedule enable ` - Enable a schedule\n - `--force` - Bypass the fork-conflict warning when the parent workspace has the same schedule (acknowledges that both crons will fire)\n- `schedule disable ` - Disable a schedule\n- `schedule set-permissioned-as ` - Set the email (run-as user) for a schedule (requires admin or wm_deployers group)\n\n### script\n\nscript related commands\n\n**Options:**\n- `--show-archived` - Show archived scripts instead of active ones\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `script list` - list all scripts\n - `--show-archived` - Show archived scripts instead of active ones\n - `--json` - Output as JSON (for piping to jq)\n- `script push ` - push a local script spec. This overrides any remote versions. Use the script file (.ts, .js, .py, .sh)\n - `--message ` - Deployment message\n- `script get ` - get a script's details\n - `--json` - Output as JSON (for piping to jq)\n- `script show ` - show a script's content (alias for get)\n- `script run ` - run a script by path\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other then the final output. Useful for scripting.\n- `script preview ` - preview a local script without deploying it. Supports both regular and codebase scripts.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other than the final output. Useful for scripting.\n- `script new ` - create a new script\n - `--summary ` - script summary\n - `--description ` - script description\n- `script bootstrap ` - create a new script (alias for new)\n - `--summary ` - script summary\n - `--description ` - script description\n- `script set-permissioned-as ` - Set the on_behalf_of_email for a script (requires admin or wm_deployers group)\n- `script history ` - show version history for a script\n - `--json` - Output as JSON (for piping to jq)\n\n### sync\n\nsync local with a remote workspaces or the opposite (push or pull)\n\n**Subcommands:**\n\n- `sync pull` - Pull any remote changes and apply them locally.\n - `--yes` - Pull without needing confirmation\n - `--dry-run` - Show changes that would be pulled without actually pushing\n - `--plain-secrets` - Pull secrets as plain text\n - `--json` - Use JSON instead of YAML\n - `--skip-variables` - Skip syncing variables (including secrets)\n - `--skip-secrets` - Skip syncing only secrets variables\n - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml)\n - `--skip-resources` - Skip syncing resources\n - `--skip-resource-types` - Skip syncing resource types\n - `--skip-scripts` - Skip syncing scripts\n - `--skip-flows` - Skip syncing flows\n - `--skip-apps` - Skip syncing apps\n - `--skip-folders` - Skip syncing folders\n - `--skip-workspace-dependencies` - Skip syncing workspace dependencies\n - `--skip-scripts-metadata` - Skip syncing scripts metadata, focus solely on logic\n - `--include-schedules` - Include syncing schedules\n - `--include-triggers` - Include syncing triggers\n - `--include-users` - Include syncing users\n - `--include-groups` - Include syncing groups\n - `--include-settings` - Include syncing workspace settings\n - `--include-key` - Include workspace encryption key\n - `--skip-branch-validation` - Skip git branch validation and prompts\n - `--json-output` - Output results in JSON format\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Overrides wmill.yaml includes\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account. Overrides wmill.yaml excludes\n - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy\n - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment\n- `sync push` - Push any local changes and apply them remotely.\n - `--yes` - Push without needing confirmation\n - `--dry-run` - Show changes that would be pushed without actually pushing\n - `--plain-secrets` - Push secrets as plain text\n - `--json` - Use JSON instead of YAML\n - `--skip-variables` - Skip syncing variables (including secrets)\n - `--skip-secrets` - Skip syncing only secrets variables\n - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml)\n - `--skip-resources` - Skip syncing resources\n - `--skip-resource-types` - Skip syncing resource types\n - `--skip-scripts` - Skip syncing scripts\n - `--skip-flows` - Skip syncing flows\n - `--skip-apps` - Skip syncing apps\n - `--skip-folders` - Skip syncing folders\n - `--skip-workspace-dependencies` - Skip syncing workspace dependencies\n - `--skip-scripts-metadata` - Skip syncing scripts metadata, focus solely on logic\n - `--include-schedules` - Include syncing schedules\n - `--include-triggers` - Include syncing triggers\n - `--include-users` - Include syncing users\n - `--include-groups` - Include syncing groups\n - `--include-settings` - Include syncing workspace settings\n - `--include-key` - Include workspace encryption key\n - `--skip-branch-validation` - Skip git branch validation and prompts\n - `--json-output` - Output results in JSON format\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account.\n - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy\n - `--message ` - Include a message that will be added to all scripts/flows/apps updated during this push\n - `--parallel ` - Number of changes to process in parallel\n - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist\n - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment\n - `--lint` - Run lint validation before pushing\n - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks\n - `--auto-metadata` - Automatically regenerate stale metadata (locks and schemas) before pushing\n - `--accept-overriding-permissioned-as-with-self` - Accept that items with a different permissioned_as will be updated with your own user\n\n### token\n\nManage API tokens\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `token list` - List API tokens\n - `--json` - Output as JSON (for piping to jq)\n- `token create` - Create a new API token\n - `--label ` - Token label\n - `--expiration ` - Token expiration (ISO 8601 timestamp)\n- `token delete ` - Delete a token by its prefix\n\n### trigger\n\ntrigger related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `trigger list` - list all triggers\n - `--json` - Output as JSON (for piping to jq)\n- `trigger get ` - get a trigger's details\n - `--json` - Output as JSON (for piping to jq)\n - `--kind ` - Trigger kind (http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email). Recommended for faster lookup\n- `trigger new ` - create a new trigger locally\n - `--kind ` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email)\n- `trigger push ` - push a local trigger spec. This overrides any remote versions.\n- `trigger set-permissioned-as ` - Set the email (run-as user) for a trigger (requires admin or wm_deployers group)\n - `--kind ` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email)\n\n### user\n\nuser related commands\n\n**Subcommands:**\n\n- `user add [password:string]` - Create a user\n - `--superadmin` - Specify to make the new user superadmin.\n - `--company ` - Specify to set the company of the new user.\n - `--name ` - Specify to set the name of the new user.\n- `user remove ` - Delete a user\n- `user create-token` - Create a new API token for the authenticated user\n - `--email ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.\n - `--password ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.\n\n### variable\n\nvariable related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `variable list` - list all variables\n - `--json` - Output as JSON (for piping to jq)\n- `variable get ` - get a variable's details\n - `--json` - Output as JSON (for piping to jq)\n- `variable new ` - create a new variable locally\n- `variable push ` - Push a local variable spec. This overrides any remote versions.\n - `--plain-secrets` - Push secrets as plain text\n- `variable add ` - Create a new variable on the remote. This will update the variable if it already exists.\n - `--plain-secrets` - Push secrets as plain text\n - `--public` - Legacy option, use --plain-secrets instead\n\n### version\n\nShow version information\n\n### worker-groups\n\ndisplay worker groups, pull and push worker groups configs\n\n**Subcommands:**\n\n- `worker-groups pull` - Pull worker groups (similar to `wmill instance pull --skip-users --skip-settings --skip-groups`)\n - `--instance` - Name of the instance to push to, override the active instance\n - `--base-url` - Base url to be passed to the instance settings instead of the local one\n - `--yes` - Pull without needing confirmation\n- `worker-groups push` - Push worker groups (similar to `wmill instance push --skip-users --skip-settings --skip-groups`)\n - `--instance [instance]` - Name of the instance to push to, override the active instance\n - `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n - `--yes` - Push without needing confirmation\n\n### workers\n\nList all workers grouped by worker groups\n\n**Options:**\n- `--instance [instance]` - Name of the instance to push to, override the active instance\n- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n\n### workspace\n\nworkspace related commands\n\n**Alias:** `profile`\n\n**Subcommands:**\n\n- `workspace switch ` - Switch to another workspace\n- `workspace add [workspace_name:string] [workspace_id:string] [remote:string]` - Add a workspace\n - `-c --create` - Create the workspace if it does not exist\n - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.\n - `--create-username ` - Specify your own username in the newly created workspace. Ignored if --create is not specified, the workspace already exists or automatic username creation is enabled on the instance.\n- `workspace remove ` - Remove a workspace\n- `workspace whoami` - Show the currently active user\n- `workspace list` - List local workspace profiles\n- `workspace list-remote` - List workspaces on the remote server that you have access to\n - `--as-superadmin` - List ALL workspaces on the instance (requires the token to belong to a superadmin/devops user)\n- `workspace list-forks` - List forked workspaces on the remote server\n- `workspace bind` - Create or update a workspace entry in wmill.yaml from the active profile\n - `--workspace ` - Workspace name (default: current branch or workspaceId)\n - `--branch ` - Git branch to associate (default: workspace name)\n- `workspace unbind` - Remove baseUrl and workspaceId from a workspace entry\n - `--workspace ` - Workspace to unbind\n- `workspace fork [workspace_name:string] [workspace_id:string]` - Create a forked workspace\n - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.\n - `--color ` - Workspace color (hex code, e.g. #ff0000)\n - `--datatable-behavior ` - How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt)\n - `-y --yes` - Skip interactive prompts (defaults datatable behavior to 'skip')\n- `workspace delete-fork ` - Delete a forked workspace and git branch\n - `-y --yes` - Skip confirmation prompt\n- `workspace merge` - Compare and deploy changes between a fork and its parent workspace\n - `--direction ` - Deploy direction: to-parent or to-fork\n - `--all` - Deploy all changed items including conflicts\n - `--skip-conflicts` - Skip items modified in both workspaces\n - `--include ` - Comma-separated kind:path items to include (e.g. script:f/test/main,flow:f/my/flow)\n - `--exclude ` - Comma-separated kind:path items to exclude\n - `--preserve-on-behalf-of` - Preserve original on_behalf_of/permissioned_as values\n - `-y --yes` - Non-interactive mode (deploy without prompts)\n- `workspace connect-slack` - Non-interactively connect Slack to the active workspace using a pre-minted bot token (xoxb-...). Produces the same artifacts as the UI OAuth flow: workspace_settings fields, g/slack group, f/slack_bot folder, and the encrypted bot token variable + resource at f/slack_bot/bot_token.\n - `--bot-token ` - Slack bot token (xoxb-...)\n - `--team-id ` - Slack team id\n - `--team-name ` - Slack team name\n- `workspace disconnect-slack`\n\n"; export declare const LANG_BASH = "# Bash\n\n## Structure\n\nDo not include `#!/bin/bash`. Arguments are obtained as positional parameters:\n\n```bash\n# Get arguments\nvar1=\"$1\"\nvar2=\"$2\"\n\necho \"Processing $var1 and $var2\"\n\n# Return JSON by echoing to stdout\necho \"{\\\"result\\\": \\\"$var1\\\", \\\"count\\\": $var2}\"\n```\n\n**Important:**\n- Do not include shebang (`#!/bin/bash`)\n- Arguments are always strings\n- Access with `$1`, `$2`, etc.\n\n## Output\n\nThe script output is captured as the result. For structured data, output valid JSON:\n\n```bash\nname=\"$1\"\ncount=\"$2\"\n\n# Output JSON result\ncat << EOF\n{\n \"name\": \"$name\",\n \"count\": $count,\n \"timestamp\": \"$(date -Iseconds)\"\n}\nEOF\n```\n\n## Environment Variables\n\nEnvironment variables set in Windmill are available:\n\n```bash\n# Access environment variable\necho \"Workspace: $WM_WORKSPACE\"\necho \"Job ID: $WM_JOB_ID\"\n```\n"; export declare const LANG_BIGQUERY = "# BigQuery\n\nArguments use `@name` syntax.\n\nName the parameters by adding comments before the statement:\n\n```sql\n-- @name1 (string)\n-- @name2 (int64) = 0\nSELECT * FROM users WHERE name = @name1 AND age > @name2;\n```\n\n## Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for\nit, downloads the file, and binds it as a `STRING` JSON parameter \u2014 Parquet/CSV\nfiles are decoded server-side into a JSON array of records, JSON/JSONL pass\nthrough. Consume with `JSON_EXTRACT_ARRAY` / `JSON_VALUE`:\n\n```sql\n-- @file (s3object)\nSELECT\n CAST(JSON_VALUE(row, '$.id') AS INT64) AS id,\n JSON_VALUE(row, '$.name') AS name\nFROM UNNEST(JSON_EXTRACT_ARRAY(@file)) AS row;\n```\n\n## Streaming query results to S3\n\nAdd a `-- s3` directive at the top of the script to stream the result set to S3\ninstead of returning rows. Windmill writes the file and returns its `S3Object`\nas the script result.\n\n```sql\n-- s3 prefix=exports/users format=parquet\nSELECT id, name FROM users;\n```\n\nAll keys are optional: `prefix` (object key prefix), `storage` (named storage \u2014\nomit to use the workspace default), `format` (`json` (default), `parquet`, or\n`csv`). Use this for large result sets \u2014 rows stream directly to S3 instead of\nbeing buffered, bypassing the 10000-row return cap.\n"; export declare const LANG_BUN = "# TypeScript (Bun)\n\nBun runtime with full npm ecosystem and fastest execution.\n\n## Structure\n\nExport a single **async** function called `main`:\n\n```typescript\nexport async function main(param1: string, param2: number) {\n // Your code here\n return { result: param1, count: param2 };\n}\n```\n\nDo not call the main function. Libraries are installed automatically.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nUse the `RT` namespace for resource types:\n\n```typescript\nexport async function main(stripe: RT.Stripe) {\n // stripe contains API key and config from the resource\n}\n```\n\nOnly use resource types if you need them to satisfy the instructions. Always use the RT namespace.\n\nBefore using a resource type, check the `rt.d.ts` file in the project root to see all available resource types and their fields. This file is generated by `wmill resource-type generate-namespace`.\n\n## Imports\n\n```typescript\nimport Stripe from \"stripe\";\nimport { someFunction } from \"some-package\";\n```\n\n## Windmill Client\n\nImport the windmill client for platform interactions:\n\n```typescript\nimport * as wmill from \"windmill-client\";\n```\n\nSee the SDK documentation for available methods.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```typescript\ntype Event = {\n kind:\n | \"webhook\"\n | \"http\"\n | \"websocket\"\n | \"kafka\"\n | \"email\"\n | \"nats\"\n | \"postgres\"\n | \"sqs\"\n | \"mqtt\"\n | \"gcp\";\n body: any;\n headers: Record;\n query: Record;\n};\n\nexport async function preprocessor(event: Event) {\n return {\n param1: event.body.field1,\n param2: event.query.id,\n };\n}\n```\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations. The `wmill.S3Object` type covers both the `s3://storage/key` URI form (`s3:///key` for the workspace default storage) and the `{ s3, storage? }` record form \u2014 always use it instead of redefining your own.\n\n### Receiving an S3Object as a script parameter\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\nexport async function main(file: wmill.S3Object) {\n const content = await wmill.loadS3File(file);\n // ...\n}\n```\n\n### S3 operations\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\n// Load file content from S3\nconst content: Uint8Array = await wmill.loadS3File(s3object);\n\n// Load file as stream\nconst blob: Blob = await wmill.loadS3FileStream(s3object);\n\n// Write file to S3\nconst result: wmill.S3Object = await wmill.writeS3File(\n s3object, // Target path (or undefined to auto-generate)\n fileContent, // string or Blob\n s3ResourcePath // Optional: specific S3 resource to use\n);\n```\n"; diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index fc8d0b4f28..03ae52b41b 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1165,9 +1165,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -1178,8 +1179,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -1189,8 +1192,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * \`\`\` + * + * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -1200,8 +1205,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * \`\`\` + * + * @param workspace - Workspace to write to (defaults to the \`WM_WORKSPACE\` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * \`\`\`typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * \`\`\` + * + * @param s3object - S3 object identifying the file to delete (must have \`s3\` set) + * @param workspace - Workspace to delete from (defaults to the \`WM_WORKSPACE\` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index 1dbd19aee6..b7b97326a2 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -1641,9 +1641,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -1654,8 +1655,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -1665,8 +1668,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * ``` + * + * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -1676,8 +1681,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * ``` + * + * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * ```typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * ``` + * + * @param s3object - S3 object identifying the file to delete (must have `s3` set) + * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps diff --git a/system_prompts/auto-generated/sdks/typescript.md b/system_prompts/auto-generated/sdks/typescript.md index 42c8a8e0ba..b2da413851 100644 --- a/system_prompts/auto-generated/sdks/typescript.md +++ b/system_prompts/auto-generated/sdks/typescript.md @@ -236,9 +236,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -249,8 +250,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -260,8 +263,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * ``` + * + * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -271,8 +276,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * ``` + * + * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * ```typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * ``` + * + * @param s3object - S3 object identifying the file to delete (must have `s3` set) + * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps diff --git a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md index 16682683cc..5e80f99795 100644 --- a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md @@ -391,9 +391,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -404,8 +405,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -415,8 +418,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * ``` + * + * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -426,8 +431,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * ``` + * + * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * ```typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * ``` + * + * @param s3object - S3 object identifying the file to delete (must have `s3` set) + * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps diff --git a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md index 2fc5913b46..89b1bc6f05 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -389,9 +389,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -402,8 +403,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -413,8 +416,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * ``` + * + * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -424,8 +429,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * ``` + * + * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * ```typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * ``` + * + * @param s3object - S3 object identifying the file to delete (must have `s3` set) + * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps diff --git a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md index c627f1bbc8..179392981f 100644 --- a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md @@ -395,9 +395,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -408,8 +409,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -419,8 +422,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * ``` + * + * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -430,8 +435,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * ``` + * + * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * ```typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * ``` + * + * @param s3object - S3 object identifying the file to delete (must have `s3` set) + * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps diff --git a/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md b/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md index 7c0ea92a17..42ad9448a8 100644 --- a/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md @@ -355,9 +355,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -368,8 +369,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -379,8 +382,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * ``` + * + * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -390,8 +395,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * ``` + * + * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * ```typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * ``` + * + * @param s3object - S3 object identifying the file to delete (must have `s3` set) + * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps diff --git a/typescript-client/build.jsr.sh b/typescript-client/build.jsr.sh index ee833f67a6..6dd8968b8f 100755 --- a/typescript-client/build.jsr.sh +++ b/typescript-client/build.jsr.sh @@ -15,6 +15,6 @@ cp "${script_dirpath}/sqlUtils.ts" "${script_dirpath}/src/" echo "" >> "${script_dirpath}/src/index.ts" echo 'export type { DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts" echo "" >> "${script_dirpath}/src/index.ts" -echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI } from "./client";' >> "${script_dirpath}/src/index.ts" +echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI } from "./client";' >> "${script_dirpath}/src/index.ts" diff --git a/typescript-client/build.sh b/typescript-client/build.sh index b270e966c3..beb44f448c 100755 --- a/typescript-client/build.sh +++ b/typescript-client/build.sh @@ -40,7 +40,7 @@ cp "${script_dirpath}/sqlUtils.ts" "${script_dirpath}/src/" echo "" >> "${script_dirpath}/src/index.ts" echo 'export type { DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts" echo "" >> "${script_dirpath}/src/index.ts" -echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, taskScript, taskFlow, workflow, step, sleep, parallel, waitForApproval, type TaskOptions, WorkflowCtx, _workflowCtx, setWorkflowCtx, StepSuspend, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, type Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI, commitKafkaOffsets } from "./client";' >> "${script_dirpath}/src/index.ts" +echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, taskScript, taskFlow, workflow, step, sleep, parallel, waitForApproval, type TaskOptions, WorkflowCtx, _workflowCtx, setWorkflowCtx, StepSuspend, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, type Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI, commitKafkaOffsets } from "./client";' >> "${script_dirpath}/src/index.ts" # Build default export by combining client utilities + services # This preserves backward compatibility for `import wmill from "windmill-client"` @@ -63,6 +63,7 @@ import { loadS3FileStream, loadS3File, writeS3File, + deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, @@ -148,6 +149,7 @@ const wmill = { loadS3FileStream, loadS3File, writeS3File, + deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, diff --git a/typescript-client/client.ts b/typescript-client/client.ts index ce40bd07e8..f388ff6457 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -805,14 +805,15 @@ export async function databaseUrlFromResource(path: string): Promise { /** * Get S3 client settings from a resource or workspace default * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined) + * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) * @returns S3 client configuration settings */ export async function denoS3LightClientSettings( - s3_resource_path: string | undefined + s3_resource_path: string | undefined, + workspace: string | undefined = undefined ): Promise { - const workspace = getWorkspace(); const s3Resource = await HelpersService.s3ResourceInfo({ - workspace: workspace, + workspace: workspace ?? getWorkspace(), requestBody: { s3_resource_path: parseResourceSyntax(s3_resource_path) ?? s3_resource_path, @@ -833,12 +834,19 @@ export async function denoS3LightClientSettings( * const text = new TextDecoder().decode(fileContentStream) * console.log(text); * ``` + * + * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) */ export async function loadS3File( s3object: S3Object, - s3ResourcePath: string | undefined = undefined + s3ResourcePath: string | undefined = undefined, + workspace: string | undefined = undefined ): Promise { - const fileContentBlob = await loadS3FileStream(s3object, s3ResourcePath); + const fileContentBlob = await loadS3FileStream( + s3object, + s3ResourcePath, + workspace + ); if (fileContentBlob === undefined) { return undefined; } @@ -874,10 +882,13 @@ export async function loadS3File( * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * ``` + * + * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) */ export async function loadS3FileStream( s3object: S3Object, - s3ResourcePath: string | undefined = undefined + s3ResourcePath: string | undefined = undefined, + workspace: string | undefined = undefined ): Promise { let s3Obj = s3object && parseS3Object(s3object); let params: Record = {}; @@ -889,12 +900,11 @@ export async function loadS3FileStream( params["storage"] = s3Obj.storage; } const queryParams = new URLSearchParams(params); + const w = workspace ?? getWorkspace(); // We use raw fetch here b/c OpenAPI generated client doesn't handle Blobs nicely const response = await fetch( - `${ - OpenAPI.BASE - }/w/${getWorkspace()}/job_helpers/download_s3_file?${queryParams}`, + `${OpenAPI.BASE}/w/${w}/job_helpers/download_s3_file?${queryParams}`, { method: "GET", headers: { @@ -922,13 +932,16 @@ export async function loadS3FileStream( * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * ``` + * + * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var) */ export async function writeS3File( s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, - contentDisposition: string | undefined = undefined + contentDisposition: string | undefined = undefined, + workspace: string | undefined = undefined ): Promise { let fileContentBlob: Blob; if (typeof fileContent === "string") { @@ -942,7 +955,7 @@ export async function writeS3File( let s3Obj = s3object && parseS3Object(s3object); const response = await HelpersService.fileUpload({ - workspace: getWorkspace(), + workspace: workspace ?? getWorkspace(), fileKey: s3Obj?.s3, fileExtension: undefined, s3ResourcePath: s3ResourcePath, @@ -957,6 +970,31 @@ export async function writeS3File( }; } +/** + * Permanently delete a file from S3 by key. + * + * ```typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * ``` + * + * @param s3object - S3 object identifying the file to delete (must have `s3` set) + * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var) + */ +export async function deleteS3File( + s3object: S3Object, + workspace: string | undefined = undefined +): Promise { + const s3Obj = parseS3Object(s3object); + if (!s3Obj.s3) { + throw new Error("deleteS3File: s3 key is required"); + } + await HelpersService.deleteS3File({ + workspace: workspace ?? getWorkspace(), + fileKey: s3Obj.s3, + storage: s3Obj.storage, + }); +} + /** * Sign S3 objects to be used by anonymous users in public apps * @param s3objects s3 objects to sign diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 66ecb75f68..3ec9f0e36c 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.706.1", + "version": "1.709.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index e022ff7056..9b9867899d 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.706.1", + "version": "1.709.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index b5570ce111..9fc930cbcc 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.706.1 +1.709.0