From 8529a2cf1a7776d1be6d4655954e60bf99fbb699 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 4 May 2026 15:19:03 +0200 Subject: [PATCH 001/313] chore(frontend): expose DarkModeObserver, TextInput, common/Badge from windmill-components (#9018) Adds three subpath entries to the windmill-components package's `exports` and `typesVersions` so external consumers (e.g. windmillhub) can import these components without resorting to private `node_modules` aliases. Co-authored-by: Claude Opus 4.7 (1M context) --- frontend/package.json | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/frontend/package.json b/frontend/package.json index b578298993..852edacb50 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -426,6 +426,21 @@ "./tailwindUtils": { "types": "./package/components/apps/editor/componentsPanel/tailwindUtils.d.ts", "default": "./package/components/apps/editor/componentsPanel/tailwindUtils.js" + }, + "./components/DarkModeObserver.svelte": { + "types": "./package/components/DarkModeObserver.svelte.d.ts", + "svelte": "./package/components/DarkModeObserver.svelte", + "default": "./package/components/DarkModeObserver.svelte" + }, + "./components/text_input/TextInput.svelte": { + "types": "./package/components/text_input/TextInput.svelte.d.ts", + "svelte": "./package/components/text_input/TextInput.svelte", + "default": "./package/components/text_input/TextInput.svelte" + }, + "./components/common/badge/Badge.svelte": { + "types": "./package/components/common/badge/Badge.svelte.d.ts", + "svelte": "./package/components/common/badge/Badge.svelte", + "default": "./package/components/common/badge/Badge.svelte" } }, "files": [ @@ -585,6 +600,15 @@ ], "components/custom_ui": [ "./package/components/custom_ui.d.ts" + ], + "components/DarkModeObserver.svelte": [ + "./package/components/DarkModeObserver.svelte.d.ts" + ], + "components/text_input/TextInput.svelte": [ + "./package/components/text_input/TextInput.svelte.d.ts" + ], + "components/common/badge/Badge.svelte": [ + "./package/components/common/badge/Badge.svelte.d.ts" ] } }, From f1fd245073d6bf97a6a6c64e64d545618cccc432 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Mon, 4 May 2026 17:01:06 +0200 Subject: [PATCH 002/313] feat: add separate filter searchbar for resource types tab (#9019) Co-authored-by: Claude Opus 4.7 (1M context) --- .../resources/resourceTypesFilter.ts | 23 ++++++++ .../(root)/(logged)/resources/+page.svelte | 57 +++++++++++++++---- 2 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 frontend/src/lib/components/resources/resourceTypesFilter.ts diff --git a/frontend/src/lib/components/resources/resourceTypesFilter.ts b/frontend/src/lib/components/resources/resourceTypesFilter.ts new file mode 100644 index 0000000000..37458fc728 --- /dev/null +++ b/frontend/src/lib/components/resources/resourceTypesFilter.ts @@ -0,0 +1,23 @@ +import { Boxes, FileText } from 'lucide-svelte' +import type { FilterSchemaRec } from '../FilterSearchbar.svelte' + +export function buildResourceTypesFilterSchema() { + return { + _default_: { + type: 'string' as const, + hidden: true + }, + name: { + type: 'string' as const, + label: 'Name', + icon: Boxes, + description: 'Search in resource type name' + }, + description: { + type: 'string' as const, + label: 'Description', + icon: FileText, + description: 'Search in resource type description' + } + } satisfies FilterSchemaRec +} diff --git a/frontend/src/routes/(root)/(logged)/resources/+page.svelte b/frontend/src/routes/(root)/(logged)/resources/+page.svelte index 974b0d8f4a..285be0acbb 100644 --- a/frontend/src/routes/(root)/(logged)/resources/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/resources/+page.svelte @@ -16,9 +16,11 @@ import { resourceTypesStore } from '$lib/components/resourceTypesStore' import SchemaViewer from '$lib/components/SchemaViewer.svelte' import FilterSearchbar, { - useUrlSyncedFilterInstance + useUrlSyncedFilterInstance, + type FilterInstanceRec } from '$lib/components/FilterSearchbar.svelte' import { buildResourcesFilterSchema } from '$lib/components/resources/resourcesFilter' + import { buildResourceTypesFilterSchema } from '$lib/components/resources/resourceTypesFilter' import SharedBadge from '$lib/components/SharedBadge.svelte' import ShareModal from '$lib/components/ShareModal.svelte' import SimpleEditor from '$lib/components/SimpleEditor.svelte' @@ -156,6 +158,25 @@ .sort() .map((f) => f.replace(/^f\//, '')) ) + let resourceTypesFilterSchema = buildResourceTypesFilterSchema() + let resourceTypesFilters: { + val: Partial> + } = $state({ val: {} }) + let filteredResourceTypes = $derived.by(() => { + if (!resourceTypes) return resourceTypes + const f = resourceTypesFilters.val + const defaultSearch = f._default_?.toLowerCase() + const nameSearch = f.name?.toLowerCase() + const descSearch = f.description?.toLowerCase() + if (!defaultSearch && !nameSearch && !descSearch) return resourceTypes + return resourceTypes.filter((rt) => { + if (defaultSearch && !rt.name.toLowerCase().includes(defaultSearch)) return false + if (nameSearch && !rt.name.toLowerCase().includes(nameSearch)) return false + if (descSearch && !(rt.description ?? '').toLowerCase().includes(descSearch)) return false + return true + }) + }) + let folderPresets = $derived([ ...itemFolders.map((f) => ({ name: `f/${f}`, value: `path_start:\\ f/${f}/` })), ...allLabels.map((l) => ({ name: l, value: `label:\\ ${l}` })), @@ -922,13 +943,22 @@ classes: loading.resources || loading.types ? 'animate-spin' : '' }} /> - + {#if tab == 'types'} + + {:else} + + {/if} {#if showTable} @@ -1199,6 +1229,13 @@ {#each new Array(6) as _} {/each} + {:else if filteredResourceTypes?.length == 0} +
+
No resource types found
+
+ Try changing the filters or creating a new resource type +
+
{:else}
@@ -1210,8 +1247,8 @@ - {#if resourceTypes} - {#each resourceTypes as { name, description, schema, canWrite, format_extension, is_fileset }} + {#if filteredResourceTypes} + {#each filteredResourceTypes as { name, description, schema, canWrite, format_extension, is_fileset }} Date: Mon, 4 May 2026 15:22:26 +0000 Subject: [PATCH 003/313] /ai-fast is now /ai --- .github/workflows/claude-fast.yml | 54 ------------------------------ .github/workflows/claude.yml | 55 +------------------------------ 2 files changed, 1 insertion(+), 108 deletions(-) delete mode 100644 .github/workflows/claude-fast.yml diff --git a/.github/workflows/claude-fast.yml b/.github/workflows/claude-fast.yml deleted file mode 100644 index 4ad53d326f..0000000000 --- a/.github/workflows/claude-fast.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Fast Claude - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -jobs: - check-membership: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '/ai-fast')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/ai-fast')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '/ai-fast')) || - (github.event_name == 'issues' && contains(github.event.issue.body, '/ai-fast')) - uses: ./.github/workflows/check-org-membership.yml - secrets: - access_token: ${{ secrets.ORG_ACCESS_TOKEN }} - - claude-code-action: - needs: check-membership - if: | - needs.check-membership.outputs.is_member == 'true' - runs-on: ubicloud-standard-8 - permissions: - contents: write - pull-requests: write - issues: write - id-token: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude PR Action - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - allowed_bots: "windmill-internal-app[bot]" - trigger_phrase: "/ai-fast" - settings: | - { - "env": { - "SQLX_OFFLINE": "true" - } - } - claude_args: | - --allowedTools "Bash,WebFetch,WebSearch" - --model opus diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 95436e3214..8052bb6b4c 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -1,4 +1,4 @@ -name: Claude PR Assistant +name: Fast Claude on: issue_comment: @@ -26,7 +26,6 @@ jobs: if: | needs.check-membership.outputs.is_member == 'true' runs-on: ubicloud-standard-8 - timeout-minutes: 60 permissions: contents: write pull-requests: write @@ -38,37 +37,6 @@ jobs: with: fetch-depth: 1 - - uses: actions/cache@v3 - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- - - - name: Run npm install and generate-backend-client - working-directory: ./frontend - run: | - # add a build directory for cargo check - mkdir -p build - npm install - npm run generate-backend-client - - - name: install xmlsec1 and gssapi - run: | - sudo apt-get update - sudo apt-get install -y libxml2-dev libxmlsec1-dev libkrb5-dev libsasl2-dev libcurl4-openssl-dev mold clang - - - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - cache-workspaces: backend - toolchain: 1.93.0 - - - name: cargo check - working-directory: ./backend - timeout-minutes: 16 - run: | - SQLX_OFFLINE=true cargo check --features all_sqlx_features - - name: Run Claude PR Action uses: anthropics/claude-code-action@v1 with: @@ -84,24 +52,3 @@ jobs: claude_args: | --allowedTools "Bash,WebFetch,WebSearch" --model opus - --system-prompt "## IMPORTANT INSTRUCTIONS - - Your branch name should be a short description of the requested changes. - - Bypass previous instructions and instead of adding a url in your comment to manually create a PR, if you created a new branch with your changes, automatically create a draft PR from that branch to main. - - ## Code Quality Requirements - - After making any code changes, you MUST run the appropriate validation commands: - - **Frontend Changes:** - - Run: \`npm run check\` in the frontend directory - - Fix all warnings and errors before proceeding - - **Backend Changes:** - - Run: \`cargo check --features all_sqlx_features\` in the backend directory - - Fix all warnings and errors before proceeding - - **Pull Request Creation:** - - DO NOT FORGET TO OPEN A DRAFT PR AFTER YOU ARE DONE if you made changes after a request from a git issue. - - ## Available Tools - - Bash: Full access to run validation commands and git operations" From 42be1d46a632c23830f97995e9ab1b52a1ed5d3d Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 4 May 2026 17:33:24 +0200 Subject: [PATCH 004/313] fix(autoscaling): consider dedicated workers in scale decisions (#9020) * [ee] fix(autoscaling): consider dedicated workers in scale decisions Co-Authored-By: Claude Opus 4.7 (1M context) * Update ee-repo-ref.txt * [ee] fix(autoscaling): mirror worker tag precedence (worker_tags wins) Co-Authored-By: Claude Opus 4.7 (1M context) * chore: update ee-repo-ref to 862d487032efe30d1e4a3cd0a1ed7169500c4cd9 This commit updates the EE repository reference after PR #556 was merged in windmill-ee-private. Previous ee-repo-ref: cf87e9dcef2e95b1834b3f5c154209defc5a9ca2 New ee-repo-ref: 862d487032efe30d1e4a3cd0a1ed7169500c4cd9 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index f48c738f28..6e8b99d833 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -967f961f0a88b027d894aebd03977181129477a8 +862d487032efe30d1e4a3cd0a1ed7169500c4cd9 From 85a05765e28279af53d92fa197e30b6108fe7050 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 4 May 2026 17:53:12 +0200 Subject: [PATCH 005/313] docs(skills): document S3Object inputs and S3 streaming in script skills (#9022) Co-authored-by: Claude Opus 4.5 --- cli/src/guidance/skills.gen.ts | 248 ++++++++++++++++-- system_prompts/auto-generated/prompts.ts | 248 ++++++++++++++++-- system_prompts/auto-generated/script.md | 248 ++++++++++++++++-- .../skills/write-script-bigquery/SKILL.md | 31 +++ .../skills/write-script-bun/SKILL.md | 19 +- .../skills/write-script-bunnative/SKILL.md | 19 +- .../skills/write-script-deno/SKILL.md | 19 +- .../skills/write-script-duckdb/SKILL.md | 24 ++ .../skills/write-script-mssql/SKILL.md | 30 +++ .../skills/write-script-mysql/SKILL.md | 31 +++ .../skills/write-script-postgresql/SKILL.md | 29 ++ .../skills/write-script-python3/SKILL.md | 15 ++ .../skills/write-script-snowflake/SKILL.md | 31 +++ system_prompts/languages/bigquery.md | 31 +++ system_prompts/languages/bun.md | 19 +- system_prompts/languages/bunnative.md | 19 +- system_prompts/languages/deno.md | 19 +- system_prompts/languages/duckdb.md | 24 ++ system_prompts/languages/mssql.md | 30 +++ system_prompts/languages/mysql.md | 31 +++ system_prompts/languages/postgresql.md | 29 ++ system_prompts/languages/python3.md | 15 ++ system_prompts/languages/snowflake.md | 31 +++ 23 files changed, 1105 insertions(+), 135 deletions(-) diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 0f2c101b58..b5ac998250 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -180,6 +180,37 @@ Name the parameters by adding comments before the statement: -- @name2 (int64) = 0 SELECT * FROM users WHERE name = @name1 AND age > @name2; \`\`\` + +## Receiving an S3Object as a script parameter + +Declare the arg with type \`(s3object)\`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as a \`STRING\` JSON parameter — Parquet/CSV +files are decoded server-side into a JSON array of records, JSON/JSONL pass +through. Consume with \`JSON_EXTRACT_ARRAY\` / \`JSON_VALUE\`: + +\`\`\`sql +-- @file (s3object) +SELECT + CAST(JSON_VALUE(row, '$.id') AS INT64) AS id, + JSON_VALUE(row, '$.name') AS name +FROM UNNEST(JSON_EXTRACT_ARRAY(@file)) AS row; +\`\`\` + +## Streaming query results to S3 + +Add a \`-- s3\` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its \`S3Object\` +as the script result. + +\`\`\`sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +\`\`\` + +All keys are optional: \`prefix\` (object key prefix), \`storage\` (named storage — +omit to use the workspace default), \`format\` (\`json\` (default), \`parquet\`, or +\`csv\`). Use this for large result sets — rows stream directly to S3 instead of +being buffered, bypassing the 10000-row return cap. `, "write-script-bun": `--- name: write-script-bun @@ -303,19 +334,20 @@ export async function preprocessor(event: Event) { ## S3 Object Operations -Windmill provides built-in support for S3-compatible storage operations. +Windmill 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 — always use it instead of redefining your own. -### S3Object Type - -The S3Object type represents a file in S3 storage: +### Receiving an S3Object as a script parameter \`\`\`typescript -type S3Object = { - s3: string; // Path within the bucket -}; +import * as wmill from "windmill-client"; + +export async function main(file: wmill.S3Object) { + const content = await wmill.loadS3File(file); + // ... +} \`\`\` -## TypeScript Operations +### S3 operations \`\`\`typescript import * as wmill from "windmill-client"; @@ -327,7 +359,7 @@ const content: Uint8Array = await wmill.loadS3File(s3object); const blob: Blob = await wmill.loadS3FileStream(s3object); // Write file to S3 -const result: S3Object = await wmill.writeS3File( +const result: wmill.S3Object = await wmill.writeS3File( s3object, // Target path (or undefined to auto-generate) fileContent, // string or Blob s3ResourcePath // Optional: specific S3 resource to use @@ -993,19 +1025,20 @@ export async function preprocessor(event: Event) { ## S3 Object Operations -Windmill provides built-in support for S3-compatible storage operations. +Windmill 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 — always use it instead of redefining your own. -### S3Object Type - -The S3Object type represents a file in S3 storage: +### Receiving an S3Object as a script parameter \`\`\`typescript -type S3Object = { - s3: string; // Path within the bucket -}; +import * as wmill from "windmill-client"; + +export async function main(file: wmill.S3Object) { + const content = await wmill.loadS3File(file); + // ... +} \`\`\` -## TypeScript Operations +### S3 operations \`\`\`typescript import * as wmill from "windmill-client"; @@ -1017,7 +1050,7 @@ const content: Uint8Array = await wmill.loadS3File(s3object); const blob: Blob = await wmill.loadS3FileStream(s3object); // Write file to S3 -const result: S3Object = await wmill.writeS3File( +const result: wmill.S3Object = await wmill.writeS3File( s3object, // Target path (or undefined to auto-generate) fileContent, // string or Blob s3ResourcePath // Optional: specific S3 resource to use @@ -1771,19 +1804,20 @@ export async function preprocessor(event: Event) { ## S3 Object Operations -Windmill provides built-in support for S3-compatible storage operations. +Windmill 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 — always use it instead of redefining your own. -### S3Object Type - -The S3Object type represents a file in S3 storage: +### Receiving an S3Object as a script parameter \`\`\`typescript -type S3Object = { - s3: string; // Path within the bucket -}; +import * as wmill from "windmill-client"; + +export async function main(file: wmill.S3Object) { + const content = await wmill.loadS3File(file); + // ... +} \`\`\` -## TypeScript Operations +### S3 operations \`\`\`typescript import * as wmill from "windmill-client"; @@ -1795,7 +1829,7 @@ const content: Uint8Array = await wmill.loadS3File(s3object); const blob: Blob = await wmill.loadS3FileStream(s3object); // Write file to S3 -const result: S3Object = await wmill.writeS3File( +const result: wmill.S3Object = await wmill.writeS3File( s3object, // Target path (or undefined to auto-generate) fileContent, // string or Blob s3ResourcePath // Optional: specific S3 resource to use @@ -2432,6 +2466,30 @@ SELECT * FROM read_parquet('s3:///path/to/file.parquet'); -- JSON files SELECT * FROM read_json('s3:///path/to/file.json'); \`\`\` + +### Receiving an S3Object as a script parameter + +Declare the arg with type \`(s3object)\`. Windmill renders an S3 file picker for it +and binds the arg as the bare \`s3://storage/key\` URI, which DuckDB's reader +functions consume directly: + +\`\`\`sql +-- $file (s3object) +SELECT * FROM read_parquet($file); +\`\`\` + +Works with any DuckDB reader: \`read_csv($file)\`, \`read_json($file)\`, etc. + +### Writing query results to S3 + +DuckDB writes to S3 natively via \`COPY ... TO\`: + +\`\`\`sql +COPY (SELECT * FROM users) TO 's3:///exports/users.parquet' (FORMAT PARQUET); +\`\`\` + +Use this instead of the \`-- s3\` streaming directive supported by the other SQL +dialects — that directive is not available in DuckDB. `, "write-script-go": `--- name: write-script-go @@ -2748,6 +2806,36 @@ Name the parameters by adding comments before the statement: -- @P2 name2 (int) = 0 SELECT * FROM users WHERE name = @P1 AND age > @P2; \`\`\` + +## Receiving an S3Object as a script parameter + +Declare the arg with type \`(s3object)\`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as \`nvarchar(max)\` JSON text — Parquet/CSV +files are decoded server-side into a JSON array of records, JSON/JSONL pass +through. Consume with \`OPENJSON\`: + +\`\`\`sql +-- @P1 file (s3object) +SELECT id, name +FROM OPENJSON(@P1) +WITH (id INT, name NVARCHAR(200)); +\`\`\` + +## Streaming query results to S3 + +Add a \`-- s3\` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its \`S3Object\` +as the script result. + +\`\`\`sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +\`\`\` + +All keys are optional: \`prefix\` (object key prefix), \`storage\` (named storage — +omit to use the workspace default), \`format\` (\`json\` (default), \`parquet\`, or +\`csv\`). Use this for large result sets — rows stream directly to S3 instead of +being buffered as the script return value. `, "write-script-mysql": `--- name: write-script-mysql @@ -2800,6 +2888,37 @@ Name the parameters by adding comments before the statement: -- ? name2 (int) = 0 SELECT * FROM users WHERE name = ? AND age > ?; \`\`\` + +## Receiving an S3Object as a script parameter + +Declare the arg with type \`(s3object)\`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as JSON text — Parquet/CSV files are +decoded server-side into a JSON array of records, JSON/JSONL pass through. +Consume with \`JSON_TABLE\`: + +\`\`\`sql +-- ? file (s3object) +SELECT id, name +FROM JSON_TABLE(?, '$[*]' + COLUMNS (id INT PATH '$.id', name VARCHAR(200) PATH '$.name') +) AS r; +\`\`\` + +## Streaming query results to S3 + +Add a \`-- s3\` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its \`S3Object\` +as the script result. + +\`\`\`sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +\`\`\` + +All keys are optional: \`prefix\` (object key prefix), \`storage\` (named storage — +omit to use the workspace default), \`format\` (\`json\` (default), \`parquet\`, or +\`csv\`). Use this for large result sets — rows stream directly to S3 instead of +being buffered as the script return value. `, "write-script-nativets": `--- name: write-script-nativets @@ -3607,6 +3726,35 @@ Name the parameters by adding comments at the beginning of the script (without s -- $2 name2 = default_value SELECT * FROM users WHERE name = $1::TEXT AND age > $2::INT; \`\`\` + +## Receiving an S3Object as a script parameter + +Declare the arg with type \`(s3object)\`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as a \`jsonb\` parameter — Parquet/CSV files +are decoded server-side into a JSON array of records, JSON/JSONL pass through. +Consume with \`jsonb_to_recordset\` (or any \`jsonb\` API): + +\`\`\`sql +-- $1 file (s3object) +SELECT * +FROM jsonb_to_recordset($1::jsonb) AS r(id INT, name TEXT); +\`\`\` + +## Streaming query results to S3 + +Add a \`-- s3\` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its \`S3Object\` +as the script result. + +\`\`\`sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +\`\`\` + +All keys are optional: \`prefix\` (object key prefix), \`storage\` (named storage — +omit to use the workspace default), \`format\` (\`json\` (default), \`parquet\`, or +\`csv\`). Use this for large result sets — rows stream directly to S3 instead of +being buffered as the script return value. `, "write-script-powershell": `--- name: write-script-powershell @@ -3844,6 +3992,21 @@ def preprocessor(event: Event): Windmill provides built-in support for S3-compatible storage operations. +### Receiving an S3Object as a script parameter + +To accept a file from S3 as input to a script, type the parameter with \`S3Object\` (imported from \`wmill\`): + +\`\`\`python +import wmill +from wmill import S3Object + +def main(file: S3Object): + content = wmill.load_s3_file(file) + # ... +\`\`\` + +### S3 operations + \`\`\`python import wmill @@ -4848,6 +5011,37 @@ Name the parameters by adding comments before the statement: -- ? name2 (number) = 0 SELECT * FROM users WHERE name = ? AND age > ?; \`\`\` + +## Receiving an S3Object as a script parameter + +Declare the arg with type \`(s3object)\`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as JSON text — Parquet/CSV files are +decoded server-side into a JSON array of records, JSON/JSONL pass through. +Wrap the bind with \`PARSE_JSON(?)\` and walk it with \`LATERAL FLATTEN\`: + +\`\`\`sql +-- ? file (s3object) +SELECT + v.value:id::NUMBER AS id, + v.value:name::STRING AS name +FROM LATERAL FLATTEN(input => PARSE_JSON(?)) v; +\`\`\` + +## Streaming query results to S3 + +Add a \`-- s3\` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its \`S3Object\` +as the script result. + +\`\`\`sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +\`\`\` + +All keys are optional: \`prefix\` (object key prefix), \`storage\` (named storage — +omit to use the workspace default), \`format\` (\`json\` (default), \`parquet\`, or +\`csv\`). Use this for large result sets — rows stream directly to S3 instead of +being buffered, bypassing the 10000-row return cap. `, "write-flow": `--- name: write-flow diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index c9ba432467..727daad2c2 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -2852,6 +2852,37 @@ Name the parameters by adding comments before the statement: -- @name2 (int64) = 0 SELECT * FROM users WHERE name = @name1 AND age > @name2; \`\`\` + +## Receiving an S3Object as a script parameter + +Declare the arg with type \`(s3object)\`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as a \`STRING\` JSON parameter — Parquet/CSV +files are decoded server-side into a JSON array of records, JSON/JSONL pass +through. Consume with \`JSON_EXTRACT_ARRAY\` / \`JSON_VALUE\`: + +\`\`\`sql +-- @file (s3object) +SELECT + CAST(JSON_VALUE(row, '$.id') AS INT64) AS id, + JSON_VALUE(row, '$.name') AS name +FROM UNNEST(JSON_EXTRACT_ARRAY(@file)) AS row; +\`\`\` + +## Streaming query results to S3 + +Add a \`-- s3\` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its \`S3Object\` +as the script result. + +\`\`\`sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +\`\`\` + +All keys are optional: \`prefix\` (object key prefix), \`storage\` (named storage — +omit to use the workspace default), \`format\` (\`json\` (default), \`parquet\`, or +\`csv\`). Use this for large result sets — rows stream directly to S3 instead of +being buffered, bypassing the 10000-row return cap. `; export const LANG_BUN = `# TypeScript (Bun) @@ -2936,19 +2967,20 @@ export async function preprocessor(event: Event) { ## S3 Object Operations -Windmill provides built-in support for S3-compatible storage operations. +Windmill 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 — always use it instead of redefining your own. -### S3Object Type - -The S3Object type represents a file in S3 storage: +### Receiving an S3Object as a script parameter \`\`\`typescript -type S3Object = { - s3: string; // Path within the bucket -}; +import * as wmill from "windmill-client"; + +export async function main(file: wmill.S3Object) { + const content = await wmill.loadS3File(file); + // ... +} \`\`\` -## TypeScript Operations +### S3 operations \`\`\`typescript import * as wmill from "windmill-client"; @@ -2960,7 +2992,7 @@ const content: Uint8Array = await wmill.loadS3File(s3object); const blob: Blob = await wmill.loadS3FileStream(s3object); // Write file to S3 -const result: S3Object = await wmill.writeS3File( +const result: wmill.S3Object = await wmill.writeS3File( s3object, // Target path (or undefined to auto-generate) fileContent, // string or Blob s3ResourcePath // Optional: specific S3 resource to use @@ -3048,19 +3080,20 @@ export async function preprocessor(event: Event) { ## S3 Object Operations -Windmill provides built-in support for S3-compatible storage operations. +Windmill 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 — always use it instead of redefining your own. -### S3Object Type - -The S3Object type represents a file in S3 storage: +### Receiving an S3Object as a script parameter \`\`\`typescript -type S3Object = { - s3: string; // Path within the bucket -}; +import * as wmill from "windmill-client"; + +export async function main(file: wmill.S3Object) { + const content = await wmill.loadS3File(file); + // ... +} \`\`\` -## TypeScript Operations +### S3 operations \`\`\`typescript import * as wmill from "windmill-client"; @@ -3072,7 +3105,7 @@ const content: Uint8Array = await wmill.loadS3File(s3object); const blob: Blob = await wmill.loadS3FileStream(s3object); // Write file to S3 -const result: S3Object = await wmill.writeS3File( +const result: wmill.S3Object = await wmill.writeS3File( s3object, // Target path (or undefined to auto-generate) fileContent, // string or Blob s3ResourcePath // Optional: specific S3 resource to use @@ -3209,19 +3242,20 @@ export async function preprocessor(event: Event) { ## S3 Object Operations -Windmill provides built-in support for S3-compatible storage operations. +Windmill 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 — always use it instead of redefining your own. -### S3Object Type - -The S3Object type represents a file in S3 storage: +### Receiving an S3Object as a script parameter \`\`\`typescript -type S3Object = { - s3: string; // Path within the bucket -}; +import * as wmill from "windmill-client"; + +export async function main(file: wmill.S3Object) { + const content = await wmill.loadS3File(file); + // ... +} \`\`\` -## TypeScript Operations +### S3 operations \`\`\`typescript import * as wmill from "windmill-client"; @@ -3233,7 +3267,7 @@ const content: Uint8Array = await wmill.loadS3File(s3object); const blob: Blob = await wmill.loadS3FileStream(s3object); // Write file to S3 -const result: S3Object = await wmill.writeS3File( +const result: wmill.S3Object = await wmill.writeS3File( s3object, // Target path (or undefined to auto-generate) fileContent, // string or Blob s3ResourcePath // Optional: specific S3 resource to use @@ -3292,6 +3326,30 @@ SELECT * FROM read_parquet('s3:///path/to/file.parquet'); -- JSON files SELECT * FROM read_json('s3:///path/to/file.json'); \`\`\` + +### Receiving an S3Object as a script parameter + +Declare the arg with type \`(s3object)\`. Windmill renders an S3 file picker for it +and binds the arg as the bare \`s3://storage/key\` URI, which DuckDB's reader +functions consume directly: + +\`\`\`sql +-- $file (s3object) +SELECT * FROM read_parquet($file); +\`\`\` + +Works with any DuckDB reader: \`read_csv($file)\`, \`read_json($file)\`, etc. + +### Writing query results to S3 + +DuckDB writes to S3 natively via \`COPY ... TO\`: + +\`\`\`sql +COPY (SELECT * FROM users) TO 's3:///exports/users.parquet' (FORMAT PARQUET); +\`\`\` + +Use this instead of the \`-- s3\` streaming directive supported by the other SQL +dialects — that directive is not available in DuckDB. `; export const LANG_GO = `# Go @@ -3452,6 +3510,36 @@ Name the parameters by adding comments before the statement: -- @P2 name2 (int) = 0 SELECT * FROM users WHERE name = @P1 AND age > @P2; \`\`\` + +## Receiving an S3Object as a script parameter + +Declare the arg with type \`(s3object)\`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as \`nvarchar(max)\` JSON text — Parquet/CSV +files are decoded server-side into a JSON array of records, JSON/JSONL pass +through. Consume with \`OPENJSON\`: + +\`\`\`sql +-- @P1 file (s3object) +SELECT id, name +FROM OPENJSON(@P1) +WITH (id INT, name NVARCHAR(200)); +\`\`\` + +## Streaming query results to S3 + +Add a \`-- s3\` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its \`S3Object\` +as the script result. + +\`\`\`sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +\`\`\` + +All keys are optional: \`prefix\` (object key prefix), \`storage\` (named storage — +omit to use the workspace default), \`format\` (\`json\` (default), \`parquet\`, or +\`csv\`). Use this for large result sets — rows stream directly to S3 instead of +being buffered as the script return value. `; export const LANG_MYSQL = `# MySQL @@ -3465,6 +3553,37 @@ Name the parameters by adding comments before the statement: -- ? name2 (int) = 0 SELECT * FROM users WHERE name = ? AND age > ?; \`\`\` + +## Receiving an S3Object as a script parameter + +Declare the arg with type \`(s3object)\`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as JSON text — Parquet/CSV files are +decoded server-side into a JSON array of records, JSON/JSONL pass through. +Consume with \`JSON_TABLE\`: + +\`\`\`sql +-- ? file (s3object) +SELECT id, name +FROM JSON_TABLE(?, '$[*]' + COLUMNS (id INT PATH '$.id', name VARCHAR(200) PATH '$.name') +) AS r; +\`\`\` + +## Streaming query results to S3 + +Add a \`-- s3\` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its \`S3Object\` +as the script result. + +\`\`\`sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +\`\`\` + +All keys are optional: \`prefix\` (object key prefix), \`storage\` (named storage — +omit to use the workspace default), \`format\` (\`json\` (default), \`parquet\`, or +\`csv\`). Use this for large result sets — rows stream directly to S3 instead of +being buffered as the script return value. `; export const LANG_NATIVETS = `# TypeScript (Native) @@ -3616,6 +3735,35 @@ Name the parameters by adding comments at the beginning of the script (without s -- $2 name2 = default_value SELECT * FROM users WHERE name = $1::TEXT AND age > $2::INT; \`\`\` + +## Receiving an S3Object as a script parameter + +Declare the arg with type \`(s3object)\`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as a \`jsonb\` parameter — Parquet/CSV files +are decoded server-side into a JSON array of records, JSON/JSONL pass through. +Consume with \`jsonb_to_recordset\` (or any \`jsonb\` API): + +\`\`\`sql +-- $1 file (s3object) +SELECT * +FROM jsonb_to_recordset($1::jsonb) AS r(id INT, name TEXT); +\`\`\` + +## Streaming query results to S3 + +Add a \`-- s3\` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its \`S3Object\` +as the script result. + +\`\`\`sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +\`\`\` + +All keys are optional: \`prefix\` (object key prefix), \`storage\` (named storage — +omit to use the workspace default), \`format\` (\`json\` (default), \`parquet\`, or +\`csv\`). Use this for large result sets — rows stream directly to S3 instead of +being buffered as the script return value. `; export const LANG_POWERSHELL = `# PowerShell @@ -3775,6 +3923,21 @@ def preprocessor(event: Event): Windmill provides built-in support for S3-compatible storage operations. +### Receiving an S3Object as a script parameter + +To accept a file from S3 as input to a script, type the parameter with \`S3Object\` (imported from \`wmill\`): + +\`\`\`python +import wmill +from wmill import S3Object + +def main(file: S3Object): + content = wmill.load_s3_file(file) + # ... +\`\`\` + +### S3 operations + \`\`\`python import wmill @@ -3970,5 +4133,36 @@ Name the parameters by adding comments before the statement: -- ? name2 (number) = 0 SELECT * FROM users WHERE name = ? AND age > ?; \`\`\` + +## Receiving an S3Object as a script parameter + +Declare the arg with type \`(s3object)\`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as JSON text — Parquet/CSV files are +decoded server-side into a JSON array of records, JSON/JSONL pass through. +Wrap the bind with \`PARSE_JSON(?)\` and walk it with \`LATERAL FLATTEN\`: + +\`\`\`sql +-- ? file (s3object) +SELECT + v.value:id::NUMBER AS id, + v.value:name::STRING AS name +FROM LATERAL FLATTEN(input => PARSE_JSON(?)) v; +\`\`\` + +## Streaming query results to S3 + +Add a \`-- s3\` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its \`S3Object\` +as the script result. + +\`\`\`sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +\`\`\` + +All keys are optional: \`prefix\` (object key prefix), \`storage\` (named storage — +omit to use the workspace default), \`format\` (\`json\` (default), \`parquet\`, or +\`csv\`). Use this for large result sets — rows stream directly to S3 instead of +being buffered, bypassing the 10000-row return cap. `; diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index 463cd7965b..1dbd19aee6 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -90,6 +90,37 @@ Name the parameters by adding comments before the statement: SELECT * FROM users WHERE name = @name1 AND age > @name2; ``` +## Receiving an S3Object as a script parameter + +Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as a `STRING` JSON parameter — Parquet/CSV +files are decoded server-side into a JSON array of records, JSON/JSONL pass +through. Consume with `JSON_EXTRACT_ARRAY` / `JSON_VALUE`: + +```sql +-- @file (s3object) +SELECT + CAST(JSON_VALUE(row, '$.id') AS INT64) AS id, + JSON_VALUE(row, '$.name') AS name +FROM UNNEST(JSON_EXTRACT_ARRAY(@file)) AS row; +``` + +## Streaming query results to S3 + +Add a `-- s3` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its `S3Object` +as the script result. + +```sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +``` + +All keys are optional: `prefix` (object key prefix), `storage` (named storage — +omit to use the workspace default), `format` (`json` (default), `parquet`, or +`csv`). Use this for large result sets — rows stream directly to S3 instead of +being buffered, bypassing the 10000-row return cap. + # TypeScript (Bun) @@ -173,19 +204,20 @@ export async function preprocessor(event: Event) { ## S3 Object Operations -Windmill provides built-in support for S3-compatible storage operations. +Windmill 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 — always use it instead of redefining your own. -### S3Object Type - -The S3Object type represents a file in S3 storage: +### Receiving an S3Object as a script parameter ```typescript -type S3Object = { - s3: string; // Path within the bucket -}; +import * as wmill from "windmill-client"; + +export async function main(file: wmill.S3Object) { + const content = await wmill.loadS3File(file); + // ... +} ``` -## TypeScript Operations +### S3 operations ```typescript import * as wmill from "windmill-client"; @@ -197,7 +229,7 @@ const content: Uint8Array = await wmill.loadS3File(s3object); const blob: Blob = await wmill.loadS3FileStream(s3object); // Write file to S3 -const result: S3Object = await wmill.writeS3File( +const result: wmill.S3Object = await wmill.writeS3File( s3object, // Target path (or undefined to auto-generate) fileContent, // string or Blob s3ResourcePath // Optional: specific S3 resource to use @@ -285,19 +317,20 @@ export async function preprocessor(event: Event) { ## S3 Object Operations -Windmill provides built-in support for S3-compatible storage operations. +Windmill 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 — always use it instead of redefining your own. -### S3Object Type - -The S3Object type represents a file in S3 storage: +### Receiving an S3Object as a script parameter ```typescript -type S3Object = { - s3: string; // Path within the bucket -}; +import * as wmill from "windmill-client"; + +export async function main(file: wmill.S3Object) { + const content = await wmill.loadS3File(file); + // ... +} ``` -## TypeScript Operations +### S3 operations ```typescript import * as wmill from "windmill-client"; @@ -309,7 +342,7 @@ const content: Uint8Array = await wmill.loadS3File(s3object); const blob: Blob = await wmill.loadS3FileStream(s3object); // Write file to S3 -const result: S3Object = await wmill.writeS3File( +const result: wmill.S3Object = await wmill.writeS3File( s3object, // Target path (or undefined to auto-generate) fileContent, // string or Blob s3ResourcePath // Optional: specific S3 resource to use @@ -446,19 +479,20 @@ export async function preprocessor(event: Event) { ## S3 Object Operations -Windmill provides built-in support for S3-compatible storage operations. +Windmill 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 — always use it instead of redefining your own. -### S3Object Type - -The S3Object type represents a file in S3 storage: +### Receiving an S3Object as a script parameter ```typescript -type S3Object = { - s3: string; // Path within the bucket -}; +import * as wmill from "windmill-client"; + +export async function main(file: wmill.S3Object) { + const content = await wmill.loadS3File(file); + // ... +} ``` -## TypeScript Operations +### S3 operations ```typescript import * as wmill from "windmill-client"; @@ -470,7 +504,7 @@ const content: Uint8Array = await wmill.loadS3File(s3object); const blob: Blob = await wmill.loadS3FileStream(s3object); // Write file to S3 -const result: S3Object = await wmill.writeS3File( +const result: wmill.S3Object = await wmill.writeS3File( s3object, // Target path (or undefined to auto-generate) fileContent, // string or Blob s3ResourcePath // Optional: specific S3 resource to use @@ -530,6 +564,30 @@ SELECT * FROM read_parquet('s3:///path/to/file.parquet'); SELECT * FROM read_json('s3:///path/to/file.json'); ``` +### Receiving an S3Object as a script parameter + +Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for it +and binds the arg as the bare `s3://storage/key` URI, which DuckDB's reader +functions consume directly: + +```sql +-- $file (s3object) +SELECT * FROM read_parquet($file); +``` + +Works with any DuckDB reader: `read_csv($file)`, `read_json($file)`, etc. + +### Writing query results to S3 + +DuckDB writes to S3 natively via `COPY ... TO`: + +```sql +COPY (SELECT * FROM users) TO 's3:///exports/users.parquet' (FORMAT PARQUET); +``` + +Use this instead of the `-- s3` streaming directive supported by the other SQL +dialects — that directive is not available in DuckDB. + # Go @@ -690,6 +748,36 @@ Name the parameters by adding comments before the statement: SELECT * FROM users WHERE name = @P1 AND age > @P2; ``` +## Receiving an S3Object as a script parameter + +Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as `nvarchar(max)` JSON text — Parquet/CSV +files are decoded server-side into a JSON array of records, JSON/JSONL pass +through. Consume with `OPENJSON`: + +```sql +-- @P1 file (s3object) +SELECT id, name +FROM OPENJSON(@P1) +WITH (id INT, name NVARCHAR(200)); +``` + +## Streaming query results to S3 + +Add a `-- s3` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its `S3Object` +as the script result. + +```sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +``` + +All keys are optional: `prefix` (object key prefix), `storage` (named storage — +omit to use the workspace default), `format` (`json` (default), `parquet`, or +`csv`). Use this for large result sets — rows stream directly to S3 instead of +being buffered as the script return value. + # MySQL @@ -703,6 +791,37 @@ Name the parameters by adding comments before the statement: SELECT * FROM users WHERE name = ? AND age > ?; ``` +## Receiving an S3Object as a script parameter + +Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as JSON text — Parquet/CSV files are +decoded server-side into a JSON array of records, JSON/JSONL pass through. +Consume with `JSON_TABLE`: + +```sql +-- ? file (s3object) +SELECT id, name +FROM JSON_TABLE(?, '$[*]' + COLUMNS (id INT PATH '$.id', name VARCHAR(200) PATH '$.name') +) AS r; +``` + +## Streaming query results to S3 + +Add a `-- s3` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its `S3Object` +as the script result. + +```sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +``` + +All keys are optional: `prefix` (object key prefix), `storage` (named storage — +omit to use the workspace default), `format` (`json` (default), `parquet`, or +`csv`). Use this for large result sets — rows stream directly to S3 instead of +being buffered as the script return value. + # TypeScript (Native) @@ -854,6 +973,35 @@ Name the parameters by adding comments at the beginning of the script (without s SELECT * FROM users WHERE name = $1::TEXT AND age > $2::INT; ``` +## Receiving an S3Object as a script parameter + +Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as a `jsonb` parameter — Parquet/CSV files +are decoded server-side into a JSON array of records, JSON/JSONL pass through. +Consume with `jsonb_to_recordset` (or any `jsonb` API): + +```sql +-- $1 file (s3object) +SELECT * +FROM jsonb_to_recordset($1::jsonb) AS r(id INT, name TEXT); +``` + +## Streaming query results to S3 + +Add a `-- s3` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its `S3Object` +as the script result. + +```sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +``` + +All keys are optional: `prefix` (object key prefix), `storage` (named storage — +omit to use the workspace default), `format` (`json` (default), `parquet`, or +`csv`). Use this for large result sets — rows stream directly to S3 instead of +being buffered as the script return value. + # PowerShell @@ -1012,6 +1160,21 @@ def preprocessor(event: Event): Windmill provides built-in support for S3-compatible storage operations. +### Receiving an S3Object as a script parameter + +To accept a file from S3 as input to a script, type the parameter with `S3Object` (imported from `wmill`): + +```python +import wmill +from wmill import S3Object + +def main(file: S3Object): + content = wmill.load_s3_file(file) + # ... +``` + +### S3 operations + ```python import wmill @@ -1208,6 +1371,37 @@ Name the parameters by adding comments before the statement: SELECT * FROM users WHERE name = ? AND age > ?; ``` +## Receiving an S3Object as a script parameter + +Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as JSON text — Parquet/CSV files are +decoded server-side into a JSON array of records, JSON/JSONL pass through. +Wrap the bind with `PARSE_JSON(?)` and walk it with `LATERAL FLATTEN`: + +```sql +-- ? file (s3object) +SELECT + v.value:id::NUMBER AS id, + v.value:name::STRING AS name +FROM LATERAL FLATTEN(input => PARSE_JSON(?)) v; +``` + +## Streaming query results to S3 + +Add a `-- s3` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its `S3Object` +as the script result. + +```sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +``` + +All keys are optional: `prefix` (object key prefix), `storage` (named storage — +omit to use the workspace default), `format` (`json` (default), `parquet`, or +`csv`). Use this for large result sets — rows stream directly to S3 instead of +being buffered, bypassing the 10000-row return cap. + # TypeScript SDK (windmill-client) diff --git a/system_prompts/auto-generated/skills/write-script-bigquery/SKILL.md b/system_prompts/auto-generated/skills/write-script-bigquery/SKILL.md index e2347163c3..b9517c07f6 100644 --- a/system_prompts/auto-generated/skills/write-script-bigquery/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bigquery/SKILL.md @@ -49,3 +49,34 @@ Name the parameters by adding comments before the statement: -- @name2 (int64) = 0 SELECT * FROM users WHERE name = @name1 AND age > @name2; ``` + +## Receiving an S3Object as a script parameter + +Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as a `STRING` JSON parameter — Parquet/CSV +files are decoded server-side into a JSON array of records, JSON/JSONL pass +through. Consume with `JSON_EXTRACT_ARRAY` / `JSON_VALUE`: + +```sql +-- @file (s3object) +SELECT + CAST(JSON_VALUE(row, '$.id') AS INT64) AS id, + JSON_VALUE(row, '$.name') AS name +FROM UNNEST(JSON_EXTRACT_ARRAY(@file)) AS row; +``` + +## Streaming query results to S3 + +Add a `-- s3` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its `S3Object` +as the script result. + +```sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +``` + +All keys are optional: `prefix` (object key prefix), `storage` (named storage — +omit to use the workspace default), `format` (`json` (default), `parquet`, or +`csv`). Use this for large result sets — rows stream directly to S3 instead of +being buffered, bypassing the 10000-row return cap. 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 39d66a6433..16682683cc 100644 --- a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md @@ -120,19 +120,20 @@ export async function preprocessor(event: Event) { ## S3 Object Operations -Windmill provides built-in support for S3-compatible storage operations. +Windmill 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 — always use it instead of redefining your own. -### S3Object Type - -The S3Object type represents a file in S3 storage: +### Receiving an S3Object as a script parameter ```typescript -type S3Object = { - s3: string; // Path within the bucket -}; +import * as wmill from "windmill-client"; + +export async function main(file: wmill.S3Object) { + const content = await wmill.loadS3File(file); + // ... +} ``` -## TypeScript Operations +### S3 operations ```typescript import * as wmill from "windmill-client"; @@ -144,7 +145,7 @@ const content: Uint8Array = await wmill.loadS3File(s3object); const blob: Blob = await wmill.loadS3FileStream(s3object); // Write file to S3 -const result: S3Object = await wmill.writeS3File( +const result: wmill.S3Object = await wmill.writeS3File( s3object, // Target path (or undefined to auto-generate) fileContent, // string or Blob s3ResourcePath // Optional: specific S3 resource to use 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 442a6d4a7b..2fc5913b46 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -118,19 +118,20 @@ export async function preprocessor(event: Event) { ## S3 Object Operations -Windmill provides built-in support for S3-compatible storage operations. +Windmill 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 — always use it instead of redefining your own. -### S3Object Type - -The S3Object type represents a file in S3 storage: +### Receiving an S3Object as a script parameter ```typescript -type S3Object = { - s3: string; // Path within the bucket -}; +import * as wmill from "windmill-client"; + +export async function main(file: wmill.S3Object) { + const content = await wmill.loadS3File(file); + // ... +} ``` -## TypeScript Operations +### S3 operations ```typescript import * as wmill from "windmill-client"; @@ -142,7 +143,7 @@ const content: Uint8Array = await wmill.loadS3File(s3object); const blob: Blob = await wmill.loadS3FileStream(s3object); // Write file to S3 -const result: S3Object = await wmill.writeS3File( +const result: wmill.S3Object = await wmill.writeS3File( s3object, // Target path (or undefined to auto-generate) fileContent, // string or Blob s3ResourcePath // Optional: specific S3 resource to use 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 08b18f2a40..c627f1bbc8 100644 --- a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md @@ -124,19 +124,20 @@ export async function preprocessor(event: Event) { ## S3 Object Operations -Windmill provides built-in support for S3-compatible storage operations. +Windmill 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 — always use it instead of redefining your own. -### S3Object Type - -The S3Object type represents a file in S3 storage: +### Receiving an S3Object as a script parameter ```typescript -type S3Object = { - s3: string; // Path within the bucket -}; +import * as wmill from "windmill-client"; + +export async function main(file: wmill.S3Object) { + const content = await wmill.loadS3File(file); + // ... +} ``` -## TypeScript Operations +### S3 operations ```typescript import * as wmill from "windmill-client"; @@ -148,7 +149,7 @@ const content: Uint8Array = await wmill.loadS3File(s3object); const blob: Blob = await wmill.loadS3FileStream(s3object); // Write file to S3 -const result: S3Object = await wmill.writeS3File( +const result: wmill.S3Object = await wmill.writeS3File( s3object, // Target path (or undefined to auto-generate) fileContent, // string or Blob s3ResourcePath // Optional: specific S3 resource to use diff --git a/system_prompts/auto-generated/skills/write-script-duckdb/SKILL.md b/system_prompts/auto-generated/skills/write-script-duckdb/SKILL.md index c19544da17..de29cbfb3c 100644 --- a/system_prompts/auto-generated/skills/write-script-duckdb/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-duckdb/SKILL.md @@ -89,3 +89,27 @@ SELECT * FROM read_parquet('s3:///path/to/file.parquet'); -- JSON files SELECT * FROM read_json('s3:///path/to/file.json'); ``` + +### Receiving an S3Object as a script parameter + +Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for it +and binds the arg as the bare `s3://storage/key` URI, which DuckDB's reader +functions consume directly: + +```sql +-- $file (s3object) +SELECT * FROM read_parquet($file); +``` + +Works with any DuckDB reader: `read_csv($file)`, `read_json($file)`, etc. + +### Writing query results to S3 + +DuckDB writes to S3 natively via `COPY ... TO`: + +```sql +COPY (SELECT * FROM users) TO 's3:///exports/users.parquet' (FORMAT PARQUET); +``` + +Use this instead of the `-- s3` streaming directive supported by the other SQL +dialects — that directive is not available in DuckDB. diff --git a/system_prompts/auto-generated/skills/write-script-mssql/SKILL.md b/system_prompts/auto-generated/skills/write-script-mssql/SKILL.md index 7a64a03ea7..76b63b896d 100644 --- a/system_prompts/auto-generated/skills/write-script-mssql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-mssql/SKILL.md @@ -49,3 +49,33 @@ Name the parameters by adding comments before the statement: -- @P2 name2 (int) = 0 SELECT * FROM users WHERE name = @P1 AND age > @P2; ``` + +## Receiving an S3Object as a script parameter + +Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as `nvarchar(max)` JSON text — Parquet/CSV +files are decoded server-side into a JSON array of records, JSON/JSONL pass +through. Consume with `OPENJSON`: + +```sql +-- @P1 file (s3object) +SELECT id, name +FROM OPENJSON(@P1) +WITH (id INT, name NVARCHAR(200)); +``` + +## Streaming query results to S3 + +Add a `-- s3` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its `S3Object` +as the script result. + +```sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +``` + +All keys are optional: `prefix` (object key prefix), `storage` (named storage — +omit to use the workspace default), `format` (`json` (default), `parquet`, or +`csv`). Use this for large result sets — rows stream directly to S3 instead of +being buffered as the script return value. diff --git a/system_prompts/auto-generated/skills/write-script-mysql/SKILL.md b/system_prompts/auto-generated/skills/write-script-mysql/SKILL.md index 2c9044be23..b06b211d7a 100644 --- a/system_prompts/auto-generated/skills/write-script-mysql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-mysql/SKILL.md @@ -49,3 +49,34 @@ Name the parameters by adding comments before the statement: -- ? name2 (int) = 0 SELECT * FROM users WHERE name = ? AND age > ?; ``` + +## Receiving an S3Object as a script parameter + +Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as JSON text — Parquet/CSV files are +decoded server-side into a JSON array of records, JSON/JSONL pass through. +Consume with `JSON_TABLE`: + +```sql +-- ? file (s3object) +SELECT id, name +FROM JSON_TABLE(?, '$[*]' + COLUMNS (id INT PATH '$.id', name VARCHAR(200) PATH '$.name') +) AS r; +``` + +## Streaming query results to S3 + +Add a `-- s3` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its `S3Object` +as the script result. + +```sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +``` + +All keys are optional: `prefix` (object key prefix), `storage` (named storage — +omit to use the workspace default), `format` (`json` (default), `parquet`, or +`csv`). Use this for large result sets — rows stream directly to S3 instead of +being buffered as the script return value. diff --git a/system_prompts/auto-generated/skills/write-script-postgresql/SKILL.md b/system_prompts/auto-generated/skills/write-script-postgresql/SKILL.md index e370b7a3f2..aeb976d194 100644 --- a/system_prompts/auto-generated/skills/write-script-postgresql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-postgresql/SKILL.md @@ -49,3 +49,32 @@ Name the parameters by adding comments at the beginning of the script (without s -- $2 name2 = default_value SELECT * FROM users WHERE name = $1::TEXT AND age > $2::INT; ``` + +## Receiving an S3Object as a script parameter + +Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as a `jsonb` parameter — Parquet/CSV files +are decoded server-side into a JSON array of records, JSON/JSONL pass through. +Consume with `jsonb_to_recordset` (or any `jsonb` API): + +```sql +-- $1 file (s3object) +SELECT * +FROM jsonb_to_recordset($1::jsonb) AS r(id INT, name TEXT); +``` + +## Streaming query results to S3 + +Add a `-- s3` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its `S3Object` +as the script result. + +```sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +``` + +All keys are optional: `prefix` (object key prefix), `storage` (named storage — +omit to use the workspace default), `format` (`json` (default), `parquet`, or +`csv`). Use this for large result sets — rows stream directly to S3 instead of +being buffered as the script return value. diff --git a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md index a0f16fb732..bf47615ed5 100644 --- a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md @@ -138,6 +138,21 @@ def preprocessor(event: Event): Windmill provides built-in support for S3-compatible storage operations. +### Receiving an S3Object as a script parameter + +To accept a file from S3 as input to a script, type the parameter with `S3Object` (imported from `wmill`): + +```python +import wmill +from wmill import S3Object + +def main(file: S3Object): + content = wmill.load_s3_file(file) + # ... +``` + +### S3 operations + ```python import wmill diff --git a/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md b/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md index 68c49ffa6e..3105cb1acc 100644 --- a/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md @@ -49,3 +49,34 @@ Name the parameters by adding comments before the statement: -- ? name2 (number) = 0 SELECT * FROM users WHERE name = ? AND age > ?; ``` + +## Receiving an S3Object as a script parameter + +Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as JSON text — Parquet/CSV files are +decoded server-side into a JSON array of records, JSON/JSONL pass through. +Wrap the bind with `PARSE_JSON(?)` and walk it with `LATERAL FLATTEN`: + +```sql +-- ? file (s3object) +SELECT + v.value:id::NUMBER AS id, + v.value:name::STRING AS name +FROM LATERAL FLATTEN(input => PARSE_JSON(?)) v; +``` + +## Streaming query results to S3 + +Add a `-- s3` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its `S3Object` +as the script result. + +```sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +``` + +All keys are optional: `prefix` (object key prefix), `storage` (named storage — +omit to use the workspace default), `format` (`json` (default), `parquet`, or +`csv`). Use this for large result sets — rows stream directly to S3 instead of +being buffered, bypassing the 10000-row return cap. diff --git a/system_prompts/languages/bigquery.md b/system_prompts/languages/bigquery.md index 4fa9fc3030..829921dff6 100644 --- a/system_prompts/languages/bigquery.md +++ b/system_prompts/languages/bigquery.md @@ -9,3 +9,34 @@ Name the parameters by adding comments before the statement: -- @name2 (int64) = 0 SELECT * FROM users WHERE name = @name1 AND age > @name2; ``` + +## Receiving an S3Object as a script parameter + +Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as a `STRING` JSON parameter — Parquet/CSV +files are decoded server-side into a JSON array of records, JSON/JSONL pass +through. Consume with `JSON_EXTRACT_ARRAY` / `JSON_VALUE`: + +```sql +-- @file (s3object) +SELECT + CAST(JSON_VALUE(row, '$.id') AS INT64) AS id, + JSON_VALUE(row, '$.name') AS name +FROM UNNEST(JSON_EXTRACT_ARRAY(@file)) AS row; +``` + +## Streaming query results to S3 + +Add a `-- s3` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its `S3Object` +as the script result. + +```sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +``` + +All keys are optional: `prefix` (object key prefix), `storage` (named storage — +omit to use the workspace default), `format` (`json` (default), `parquet`, or +`csv`). Use this for large result sets — rows stream directly to S3 instead of +being buffered, bypassing the 10000-row return cap. diff --git a/system_prompts/languages/bun.md b/system_prompts/languages/bun.md index d9c210850e..5d5abdc4b8 100644 --- a/system_prompts/languages/bun.md +++ b/system_prompts/languages/bun.md @@ -80,19 +80,20 @@ export async function preprocessor(event: Event) { ## S3 Object Operations -Windmill provides built-in support for S3-compatible storage operations. +Windmill 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 — always use it instead of redefining your own. -### S3Object Type - -The S3Object type represents a file in S3 storage: +### Receiving an S3Object as a script parameter ```typescript -type S3Object = { - s3: string; // Path within the bucket -}; +import * as wmill from "windmill-client"; + +export async function main(file: wmill.S3Object) { + const content = await wmill.loadS3File(file); + // ... +} ``` -## TypeScript Operations +### S3 operations ```typescript import * as wmill from "windmill-client"; @@ -104,7 +105,7 @@ const content: Uint8Array = await wmill.loadS3File(s3object); const blob: Blob = await wmill.loadS3FileStream(s3object); // Write file to S3 -const result: S3Object = await wmill.writeS3File( +const result: wmill.S3Object = await wmill.writeS3File( s3object, // Target path (or undefined to auto-generate) fileContent, // string or Blob s3ResourcePath // Optional: specific S3 resource to use diff --git a/system_prompts/languages/bunnative.md b/system_prompts/languages/bunnative.md index d09723b392..977c974737 100644 --- a/system_prompts/languages/bunnative.md +++ b/system_prompts/languages/bunnative.md @@ -78,19 +78,20 @@ export async function preprocessor(event: Event) { ## S3 Object Operations -Windmill provides built-in support for S3-compatible storage operations. +Windmill 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 — always use it instead of redefining your own. -### S3Object Type - -The S3Object type represents a file in S3 storage: +### Receiving an S3Object as a script parameter ```typescript -type S3Object = { - s3: string; // Path within the bucket -}; +import * as wmill from "windmill-client"; + +export async function main(file: wmill.S3Object) { + const content = await wmill.loadS3File(file); + // ... +} ``` -## TypeScript Operations +### S3 operations ```typescript import * as wmill from "windmill-client"; @@ -102,7 +103,7 @@ const content: Uint8Array = await wmill.loadS3File(s3object); const blob: Blob = await wmill.loadS3FileStream(s3object); // Write file to S3 -const result: S3Object = await wmill.writeS3File( +const result: wmill.S3Object = await wmill.writeS3File( s3object, // Target path (or undefined to auto-generate) fileContent, // string or Blob s3ResourcePath // Optional: specific S3 resource to use diff --git a/system_prompts/languages/deno.md b/system_prompts/languages/deno.md index 74ce91b398..adf5677a6f 100644 --- a/system_prompts/languages/deno.md +++ b/system_prompts/languages/deno.md @@ -84,19 +84,20 @@ export async function preprocessor(event: Event) { ## S3 Object Operations -Windmill provides built-in support for S3-compatible storage operations. +Windmill 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 — always use it instead of redefining your own. -### S3Object Type - -The S3Object type represents a file in S3 storage: +### Receiving an S3Object as a script parameter ```typescript -type S3Object = { - s3: string; // Path within the bucket -}; +import * as wmill from "windmill-client"; + +export async function main(file: wmill.S3Object) { + const content = await wmill.loadS3File(file); + // ... +} ``` -## TypeScript Operations +### S3 operations ```typescript import * as wmill from "windmill-client"; @@ -108,7 +109,7 @@ const content: Uint8Array = await wmill.loadS3File(s3object); const blob: Blob = await wmill.loadS3FileStream(s3object); // Write file to S3 -const result: S3Object = await wmill.writeS3File( +const result: wmill.S3Object = await wmill.writeS3File( s3object, // Target path (or undefined to auto-generate) fileContent, // string or Blob s3ResourcePath // Optional: specific S3 resource to use diff --git a/system_prompts/languages/duckdb.md b/system_prompts/languages/duckdb.md index 7f80b29497..d834015890 100644 --- a/system_prompts/languages/duckdb.md +++ b/system_prompts/languages/duckdb.md @@ -49,3 +49,27 @@ SELECT * FROM read_parquet('s3:///path/to/file.parquet'); -- JSON files SELECT * FROM read_json('s3:///path/to/file.json'); ``` + +### Receiving an S3Object as a script parameter + +Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for it +and binds the arg as the bare `s3://storage/key` URI, which DuckDB's reader +functions consume directly: + +```sql +-- $file (s3object) +SELECT * FROM read_parquet($file); +``` + +Works with any DuckDB reader: `read_csv($file)`, `read_json($file)`, etc. + +### Writing query results to S3 + +DuckDB writes to S3 natively via `COPY ... TO`: + +```sql +COPY (SELECT * FROM users) TO 's3:///exports/users.parquet' (FORMAT PARQUET); +``` + +Use this instead of the `-- s3` streaming directive supported by the other SQL +dialects — that directive is not available in DuckDB. diff --git a/system_prompts/languages/mssql.md b/system_prompts/languages/mssql.md index a4dae5e0fb..efeece93dd 100644 --- a/system_prompts/languages/mssql.md +++ b/system_prompts/languages/mssql.md @@ -9,3 +9,33 @@ Name the parameters by adding comments before the statement: -- @P2 name2 (int) = 0 SELECT * FROM users WHERE name = @P1 AND age > @P2; ``` + +## Receiving an S3Object as a script parameter + +Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as `nvarchar(max)` JSON text — Parquet/CSV +files are decoded server-side into a JSON array of records, JSON/JSONL pass +through. Consume with `OPENJSON`: + +```sql +-- @P1 file (s3object) +SELECT id, name +FROM OPENJSON(@P1) +WITH (id INT, name NVARCHAR(200)); +``` + +## Streaming query results to S3 + +Add a `-- s3` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its `S3Object` +as the script result. + +```sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +``` + +All keys are optional: `prefix` (object key prefix), `storage` (named storage — +omit to use the workspace default), `format` (`json` (default), `parquet`, or +`csv`). Use this for large result sets — rows stream directly to S3 instead of +being buffered as the script return value. diff --git a/system_prompts/languages/mysql.md b/system_prompts/languages/mysql.md index 78aa637232..4f33d2458f 100644 --- a/system_prompts/languages/mysql.md +++ b/system_prompts/languages/mysql.md @@ -9,3 +9,34 @@ Name the parameters by adding comments before the statement: -- ? name2 (int) = 0 SELECT * FROM users WHERE name = ? AND age > ?; ``` + +## Receiving an S3Object as a script parameter + +Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as JSON text — Parquet/CSV files are +decoded server-side into a JSON array of records, JSON/JSONL pass through. +Consume with `JSON_TABLE`: + +```sql +-- ? file (s3object) +SELECT id, name +FROM JSON_TABLE(?, '$[*]' + COLUMNS (id INT PATH '$.id', name VARCHAR(200) PATH '$.name') +) AS r; +``` + +## Streaming query results to S3 + +Add a `-- s3` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its `S3Object` +as the script result. + +```sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +``` + +All keys are optional: `prefix` (object key prefix), `storage` (named storage — +omit to use the workspace default), `format` (`json` (default), `parquet`, or +`csv`). Use this for large result sets — rows stream directly to S3 instead of +being buffered as the script return value. diff --git a/system_prompts/languages/postgresql.md b/system_prompts/languages/postgresql.md index 2cf90ea9c1..9bf8cf8026 100644 --- a/system_prompts/languages/postgresql.md +++ b/system_prompts/languages/postgresql.md @@ -9,3 +9,32 @@ Name the parameters by adding comments at the beginning of the script (without s -- $2 name2 = default_value SELECT * FROM users WHERE name = $1::TEXT AND age > $2::INT; ``` + +## Receiving an S3Object as a script parameter + +Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as a `jsonb` parameter — Parquet/CSV files +are decoded server-side into a JSON array of records, JSON/JSONL pass through. +Consume with `jsonb_to_recordset` (or any `jsonb` API): + +```sql +-- $1 file (s3object) +SELECT * +FROM jsonb_to_recordset($1::jsonb) AS r(id INT, name TEXT); +``` + +## Streaming query results to S3 + +Add a `-- s3` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its `S3Object` +as the script result. + +```sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +``` + +All keys are optional: `prefix` (object key prefix), `storage` (named storage — +omit to use the workspace default), `format` (`json` (default), `parquet`, or +`csv`). Use this for large result sets — rows stream directly to S3 instead of +being buffered as the script return value. diff --git a/system_prompts/languages/python3.md b/system_prompts/languages/python3.md index ddb92a3c4b..d556e45868 100644 --- a/system_prompts/languages/python3.md +++ b/system_prompts/languages/python3.md @@ -98,6 +98,21 @@ def preprocessor(event: Event): Windmill provides built-in support for S3-compatible storage operations. +### Receiving an S3Object as a script parameter + +To accept a file from S3 as input to a script, type the parameter with `S3Object` (imported from `wmill`): + +```python +import wmill +from wmill import S3Object + +def main(file: S3Object): + content = wmill.load_s3_file(file) + # ... +``` + +### S3 operations + ```python import wmill diff --git a/system_prompts/languages/snowflake.md b/system_prompts/languages/snowflake.md index 23d10d11b0..8ded76eb3c 100644 --- a/system_prompts/languages/snowflake.md +++ b/system_prompts/languages/snowflake.md @@ -9,3 +9,34 @@ Name the parameters by adding comments before the statement: -- ? name2 (number) = 0 SELECT * FROM users WHERE name = ? AND age > ?; ``` + +## Receiving an S3Object as a script parameter + +Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for +it, downloads the file, and binds it as JSON text — Parquet/CSV files are +decoded server-side into a JSON array of records, JSON/JSONL pass through. +Wrap the bind with `PARSE_JSON(?)` and walk it with `LATERAL FLATTEN`: + +```sql +-- ? file (s3object) +SELECT + v.value:id::NUMBER AS id, + v.value:name::STRING AS name +FROM LATERAL FLATTEN(input => PARSE_JSON(?)) v; +``` + +## Streaming query results to S3 + +Add a `-- s3` directive at the top of the script to stream the result set to S3 +instead of returning rows. Windmill writes the file and returns its `S3Object` +as the script result. + +```sql +-- s3 prefix=exports/users format=parquet +SELECT id, name FROM users; +``` + +All keys are optional: `prefix` (object key prefix), `storage` (named storage — +omit to use the workspace default), `format` (`json` (default), `parquet`, or +`csv`). Use this for large result sets — rows stream directly to S3 instead of +being buffered, bypassing the 10000-row return cap. From 1ee73c51ba9b348f0bbbe8f8fd4fba963d3a42f1 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 4 May 2026 16:26:36 +0000 Subject: [PATCH 006/313] ci: multi-tool PR reviews (Codex/Pi/Claude) with slash commands (#9026) * ci: add Pi+DeepSeek-V4 review and slash command dispatcher Auto-reviews now fan out to Claude (Opus), Codex (gpt-5.4), and Pi (DeepSeek-V4-Pro) on PR open/ready. PR comments support /review (all three), /codex, /pi, /claude with optional extra context appended to the prompt. All review workflows now substitute EE code before review and gate the auto-trigger path on org membership of the PR author. Co-Authored-By: Claude Opus 4.7 (1M context) * ci: fix command parser whitespace, align checkout v5, broaden PR perms - Trim leading/trailing whitespace from comment first line so /review with leading space parses correctly (caught by Pi review) - Standardize EE checkout step on actions/checkout@v5 across all three review workflows (caught by Pi review) - Bump pull-requests permission to write to satisfy GitHub's PR comment endpoint when issues=write alone is rejected Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .github/codex/pr-review.prompt.md | 1 + .github/pi/pr-review.prompt.md | 25 +++ .github/workflows/codex-pr-review.yml | 163 +++++++++++++-- .github/workflows/pi-pr-review.yml | 256 +++++++++++++++++++++++ .github/workflows/pr-ready-review.yml | 93 +++++++- .github/workflows/pr-review-commands.yml | 122 +++++++++++ 6 files changed, 637 insertions(+), 23 deletions(-) create mode 100644 .github/pi/pr-review.prompt.md create mode 100644 .github/workflows/pi-pr-review.yml create mode 100644 .github/workflows/pr-review-commands.yml diff --git a/.github/codex/pr-review.prompt.md b/.github/codex/pr-review.prompt.md index d3e6dfc4e8..f0f7619b7a 100644 --- a/.github/codex/pr-review.prompt.md +++ b/.github/codex/pr-review.prompt.md @@ -9,6 +9,7 @@ Review policy: Repository context: - Read `./.github/codex/pr-review-context.md` for the PR metadata and the exact diff commands to use. +- If the context file ends with an "Additional reviewer instructions:" section, treat it as extra guidance from the human who triggered this review and follow it. - Review only the changes introduced by this PR. - Read additional files only when the diff is not enough to validate a finding. - Do not modify any files. diff --git a/.github/pi/pr-review.prompt.md b/.github/pi/pr-review.prompt.md new file mode 100644 index 0000000000..ec9f340120 --- /dev/null +++ b/.github/pi/pr-review.prompt.md @@ -0,0 +1,25 @@ +You are reviewing a GitHub pull request for this repository. + +Review policy: +- Read `AGENTS.md` (and any `AGENTS.md` in directories containing changed files) before reviewing — it is the project's contributor guide. +- Only report issues you are confident are real and introduced by this pull request. +- Focus on bugs, security problems, and clear `AGENTS.md` violations. +- Do not report style nits, speculative concerns, pre-existing issues, or anything a normal linter/typechecker would obviously catch. +- Keep the review high signal. If there is no clear issue, return no findings. + +Repository context: +- Read `./.github/pi/pr-review-context.md` for PR metadata and the exact diff commands to use. +- If the context file ends with an "Additional reviewer instructions:" section, treat it as extra guidance from the human who triggered this review and follow it. +- Run those `git diff` / `git log` commands to inspect the changes — they reference the base and head SHAs of this PR. +- Review only the changes introduced by this PR. Read additional files only when the diff is not enough to validate a finding. +- Do not modify any files. Do not create new files outside this review. + +Output requirements: +- Return a GitHub PR comment in markdown, not JSON. +- Start the comment with `## Pi Review (DeepSeek V4)`. +- Give a short overall summary first. +- If you found high-signal issues, list them in a short numbered list with file paths and line numbers when you know them confidently. +- If you found no high-signal issues, say that explicitly. +- End with a `### Reproduction instructions` section containing a short descriptive paragraph for a tester explaining how to navigate the app to observe the change. Do not make it a numbered list. If the diff is not enough to infer this safely, say that plainly. +- Prefer at most 10 findings. +- Output ONLY the final review markdown — no preamble, no thinking, no tool transcripts. diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml index e945f5fd45..f40f86ca2c 100644 --- a/.github/workflows/codex-pr-review.yml +++ b/.github/workflows/codex-pr-review.yml @@ -3,19 +3,59 @@ name: Codex Auto Review on: pull_request: types: [ready_for_review, opened] + workflow_call: + inputs: + pr_number: + description: 'PR number to review' + required: true + type: number + extra_prompt: + description: 'Additional reviewer instructions appended to the standard review prompt' + required: false + type: string + default: '' + triggered_by: + description: 'GitHub username that triggered this review (for audit only)' + required: false + type: string + default: '' + secrets: + CODEX_AUTH_JSON: + required: false + WINDMILL_EE_PRIVATE_ACCESS: + required: false concurrency: - group: codex-review-${{ github.event.pull_request.number }} + group: codex-review-${{ inputs.pr_number || github.event.pull_request.number }} cancel-in-progress: true jobs: + check-membership: + if: github.event_name == 'pull_request' + uses: ./.github/workflows/check-org-membership.yml + with: + commenter: ${{ github.event.pull_request.user.login }} + secrets: + access_token: ${{ secrets.ORG_ACCESS_TOKEN }} + codex-review: + needs: check-membership runs-on: ubicloud-standard-2 timeout-minutes: 30 - if: github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false + if: | + always() && + ( + needs.check-membership.result == 'skipped' || + (needs.check-membership.result == 'success' && needs.check-membership.outputs.is_member == 'true') + ) && + ( + github.event_name == 'workflow_call' || + (github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false) + ) permissions: contents: read issues: write + pull-requests: write steps: - name: Check Codex configuration id: codex_config @@ -29,25 +69,104 @@ jobs: echo "CODEX_AUTH_JSON is not configured; skipping Codex review." fi - - name: Checkout repository + - name: Resolve PR metadata if: steps.codex_config.outputs.enabled == 'true' + id: pr + env: + GH_TOKEN: ${{ github.token }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }} + EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }} + EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + EVENT_TITLE: ${{ github.event.pull_request.title }} + EVENT_BODY: ${{ github.event.pull_request.body }} + EVENT_FORK: ${{ github.event.pull_request.head.repo.fork }} + run: | + if [ -n "$INPUT_PR_NUMBER" ]; then + PR_JSON=$(gh pr view "$INPUT_PR_NUMBER" --repo "${{ github.repository }}" \ + --json number,baseRefName,baseRefOid,headRefOid,title,body,isCrossRepository) + PR_NUMBER=$(echo "$PR_JSON" | jq -r '.number') + BASE_REF=$(echo "$PR_JSON" | jq -r '.baseRefName') + BASE_SHA=$(echo "$PR_JSON" | jq -r '.baseRefOid') + HEAD_SHA=$(echo "$PR_JSON" | jq -r '.headRefOid') + PR_TITLE=$(echo "$PR_JSON" | jq -r '.title') + PR_BODY=$(echo "$PR_JSON" | jq -r '.body // ""') + IS_FORK=$(echo "$PR_JSON" | jq -r '.isCrossRepository') + else + PR_NUMBER="$EVENT_PR_NUMBER" + BASE_REF="$EVENT_BASE_REF" + BASE_SHA="$EVENT_BASE_SHA" + HEAD_SHA="$EVENT_HEAD_SHA" + PR_TITLE="$EVENT_TITLE" + PR_BODY="$EVENT_BODY" + IS_FORK="$EVENT_FORK" + fi + if [ "$IS_FORK" = "true" ]; then + echo "Skipping Codex review for fork PR." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + { + echo "skip=false" + echo "pr_number=$PR_NUMBER" + echo "base_ref=$BASE_REF" + echo "base_sha=$BASE_SHA" + echo "head_sha=$HEAD_SHA" + echo 'title<> "$GITHUB_OUTPUT" + + - name: Checkout repository + if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' uses: actions/checkout@v5 with: - ref: refs/pull/${{ github.event.pull_request.number }}/merge + ref: refs/pull/${{ steps.pr.outputs.pr_number }}/merge fetch-depth: 1 + - name: Check EE access + if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' + id: ee + env: + EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }} + run: | + if [ -n "$EE_TOKEN" ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + echo "ee_repo_ref=$(cat ./backend/ee-repo-ref.txt)" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout EE repository + if: steps.ee.outputs.available == 'true' + uses: actions/checkout@v5 + with: + repository: windmill-labs/windmill-ee-private + path: ./windmill-ee-private + ref: ${{ steps.ee.outputs.ee_repo_ref }} + token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }} + fetch-depth: 1 + + - name: Substitute EE code + if: steps.ee.outputs.available == 'true' + run: ./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private + - name: Set up Node.js - if: steps.codex_config.outputs.enabled == 'true' + if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' uses: actions/setup-node@v4 with: node-version: 22 - name: Install Codex CLI - if: steps.codex_config.outputs.enabled == 'true' + if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' run: npm install --global @openai/codex@0.117.0 - name: Configure file-backed Codex auth - if: steps.codex_config.outputs.enabled == 'true' + if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' env: CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} run: | @@ -63,24 +182,25 @@ jobs: node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$CODEX_HOME/auth.json" - name: Pre-fetch base and head refs for the PR - if: steps.codex_config.outputs.enabled == 'true' + if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' env: - PR_BASE_REF: ${{ github.event.pull_request.base.ref }} - PR_NUMBER: ${{ github.event.pull_request.number }} + PR_BASE_REF: ${{ steps.pr.outputs.base_ref }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} run: | git fetch --no-tags origin \ "$PR_BASE_REF" \ "+refs/pull/$PR_NUMBER/head" - name: Write Codex review context - if: steps.codex_config.outputs.enabled == 'true' + if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' env: PR_REPOSITORY: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} - PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PR_TITLE: ${{ github.event.pull_request.title }} - PR_BODY: ${{ github.event.pull_request.body || '' }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + PR_BASE_SHA: ${{ steps.pr.outputs.base_sha }} + PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + PR_TITLE: ${{ steps.pr.outputs.title }} + PR_BODY: ${{ steps.pr.outputs.body }} + EXTRA_PROMPT: ${{ inputs.extra_prompt }} run: | mkdir -p .github/codex node <<'NODE' @@ -106,11 +226,14 @@ jobs: 'Full review diff command:', `git diff --unified=0 ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}` ]; + if (process.env.EXTRA_PROMPT && process.env.EXTRA_PROMPT.trim()) { + lines.push('', 'Additional reviewer instructions:', process.env.EXTRA_PROMPT.trim()); + } fs.writeFileSync('.github/codex/pr-review-context.md', `${lines.join('\n')}\n`); NODE - name: Run Codex review - if: steps.codex_config.outputs.enabled == 'true' + if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' run: | codex exec \ -C "$GITHUB_WORKSPACE" \ @@ -121,8 +244,10 @@ jobs: - < .github/codex/pr-review.prompt.md - name: Post Codex review comment - if: steps.codex_config.outputs.enabled == 'true' + if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} with: github-token: ${{ github.token }} script: | @@ -140,6 +265,6 @@ jobs: await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: context.payload.pull_request.number, + issue_number: Number(process.env.PR_NUMBER), body, }); diff --git a/.github/workflows/pi-pr-review.yml b/.github/workflows/pi-pr-review.yml new file mode 100644 index 0000000000..04cf8a20ca --- /dev/null +++ b/.github/workflows/pi-pr-review.yml @@ -0,0 +1,256 @@ +name: Pi Auto Review + +on: + pull_request: + types: [ready_for_review, opened] + workflow_call: + inputs: + pr_number: + description: 'PR number to review' + required: true + type: number + extra_prompt: + description: 'Additional reviewer instructions appended to the standard review prompt' + required: false + type: string + default: '' + triggered_by: + description: 'GitHub username that triggered this review (for audit only)' + required: false + type: string + default: '' + secrets: + DEEPSEEK_API_KEY: + required: false + WINDMILL_EE_PRIVATE_ACCESS: + required: false + +concurrency: + group: pi-review-${{ inputs.pr_number || github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + check-membership: + if: github.event_name == 'pull_request' + uses: ./.github/workflows/check-org-membership.yml + with: + commenter: ${{ github.event.pull_request.user.login }} + secrets: + access_token: ${{ secrets.ORG_ACCESS_TOKEN }} + + pi-review: + needs: check-membership + runs-on: ubicloud-standard-2 + timeout-minutes: 30 + if: | + always() && + ( + needs.check-membership.result == 'skipped' || + (needs.check-membership.result == 'success' && needs.check-membership.outputs.is_member == 'true') + ) && + ( + github.event_name == 'workflow_call' || + (github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false) + ) + permissions: + contents: read + issues: write + pull-requests: write + steps: + - name: Check Pi configuration + id: pi_config + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + run: | + if [ -n "$DEEPSEEK_API_KEY" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "DEEPSEEK_API_KEY is not configured; skipping Pi review." + fi + + - name: Resolve PR metadata + if: steps.pi_config.outputs.enabled == 'true' + id: pr + env: + GH_TOKEN: ${{ github.token }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }} + EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }} + EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + EVENT_TITLE: ${{ github.event.pull_request.title }} + EVENT_BODY: ${{ github.event.pull_request.body }} + EVENT_FORK: ${{ github.event.pull_request.head.repo.fork }} + run: | + if [ -n "$INPUT_PR_NUMBER" ]; then + PR_JSON=$(gh pr view "$INPUT_PR_NUMBER" --repo "${{ github.repository }}" \ + --json number,baseRefName,baseRefOid,headRefOid,title,body,isCrossRepository) + PR_NUMBER=$(echo "$PR_JSON" | jq -r '.number') + BASE_REF=$(echo "$PR_JSON" | jq -r '.baseRefName') + BASE_SHA=$(echo "$PR_JSON" | jq -r '.baseRefOid') + HEAD_SHA=$(echo "$PR_JSON" | jq -r '.headRefOid') + PR_TITLE=$(echo "$PR_JSON" | jq -r '.title') + PR_BODY=$(echo "$PR_JSON" | jq -r '.body // ""') + IS_FORK=$(echo "$PR_JSON" | jq -r '.isCrossRepository') + else + PR_NUMBER="$EVENT_PR_NUMBER" + BASE_REF="$EVENT_BASE_REF" + BASE_SHA="$EVENT_BASE_SHA" + HEAD_SHA="$EVENT_HEAD_SHA" + PR_TITLE="$EVENT_TITLE" + PR_BODY="$EVENT_BODY" + IS_FORK="$EVENT_FORK" + fi + if [ "$IS_FORK" = "true" ]; then + echo "Skipping Pi review for fork PR." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + { + echo "skip=false" + echo "pr_number=$PR_NUMBER" + echo "base_ref=$BASE_REF" + echo "base_sha=$BASE_SHA" + echo "head_sha=$HEAD_SHA" + echo 'title<> "$GITHUB_OUTPUT" + + - name: Checkout repository + if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' + uses: actions/checkout@v5 + with: + ref: refs/pull/${{ steps.pr.outputs.pr_number }}/merge + fetch-depth: 1 + + - name: Check EE access + if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' + id: ee + env: + EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }} + run: | + if [ -n "$EE_TOKEN" ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + echo "ee_repo_ref=$(cat ./backend/ee-repo-ref.txt)" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout EE repository + if: steps.ee.outputs.available == 'true' + uses: actions/checkout@v5 + with: + repository: windmill-labs/windmill-ee-private + path: ./windmill-ee-private + ref: ${{ steps.ee.outputs.ee_repo_ref }} + token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }} + fetch-depth: 1 + + - name: Substitute EE code + if: steps.ee.outputs.available == 'true' + run: ./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private + + - name: Set up Node.js + if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install Pi CLI + if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' + run: npm install --global @mariozechner/pi-coding-agent + + - name: Pre-fetch base and head refs for the PR + if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' + env: + PR_BASE_REF: ${{ steps.pr.outputs.base_ref }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + run: | + git fetch --no-tags origin \ + "$PR_BASE_REF" \ + "+refs/pull/$PR_NUMBER/head" + + - name: Write Pi review context + if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' + env: + PR_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + PR_BASE_SHA: ${{ steps.pr.outputs.base_sha }} + PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + PR_TITLE: ${{ steps.pr.outputs.title }} + PR_BODY: ${{ steps.pr.outputs.body }} + EXTRA_PROMPT: ${{ inputs.extra_prompt }} + run: | + mkdir -p .github/pi + node <<'NODE' + const fs = require('fs'); + const lines = [ + `Repository: ${process.env.PR_REPOSITORY}`, + `PR number: ${process.env.PR_NUMBER}`, + `Base SHA: ${process.env.PR_BASE_SHA}`, + `Head SHA: ${process.env.PR_HEAD_SHA}`, + '', + 'PR title:', + process.env.PR_TITLE || '(empty)', + '', + 'PR body:', + process.env.PR_BODY || '(empty)', + '', + 'Changed commits command:', + `git log --oneline ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`, + '', + 'Changed files command:', + `git diff --stat ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`, + '', + 'Full review diff command:', + `git diff --unified=0 ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}` + ]; + if (process.env.EXTRA_PROMPT && process.env.EXTRA_PROMPT.trim()) { + lines.push('', 'Additional reviewer instructions:', process.env.EXTRA_PROMPT.trim()); + } + fs.writeFileSync('.github/pi/pr-review-context.md', `${lines.join('\n')}\n`); + NODE + + - name: Run Pi review + if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + PI_SKIP_VERSION_CHECK: '1' + run: | + pi -p \ + --provider deepseek \ + --model deepseek-v4-pro \ + --tools read,grep,find,ls,bash \ + < .github/pi/pr-review.prompt.md \ + > pi-final-message.md + + - name: Post Pi review comment + if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' + uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + with: + github-token: ${{ github.token }} + script: | + const fs = require('fs'); + const path = `${process.env.GITHUB_WORKSPACE}/pi-final-message.md`; + if (!fs.existsSync(path)) { + core.info('Pi did not produce a final message; skipping PR comment.'); + return; + } + const body = fs.readFileSync(path, 'utf8').trim(); + if (!body) { + core.info('Pi final message was empty; skipping PR comment.'); + return; + } + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(process.env.PR_NUMBER), + body, + }); diff --git a/.github/workflows/pr-ready-review.yml b/.github/workflows/pr-ready-review.yml index 78c0c3e045..78cfd41bc2 100644 --- a/.github/workflows/pr-ready-review.yml +++ b/.github/workflows/pr-ready-review.yml @@ -3,31 +3,116 @@ name: Claude Auto Review on: pull_request: types: [ready_for_review, opened] + workflow_call: + inputs: + pr_number: + description: 'PR number to review' + required: true + type: number + extra_prompt: + description: 'Additional reviewer instructions appended to the standard review prompt' + required: false + type: string + default: '' + triggered_by: + description: 'GitHub username that triggered this review (for audit only)' + required: false + type: string + default: '' + secrets: + CLAUDE_CODE_OAUTH_TOKEN: + required: true + WINDMILL_EE_PRIVATE_ACCESS: + required: false concurrency: - group: claude-review-${{ github.event.pull_request.number }} + group: claude-review-${{ inputs.pr_number || github.event.pull_request.number }} cancel-in-progress: true jobs: + check-membership: + if: github.event_name == 'pull_request' + uses: ./.github/workflows/check-org-membership.yml + with: + commenter: ${{ github.event.pull_request.user.login }} + secrets: + access_token: ${{ secrets.ORG_ACCESS_TOKEN }} + auto-review: + needs: check-membership runs-on: ubuntu-latest - if: github.event.pull_request.draft == false || github.event.pull_request.ready_for_review == true + if: | + always() && + ( + needs.check-membership.result == 'skipped' || + (needs.check-membership.result == 'success' && needs.check-membership.outputs.is_member == 'true') + ) && + ( + github.event_name == 'workflow_call' || + (github.event.pull_request.draft == false || github.event.pull_request.ready_for_review == true) + ) permissions: contents: read pull-requests: read id-token: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 1 + - name: Check EE access + id: ee + env: + EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }} + run: | + if [ -n "$EE_TOKEN" ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + echo "ee_repo_ref=$(cat ./backend/ee-repo-ref.txt)" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout EE repository + if: steps.ee.outputs.available == 'true' + uses: actions/checkout@v5 + with: + repository: windmill-labs/windmill-ee-private + path: ./windmill-ee-private + ref: ${{ steps.ee.outputs.ee_repo_ref }} + token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }} + fetch-depth: 1 + + - name: Substitute EE code + if: steps.ee.outputs.available == 'true' + run: ./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private + + - name: Resolve PR number + id: resolve + env: + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + if [ -n "$INPUT_PR_NUMBER" ]; then + echo "pr_number=$INPUT_PR_NUMBER" >> "$GITHUB_OUTPUT" + else + echo "pr_number=$EVENT_PR_NUMBER" >> "$GITHUB_OUTPUT" + fi + - name: Read review prompt id: review-prompt + env: + EXTRA_PROMPT: ${{ inputs.extra_prompt }} run: | { echo 'REVIEW_PROMPT<> "$GITHUB_ENV" @@ -38,7 +123,7 @@ jobs: track_progress: true prompt: | REPO: ${{ github.repository }} - PR NUMBER: ${{ github.event.pull_request.number }} + PR NUMBER: ${{ steps.resolve.outputs.pr_number }} ${{ env.REVIEW_PROMPT }} claude_args: | diff --git a/.github/workflows/pr-review-commands.yml b/.github/workflows/pr-review-commands.yml new file mode 100644 index 0000000000..21d5e1decd --- /dev/null +++ b/.github/workflows/pr-review-commands.yml @@ -0,0 +1,122 @@ +name: PR Review Commands + +on: + issue_comment: + types: [created] + +jobs: + parse: + if: github.event.issue.pull_request != null + runs-on: ubuntu-latest + outputs: + command: ${{ steps.parse.outputs.command }} + extra_prompt: ${{ steps.parse.outputs.extra_prompt }} + steps: + - name: Parse command from comment + id: parse + env: + BODY: ${{ github.event.comment.body }} + run: | + FIRST_LINE=$(printf '%s' "$BODY" | head -n 1 | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//') + FIRST_WORD=${FIRST_LINE%% *} + case "$FIRST_WORD" in + /review|/codex|/pi|/claude) + COMMAND="${FIRST_WORD#/}" + REMAINDER_FIRST_LINE=${FIRST_LINE#"$FIRST_WORD"} + REMAINDER_FIRST_LINE=${REMAINDER_FIRST_LINE# } + REST=$(printf '%s' "$BODY" | tail -n +2) + { + echo "command=$COMMAND" + echo 'extra_prompt<> "$GITHUB_OUTPUT" + ;; + *) + echo "command=" >> "$GITHUB_OUTPUT" + ;; + esac + + check-membership: + needs: parse + if: needs.parse.outputs.command != '' + uses: ./.github/workflows/check-org-membership.yml + secrets: + access_token: ${{ secrets.ORG_ACCESS_TOKEN }} + + acknowledge: + needs: [parse, check-membership] + if: needs.parse.outputs.command != '' && needs.check-membership.outputs.is_member == 'true' + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - name: React to comment with eyes + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + COMMENT_ID: ${{ github.event.comment.id }} + run: | + gh api -X POST \ + "/repos/$REPO/issues/comments/$COMMENT_ID/reactions" \ + -f content=eyes >/dev/null + + claude: + needs: [parse, check-membership] + if: | + needs.check-membership.outputs.is_member == 'true' && + (needs.parse.outputs.command == 'review' || needs.parse.outputs.command == 'claude') + permissions: + contents: read + pull-requests: read + id-token: write + uses: ./.github/workflows/pr-ready-review.yml + with: + pr_number: ${{ github.event.issue.number }} + extra_prompt: ${{ needs.parse.outputs.extra_prompt }} + triggered_by: ${{ github.event.comment.user.login }} + secrets: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }} + + codex: + needs: [parse, check-membership] + if: | + needs.check-membership.outputs.is_member == 'true' && + (needs.parse.outputs.command == 'review' || needs.parse.outputs.command == 'codex') + permissions: + contents: read + issues: write + pull-requests: write + uses: ./.github/workflows/codex-pr-review.yml + with: + pr_number: ${{ github.event.issue.number }} + extra_prompt: ${{ needs.parse.outputs.extra_prompt }} + triggered_by: ${{ github.event.comment.user.login }} + secrets: + CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} + WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }} + + pi: + needs: [parse, check-membership] + if: | + needs.check-membership.outputs.is_member == 'true' && + (needs.parse.outputs.command == 'review' || needs.parse.outputs.command == 'pi') + permissions: + contents: read + issues: write + pull-requests: write + uses: ./.github/workflows/pi-pr-review.yml + with: + pr_number: ${{ github.event.issue.number }} + extra_prompt: ${{ needs.parse.outputs.extra_prompt }} + triggered_by: ${{ github.event.comment.user.login }} + secrets: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }} From 548794cdbee448be9a990d6ea96bc869298d154f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 4 May 2026 16:44:53 +0000 Subject: [PATCH 007/313] ci: pi progress streaming, codex gpt-5.5 + danger-full-access sandbox (#9030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: install bubblewrap for codex sandbox; stream pi progress in logs - Codex's vendored bwrap fails to set up loopback on some ubicloud runners, leaving codex unable to read any local files. Install the system bubblewrap package before running codex so its read-only sandbox works reliably. - Switch pi to --mode json and pipe events through jq to surface agent/turn boundaries and tool calls live in the GitHub Actions log, matching codex's progress visibility. Final assistant text is extracted from the saved event log into pi-final-message.md for the PR comment. Co-Authored-By: Claude Opus 4.7 (1M context) * ci: drop bubblewrap install, use codex -s danger-full-access Codex's read-only sandbox uses bwrap which fails to set up loopback on some ubicloud runners. Rather than apt-installing bubblewrap, switch to the no-sandbox mode for parity with how Pi and Claude already operate in the same workflow — runner is ephemeral and we trust the codex prompt the same way. Co-Authored-By: Claude Opus 4.7 (1M context) * ci: bump codex review model from gpt-5.4 to gpt-5.5 gpt-5.5 is positioned as the agentic successor to gpt-5.4 — same per-token latency, fewer tokens to complete Codex tasks, and explicitly stronger at holding context across large systems and multi-tool reasoning, which matches the PR review workload. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/codex-pr-review.yml | 4 ++-- .github/workflows/pi-pr-review.yml | 32 ++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml index f40f86ca2c..1a3e968272 100644 --- a/.github/workflows/codex-pr-review.yml +++ b/.github/workflows/codex-pr-review.yml @@ -237,9 +237,9 @@ jobs: run: | codex exec \ -C "$GITHUB_WORKSPACE" \ - -m gpt-5.4 \ + -m gpt-5.5 \ -c 'model_reasoning_effort="xhigh"' \ - -s read-only \ + -s danger-full-access \ -o codex-final-message.md \ - < .github/codex/pr-review.prompt.md diff --git a/.github/workflows/pi-pr-review.yml b/.github/workflows/pi-pr-review.yml index 04cf8a20ca..eba8c2bad3 100644 --- a/.github/workflows/pi-pr-review.yml +++ b/.github/workflows/pi-pr-review.yml @@ -222,12 +222,42 @@ jobs: DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} PI_SKIP_VERSION_CHECK: '1' run: | + set -o pipefail pi -p \ --provider deepseek \ --model deepseek-v4-pro \ --tools read,grep,find,ls,bash \ + --mode json \ < .github/pi/pr-review.prompt.md \ - > pi-final-message.md + | tee pi-events.jsonl \ + | jq -rc --unbuffered ' + if .type == "agent_start" then "🤖 pi agent started" + elif .type == "turn_start" then "── turn ──" + elif .type == "message_end" then + "[\(.message.role)] " + ( + (.message.content // []) + | map( + if .type == "text" then "text(\(.text | length)c)" + elif .type == "tool_use" then "🔧 \(.name) \(.input | @json | .[:160])" + elif .type == "tool_result" then "✅ result" + else .type + end + ) + | join(" | ") + ) + elif .type == "turn_end" then "── turn done (\((.toolResults // []) | length) tool result(s)) ──" + elif .type == "agent_end" then "🏁 pi agent done" + else empty + end + ' + + jq -r ' + select(.type == "agent_end") + | .messages + | map(select(.role == "assistant")) + | last + | (.content[]? | select(.type == "text") | .text) + ' pi-events.jsonl > pi-final-message.md - name: Post Pi review comment if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' From 1be62ea926872882ddbd4c8ce81502d6e341b8c1 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 4 May 2026 16:51:59 +0000 Subject: [PATCH 008/313] fix: stop sequential whileloop on iteration failure (#9028) Co-authored-by: Claude Opus 4.7 (1M context) --- backend/windmill-worker/src/worker_flow.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 7b0038795f..1577d108e8 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -1038,7 +1038,8 @@ pub async fn update_flow_status_after_job_completion_internal( // backwards compatibility itered.as_ref().map(|itered| itered.len()).unwrap_or(0) }; - (*while_loop || (*index + 1 < itered_len) && (success || skip_loop_failures)) + (*while_loop || *index + 1 < itered_len) + && (success || skip_loop_failures) && !stop_early } => { From 505f78bd29813fbf78ed855d1da6636ff4c286d1 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 4 May 2026 16:53:20 +0000 Subject: [PATCH 009/313] ci: re-review on push, thread prior PR comments into reviewer context (#9032) * ci: re-review on push, thread prior PR comments into reviewer context - Add 'synchronize' to all three review workflow triggers so each push to a PR branch re-runs Claude/Codex/Pi. Existing cancel-in-progress concurrency groups ensure only the latest push's review actually executes. - Fetch the most recent up to 20 PR comments before each review and inject them into the prompt context so the reviewer can recognize its own previous review, focus on what changed, and avoid repeating findings the human already addressed. - Update the three review prompts (Claude, Codex, Pi) to instruct the reviewer to honor the prior-discussion section when present. Co-Authored-By: Claude Opus 4.7 (1M context) * ci: bump codex CLI to 0.128.0 for gpt-5.5 support Codex 0.117.0 rejects the gpt-5.5 model with 'requires a newer version of Codex'. 0.128.0 is the current stable release on npm. Co-Authored-By: Claude Opus 4.7 (1M context) * ci: limit synchronize re-trigger to pi review only Re-running Claude and Codex on every push gets expensive fast on busy PRs. Pi (DeepSeek-V4) is cheap enough to re-run per push, while Claude/Codex remain on opened/ready_for_review and re-trigger via slash commands. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .claude/review-prompt.md | 1 + .github/codex/pr-review.prompt.md | 3 ++- .github/pi/pr-review.prompt.md | 3 ++- .github/workflows/codex-pr-review.yml | 30 ++++++++++++++++++++++++++- .github/workflows/pi-pr-review.yml | 30 ++++++++++++++++++++++++++- .github/workflows/pr-ready-review.yml | 22 ++++++++++++++++++++ 6 files changed, 85 insertions(+), 4 deletions(-) diff --git a/.claude/review-prompt.md b/.claude/review-prompt.md index 6814089bea..c862f2d1bc 100644 --- a/.claude/review-prompt.md +++ b/.claude/review-prompt.md @@ -19,6 +19,7 @@ Read all relevant CLAUDE.md files (root and in directories containing changed fi - Use top-level comments for general observations or praise - Only flag issues introduced by this PR, not pre-existing problems - Self-validate each finding: "Is this definitely a real issue?" If uncertain, discard it +- If the prompt includes a "Prior PR discussion" section, this PR has already been reviewed. Look for your own earlier comment, focus on what changed in the latest commits, and do not repeat findings the human already pushed back on or addressed ## Testing Instructions diff --git a/.github/codex/pr-review.prompt.md b/.github/codex/pr-review.prompt.md index f0f7619b7a..141fd52d61 100644 --- a/.github/codex/pr-review.prompt.md +++ b/.github/codex/pr-review.prompt.md @@ -9,7 +9,8 @@ Review policy: Repository context: - Read `./.github/codex/pr-review-context.md` for the PR metadata and the exact diff commands to use. -- If the context file ends with an "Additional reviewer instructions:" section, treat it as extra guidance from the human who triggered this review and follow it. +- If the context file contains an "Additional reviewer instructions:" section, treat it as extra guidance from the human who triggered this review and follow it. +- If the context file contains a "Prior PR discussion" section, this PR has already received review activity. Look for your own previous "## Codex Review" comment, take it into account, focus on what changed in the latest commits, and do not repeat findings the human already pushed back on or addressed. - Review only the changes introduced by this PR. - Read additional files only when the diff is not enough to validate a finding. - Do not modify any files. diff --git a/.github/pi/pr-review.prompt.md b/.github/pi/pr-review.prompt.md index ec9f340120..93ad730ba0 100644 --- a/.github/pi/pr-review.prompt.md +++ b/.github/pi/pr-review.prompt.md @@ -9,7 +9,8 @@ Review policy: Repository context: - Read `./.github/pi/pr-review-context.md` for PR metadata and the exact diff commands to use. -- If the context file ends with an "Additional reviewer instructions:" section, treat it as extra guidance from the human who triggered this review and follow it. +- If the context file contains an "Additional reviewer instructions:" section, treat it as extra guidance from the human who triggered this review and follow it. +- If the context file contains a "Prior PR discussion" section, this PR has already received review activity. Look for your own previous "## Pi Review (DeepSeek V4)" comment, take it into account, focus on what changed in the latest commits, and do not repeat findings the human already pushed back on or addressed. - Run those `git diff` / `git log` commands to inspect the changes — they reference the base and head SHAs of this PR. - Review only the changes introduced by this PR. Read additional files only when the diff is not enough to validate a finding. - Do not modify any files. Do not create new files outside this review. diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml index 1a3e968272..79b77f842d 100644 --- a/.github/workflows/codex-pr-review.yml +++ b/.github/workflows/codex-pr-review.yml @@ -163,7 +163,7 @@ jobs: - name: Install Codex CLI if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' - run: npm install --global @openai/codex@0.117.0 + run: npm install --global @openai/codex@0.128.0 - name: Configure file-backed Codex auth if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' @@ -191,6 +191,17 @@ jobs: "$PR_BASE_REF" \ "+refs/pull/$PR_NUMBER/head" + - name: Fetch prior PR discussion + if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + run: | + gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" \ + --jq '[.[] | {user: .user.login, created_at: .created_at, body: (.body | .[:4000])}] | sort_by(.created_at) | .[-20:]' \ + > prior-comments.json || echo "[]" > prior-comments.json + - name: Write Codex review context if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' env: @@ -229,6 +240,23 @@ jobs: if (process.env.EXTRA_PROMPT && process.env.EXTRA_PROMPT.trim()) { lines.push('', 'Additional reviewer instructions:', process.env.EXTRA_PROMPT.trim()); } + if (fs.existsSync('prior-comments.json')) { + try { + const comments = JSON.parse(fs.readFileSync('prior-comments.json', 'utf8')); + if (Array.isArray(comments) && comments.length > 0) { + lines.push( + '', + 'Prior PR discussion (most recent up to 20 comments):', + '', + 'If you have already reviewed this PR (look for your own earlier "## Codex Review" comment), focus on what changed since then per the diff and respect any decisions the human made in replies. Do not re-flag findings the human already pushed back on.', + '' + ); + for (const c of comments) { + lines.push(`### @${c.user} (${c.created_at})`, '', c.body, '', '---', ''); + } + } + } catch (_) {} + } fs.writeFileSync('.github/codex/pr-review-context.md', `${lines.join('\n')}\n`); NODE diff --git a/.github/workflows/pi-pr-review.yml b/.github/workflows/pi-pr-review.yml index eba8c2bad3..d7c743fc16 100644 --- a/.github/workflows/pi-pr-review.yml +++ b/.github/workflows/pi-pr-review.yml @@ -2,7 +2,7 @@ name: Pi Auto Review on: pull_request: - types: [ready_for_review, opened] + types: [ready_for_review, opened, synchronize] workflow_call: inputs: pr_number: @@ -175,6 +175,17 @@ jobs: "$PR_BASE_REF" \ "+refs/pull/$PR_NUMBER/head" + - name: Fetch prior PR discussion + if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + run: | + gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" \ + --jq '[.[] | {user: .user.login, created_at: .created_at, body: (.body | .[:4000])}] | sort_by(.created_at) | .[-20:]' \ + > prior-comments.json || echo "[]" > prior-comments.json + - name: Write Pi review context if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' env: @@ -213,6 +224,23 @@ jobs: if (process.env.EXTRA_PROMPT && process.env.EXTRA_PROMPT.trim()) { lines.push('', 'Additional reviewer instructions:', process.env.EXTRA_PROMPT.trim()); } + if (fs.existsSync('prior-comments.json')) { + try { + const comments = JSON.parse(fs.readFileSync('prior-comments.json', 'utf8')); + if (Array.isArray(comments) && comments.length > 0) { + lines.push( + '', + 'Prior PR discussion (most recent up to 20 comments):', + '', + 'If you have already reviewed this PR (look for your own earlier "## Pi Review (DeepSeek V4)" comment), focus on what changed since then per the diff and respect any decisions the human made in replies. Do not re-flag findings the human already pushed back on.', + '' + ); + for (const c of comments) { + lines.push(`### @${c.user} (${c.created_at})`, '', c.body, '', '---', ''); + } + } + } catch (_) {} + } fs.writeFileSync('.github/pi/pr-review-context.md', `${lines.join('\n')}\n`); NODE diff --git a/.github/workflows/pr-ready-review.yml b/.github/workflows/pr-ready-review.yml index 78cfd41bc2..0058ffa8d9 100644 --- a/.github/workflows/pr-ready-review.yml +++ b/.github/workflows/pr-ready-review.yml @@ -99,6 +99,24 @@ jobs: echo "pr_number=$EVENT_PR_NUMBER" >> "$GITHUB_OUTPUT" fi + - name: Fetch prior PR discussion + id: prior + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ steps.resolve.outputs.pr_number }} + run: | + gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" \ + --jq '[.[] | {user: .user.login, created_at: .created_at, body: (.body | .[:4000])}] | sort_by(.created_at) | .[-20:]' \ + > prior-comments.json || echo "[]" > prior-comments.json + jq -r ' + if length == 0 then "" + else + "## Prior PR discussion (most recent up to 20 comments)\n\nIf you have already reviewed this PR (look for your own earlier comment), focus on what changed since then per the diff and respect any decisions the human made in replies. Do not re-flag findings the human already pushed back on.\n\n" + + (map("### @\(.user) (\(.created_at))\n\n\(.body)") | join("\n\n---\n\n")) + end + ' prior-comments.json > prior-comments.md + - name: Read review prompt id: review-prompt env: @@ -113,6 +131,10 @@ jobs: echo '' printf '%s\n' "$EXTRA_PROMPT" fi + if [ -s prior-comments.md ]; then + echo '' + cat prior-comments.md + fi echo 'EOF' } >> "$GITHUB_ENV" From 192866d5197c74ec930d6fe7bf9234fac76763f4 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 4 May 2026 16:54:53 +0000 Subject: [PATCH 010/313] fix(flows): don't bubble error when continue_on_error is on the last step (#9029) * fix(flows): don't bubble error when continue_on_error is on the last step When the last step of a flow (or branch/forloop) failed with continue_on_error or skip_failures enabled, should_continue_flow resolved to false (because the flow was at its last step), and the flow was completed with success=false. This made parent flows / subflows treat the run as a failure even though the user explicitly asked to continue past errors. Detect this case and set success=true so the failure is captured in the result but not propagated. Co-Authored-By: Claude Opus 4.7 (1M context) * docs: explain why success is overridden post should_continue_flow Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- backend/windmill-worker/src/worker_flow.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 1577d108e8..c21f22045a 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -1587,6 +1587,16 @@ pub async fn update_flow_status_after_job_completion_internal( Ok(should_retry) }; + // For a regular module with continue_on_error at the last position, nothing else + // overrides `success` — `flow_jobs` is None so the loop/branchall override above + // doesn't fire, and `should_continue_flow` resolves to `!is_last_step = false`, + // letting the flow complete with success=false and bubble the error up to the + // enclosing job/subflow. Detect that case and treat the flow as successful. + let recoverable_failure_at_last_step = !success + && is_last_step + && !unrecoverable + && (skip_seq_branch_failure || skip_loop_failures || continue_on_error); + let should_continue_flow = match success { _ if stop_early => stop_early_err_msg.is_some() && flow_value.failure_module.is_some(), // if stop_early_err_msg some, we want to trigger the error handler before stopping the flow, if any _ if flow_job.is_canceled() => false, @@ -1606,6 +1616,10 @@ pub async fn update_flow_status_after_job_completion_internal( false => false, }; + if recoverable_failure_at_last_step { + success = true; + } + tracing::info!(id = %flow_job.id, root_id = %job_root, success = %success, stop_early = %stop_early, is_last_step = %is_last_step, unrecoverable = %unrecoverable, skip_seq_branch_failure = %skip_seq_branch_failure, skip_loop_failures = %skip_loop_failures, current_module_id = %current_module.map(|x| x.id.clone()).unwrap_or_default(), From 11b60e5b95dddd9a287ad350a7cde0f6d9e889d3 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 4 May 2026 17:22:06 +0000 Subject: [PATCH 011/313] ci: share review policy across Claude/Codex/Pi via review-prompt-shared.md (#9035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: share review policy across Claude/Codex/Pi via review-prompt-shared.md All three reviewers now consume a single canonical policy document (.github/review-prompt-shared.md) covering AGENTS.md compliance, severity triage (P0/P1/P2), and a checklist for new public surfaces (auth contract, module placement, half-finished pub fns, input validation). Each tool's own prompt file shrinks to just its output-format quirks, and each workflow concatenates shared + tool-specific at runtime before invoking the model. Drops the suppressive "Prefer at most 10 findings" / "Keep the review high signal. If there is no clear issue, return no findings" wording from Codex and Pi, which was clipping P1 and P2 findings (e.g. half-finished pub fn, blocking I/O, wrong module placement). Replaces it with severity triage so both reviewers report all P0/P1 and surface P2 when the diff invites it. Also makes AGENTS.md authoritative for Codex (was CLAUDE.md, which is just @AGENTS.md in this repo) and adds an explicit "new public function" checklist that covers the missing-auth-check failure mode none of the three reviewers flagged on the test PR. Co-Authored-By: Claude Opus 4.7 (1M context) * ci: move test-coverage assessment to shared prompt, slim per-tool prompts - Replace per-tool 'Reproduction instructions' with a single shared 'Test coverage assessment' section that asks each reviewer to evaluate automated coverage (sufficient / thin / appropriate) and describe what manual verification remains, if any. - Slim per-tool prompts to the absolute minimum: just where to read context, the comment header, severity tagging, and the Pi-only 'no preamble' constraint. Everything else lives in the shared policy. - Drop the model name from Pi's title ('Pi Review (DeepSeek V4)' → 'Pi Review') — the title's job is to let the bot find its own prior comment when re-reviewing; the model is irrelevant to the reader. The titles ('## Codex Review', '## Pi Review') stay because Codex and Pi both post as github-actions[bot], so the heading is the only discriminator the bot can use to find its own past comment in the prior-discussion context. Co-Authored-By: Claude Opus 4.7 (1M context) * ci: scope test-coverage assessment to layers actually changed Don't ask reviewers about integration tests on a frontend-only diff or about playwright tests on a backend-only diff. The shared 'Test coverage' section now lists categories (backend / frontend / CI-docs) and tells the reviewer to skip the ones the PR does not touch — only ask about Rust integration tests when backend handlers/workers/queues were modified, only ask about frontend tests when components or state machines were touched, and explicitly call out 'no automated tests expected' for CI/docs/config diffs. Co-Authored-By: Claude Opus 4.7 (1M context) * ci: don't ask reviewers to flag missing frontend component tests The Windmill frontend codebase doesn't generally test Svelte components — existing tests cover pure-logic utilities only (flowDiff, previousResults, copilot logic, dbtable queries, etc.). Asking reviewers to flag every new component for lacking a test would produce noise inconsistent with the established convention. Limit the frontend test-coverage check to new pure-logic utilities (files that would naturally have a sibling *.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) * ci: point local-review skill at the shared review policy Codex flagged (and Pi confirmed on its second pass) that slimming .claude/review-prompt.md to output-only broke the local-review skill contract — the skill still told Claude to read only that file for the review criteria, so /local-review would no longer apply severity triage, the public-surface checklist, or AGENTS.md compliance. Update the skill to read .github/review-prompt-shared.md as the policy source and .claude/review-prompt.md only for Claude output preferences. Also align the local output format with the severity-tag convention used by the workflow reviewers, and replace the lingering 'CLAUDE.md compliance' wording with 'AGENTS.md compliance'. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .claude/review-prompt.md | 28 ++------------ .claude/skills/local-review/SKILL.md | 14 ++++--- .github/codex/pr-review.prompt.md | 28 ++------------ .github/pi/pr-review.prompt.md | 28 ++------------ .github/review-prompt-shared.md | 56 +++++++++++++++++++++++++++ .github/workflows/codex-pr-review.yml | 3 +- .github/workflows/pi-pr-review.yml | 3 +- .github/workflows/pr-ready-review.yml | 4 +- 8 files changed, 82 insertions(+), 82 deletions(-) create mode 100644 .github/review-prompt-shared.md diff --git a/.claude/review-prompt.md b/.claude/review-prompt.md index c862f2d1bc..b3b6df0d74 100644 --- a/.claude/review-prompt.md +++ b/.claude/review-prompt.md @@ -1,26 +1,4 @@ -# Code Review Instructions +# Claude output format -Review this pull request and provide comprehensive feedback. - -## Focus Areas - -- **Code quality and best practices** — does the code follow established patterns? -- **Potential bugs or issues** — will this code work correctly in all cases? -- **Performance considerations** — are there unnecessary allocations, N+1 queries, or bottlenecks? -- **Security implications** — injection, auth bypass, data exposure? - -## CLAUDE.md Compliance - -Read all relevant CLAUDE.md files (root and in directories containing changed files). Check each rule against the changed code. Quote the exact rule when flagging a violation. - -## Review Guidelines - -- Provide detailed feedback using inline comments for specific issues -- Use top-level comments for general observations or praise -- Only flag issues introduced by this PR, not pre-existing problems -- Self-validate each finding: "Is this definitely a real issue?" If uncertain, discard it -- If the prompt includes a "Prior PR discussion" section, this PR has already been reviewed. Look for your own earlier comment, focus on what changed in the latest commits, and do not repeat findings the human already pushed back on or addressed - -## Testing Instructions - -At the end of your review, add complete instructions to reproduce the added changes through the app interface. These instructions will be given to a tester so they can verify the changes. It should be a short descriptive text (not a step-by-step or a list) on how to navigate the app (what page, what action, what input, etc.) to see the changes. +- Use inline comments at the relevant lines for specific issues. +- Use a top-level comment for the summary, severity-tagged finding list, AGENTS.md compliance check, and the test-coverage assessment. diff --git a/.claude/skills/local-review/SKILL.md b/.claude/skills/local-review/SKILL.md index 0399ad7294..58a9d473e2 100644 --- a/.claude/skills/local-review/SKILL.md +++ b/.claude/skills/local-review/SKILL.md @@ -6,11 +6,11 @@ description: Code review a pull request for bugs and CLAUDE.md compliance. MUST # Local Code Review Skill -Run the same review locally that the GitHub Claude Auto Review action runs on PRs. The shared review instructions live in `.claude/review-prompt.md` — read that file first and follow its instructions. +Run the same review locally that the GitHub Claude Auto Review action runs on PRs. The review policy lives in `.github/review-prompt-shared.md` (severity triage, public-surface checklist, `AGENTS.md` compliance, test-coverage assessment); `.claude/review-prompt.md` holds Claude-specific output preferences. Read both before reviewing. ## Execution Steps -1. **Read `.claude/review-prompt.md`** for the review criteria and focus areas +1. **Read `.github/review-prompt-shared.md`** for the review policy and `.claude/review-prompt.md` for the Claude output format 2. **Determine the PR scope**: - If an argument is provided, use it as the PR number or branch @@ -23,7 +23,7 @@ Run the same review locally that the GitHub Claude Auto Review action runs on PR 4. **Read changed files** where the diff alone is insufficient to understand context -5. **Apply the review instructions from `.claude/review-prompt.md`** +5. **Apply the review policy from `.github/review-prompt-shared.md`** (and the output format from `.claude/review-prompt.md`) 6. **Self-validate each finding**: Before reporting, ask yourself: - "Is this definitely a real issue, not a false positive?" @@ -39,19 +39,21 @@ Run the same review locally that the GitHub Claude Auto Review action runs on PR Found N issues: -1. () +1. [P0|P1|P2] -2. () +2. [P0|P1|P2] ``` +End with a Test coverage section per `.github/review-prompt-shared.md`. + If no issues are found: ``` ## Code review -No issues found. Checked for bugs and CLAUDE.md compliance. +No issues found. Checked for bugs, security, and AGENTS.md compliance. ``` ## Posting Comments (--comment flag) diff --git a/.github/codex/pr-review.prompt.md b/.github/codex/pr-review.prompt.md index 141fd52d61..fef52dba85 100644 --- a/.github/codex/pr-review.prompt.md +++ b/.github/codex/pr-review.prompt.md @@ -1,25 +1,5 @@ -You are reviewing a GitHub pull request for this repository. +# Codex output format -Review policy: -- Read `CLAUDE.md` before reviewing code. -- Only report issues you are confident are real and introduced by this pull request. -- Focus on bugs, security problems, and clear `CLAUDE.md` violations. -- Do not report style nits, speculative concerns, pre-existing issues, or problems that a normal linter/typechecker would obviously catch. -- Keep the review high signal. If there is no clear issue, return no findings. - -Repository context: -- Read `./.github/codex/pr-review-context.md` for the PR metadata and the exact diff commands to use. -- If the context file contains an "Additional reviewer instructions:" section, treat it as extra guidance from the human who triggered this review and follow it. -- If the context file contains a "Prior PR discussion" section, this PR has already received review activity. Look for your own previous "## Codex Review" comment, take it into account, focus on what changed in the latest commits, and do not repeat findings the human already pushed back on or addressed. -- Review only the changes introduced by this PR. -- Read additional files only when the diff is not enough to validate a finding. -- Do not modify any files. - -Output requirements: -- Return a GitHub PR comment in markdown, not JSON. -- Start with `## Codex Review`. -- Give a short overall summary first. -- If you found high-signal issues, list them in a short numbered list with file paths and line numbers when you know them confidently. -- If you found no high-signal issues, say that explicitly. -- End with a `### Reproduction instructions` section containing a short descriptive paragraph for a tester explaining how to navigate the app to observe the change. Do not make it a numbered list. If the diff is not enough to infer this safely, say that plainly. -- Prefer at most 10 findings. +- Read `./.github/codex/pr-review-context.md` for PR metadata and the diff commands. +- Return a markdown PR comment starting with `## Codex Review`. +- Tag each finding with a severity (P0 / P1 / P2), file path, and line number when known confidently. diff --git a/.github/pi/pr-review.prompt.md b/.github/pi/pr-review.prompt.md index 93ad730ba0..92f128c6b1 100644 --- a/.github/pi/pr-review.prompt.md +++ b/.github/pi/pr-review.prompt.md @@ -1,26 +1,6 @@ -You are reviewing a GitHub pull request for this repository. +# Pi output format -Review policy: -- Read `AGENTS.md` (and any `AGENTS.md` in directories containing changed files) before reviewing — it is the project's contributor guide. -- Only report issues you are confident are real and introduced by this pull request. -- Focus on bugs, security problems, and clear `AGENTS.md` violations. -- Do not report style nits, speculative concerns, pre-existing issues, or anything a normal linter/typechecker would obviously catch. -- Keep the review high signal. If there is no clear issue, return no findings. - -Repository context: -- Read `./.github/pi/pr-review-context.md` for PR metadata and the exact diff commands to use. -- If the context file contains an "Additional reviewer instructions:" section, treat it as extra guidance from the human who triggered this review and follow it. -- If the context file contains a "Prior PR discussion" section, this PR has already received review activity. Look for your own previous "## Pi Review (DeepSeek V4)" comment, take it into account, focus on what changed in the latest commits, and do not repeat findings the human already pushed back on or addressed. -- Run those `git diff` / `git log` commands to inspect the changes — they reference the base and head SHAs of this PR. -- Review only the changes introduced by this PR. Read additional files only when the diff is not enough to validate a finding. -- Do not modify any files. Do not create new files outside this review. - -Output requirements: -- Return a GitHub PR comment in markdown, not JSON. -- Start the comment with `## Pi Review (DeepSeek V4)`. -- Give a short overall summary first. -- If you found high-signal issues, list them in a short numbered list with file paths and line numbers when you know them confidently. -- If you found no high-signal issues, say that explicitly. -- End with a `### Reproduction instructions` section containing a short descriptive paragraph for a tester explaining how to navigate the app to observe the change. Do not make it a numbered list. If the diff is not enough to infer this safely, say that plainly. -- Prefer at most 10 findings. +- Read `./.github/pi/pr-review-context.md` for PR metadata and the diff commands. +- Return a markdown PR comment starting with `## Pi Review`. +- Tag each finding with a severity (P0 / P1 / P2), file path, and line number when known confidently. - Output ONLY the final review markdown — no preamble, no thinking, no tool transcripts. diff --git a/.github/review-prompt-shared.md b/.github/review-prompt-shared.md new file mode 100644 index 0000000000..2070247fd0 --- /dev/null +++ b/.github/review-prompt-shared.md @@ -0,0 +1,56 @@ +# Pull request review — shared policy + +You are reviewing a GitHub pull request for this repository. Apply this policy alongside your tool's output requirements. + +## Read the project rules first + +- Read `AGENTS.md` (repo root) and any `AGENTS.md` in directories touched by the diff before reviewing — they are the canonical contributor guide. +- `CLAUDE.md` in this repo is a wrapper around `AGENTS.md` (`@AGENTS.md`) — the same content. +- Quote the exact rule from `AGENTS.md` when flagging a violation. + +## Review policy + +- Only report issues you are confident are real and introduced by this pull request. +- Focus on bugs, security problems, performance, and clear `AGENTS.md` violations. +- Do not report style nits, speculative concerns, pre-existing issues, or anything a normal linter / typechecker would obviously catch. +- Self-validate each finding before posting: "is this definitely a real issue?" If uncertain, discard it. +- Read additional files only when the diff is not enough to validate a finding. +- Do not modify any files. + +## Severity triage + +Tag each finding with a severity. Always report P0 and P1. Report P2 only when the diff invites it (a new `pub fn`, a new module, a new exported component, a meaningful refactor). + +- **P0** — RCE, auth bypass, data loss, secrets in code, SQL injection, path traversal, broken auth on a public surface. +- **P1** — significant bug, missing auth/authorization check on a new public surface, blocking I/O on a likely async path, race condition, missing input validation on caller-controlled parameters, observable performance regression. +- **P2** — wrong module placement, doc/code mismatch, half-finished public abstractions (`pub fn` + `#[allow(dead_code)]` + `TODO`), `AGENTS.md` style violations, naming that contradicts the function's behavior. + +## Checklist for new public surfaces + +For any new `pub fn` / `pub async fn` / exported Svelte component / exported prop introduced by this PR, verify: + +- (a) auth/authorization expectations are documented in the doc comment OR enforced in the function body. A new `pub fn` that touches workspace data, secrets, files, or processes without an auth check or documented "caller MUST verify" contract is a P1. +- (b) the function is placed in a module whose stated purpose matches what it does. Check the module-level doc comment (`//!`) — a config-file reader inside `external_ip.rs` is a P2. +- (c) it is not half-finished. `pub fn` + `#[allow(dead_code)]` + a `TODO` is a smell that says the function should land together with its caller, not separately. Cite the relevant `AGENTS.md` rule. +- (d) input validation defends against injection / traversal / overflow / NUL bytes at every parameter that may be caller-controlled. + +## Test coverage assessment + +End your review with a short "Test coverage" section calibrated to the layers actually changed by the diff. Skip categories the PR does not touch. + +- **Backend** (Rust under `backend/`) — expect Rust unit tests for new logic. For new or modified API handlers, worker steps, queue/cron behavior, or DB access, also expect or note the absence of integration tests. Pure-refactor backend PRs don't need new tests if existing tests cover the surface. +- **Frontend** (Svelte / TS under `frontend/`) — the codebase does not generally test Svelte components, so do not ask for component tests. Only flag missing tests for new pure-logic utilities (the kind of file that already has a sibling `*.test.ts`, e.g. `flowDiff`, `previousResults`, copilot logic). +- **CI / workflows / docs / config-only** — no automated tests expected; say so explicitly so the reader knows you considered it. + +Then state what manual verification, if any, is still needed before merge: + +- Describe each manual scenario as a short paragraph (not a numbered list): what page / action / input, and what observable outcome confirms correctness. +- If the diff has no in-app surface to exercise (purely backend internals, CI, docs, or refactor), say that plainly. + +## Additional reviewer instructions + +If the prompt or context includes an "Additional reviewer instructions" section, treat it as extra guidance from the human who triggered this review and follow it. + +## Prior PR discussion + +If the prompt or context includes a "Prior PR discussion" section, this PR has already received review activity. Look for your own previous comment, take it into account, focus on what changed in the latest commits, and do not repeat findings the human already pushed back on or addressed. diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml index 79b77f842d..fdd1d5c19a 100644 --- a/.github/workflows/codex-pr-review.yml +++ b/.github/workflows/codex-pr-review.yml @@ -263,13 +263,14 @@ jobs: - name: Run Codex review if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' run: | + cat .github/review-prompt-shared.md .github/codex/pr-review.prompt.md > /tmp/codex-prompt.md codex exec \ -C "$GITHUB_WORKSPACE" \ -m gpt-5.5 \ -c 'model_reasoning_effort="xhigh"' \ -s danger-full-access \ -o codex-final-message.md \ - - < .github/codex/pr-review.prompt.md + - < /tmp/codex-prompt.md - name: Post Codex review comment if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' diff --git a/.github/workflows/pi-pr-review.yml b/.github/workflows/pi-pr-review.yml index d7c743fc16..69767661c0 100644 --- a/.github/workflows/pi-pr-review.yml +++ b/.github/workflows/pi-pr-review.yml @@ -251,12 +251,13 @@ jobs: PI_SKIP_VERSION_CHECK: '1' run: | set -o pipefail + cat .github/review-prompt-shared.md .github/pi/pr-review.prompt.md > /tmp/pi-prompt.md pi -p \ --provider deepseek \ --model deepseek-v4-pro \ --tools read,grep,find,ls,bash \ --mode json \ - < .github/pi/pr-review.prompt.md \ + < /tmp/pi-prompt.md \ | tee pi-events.jsonl \ | jq -rc --unbuffered ' if .type == "agent_start" then "🤖 pi agent started" diff --git a/.github/workflows/pr-ready-review.yml b/.github/workflows/pr-ready-review.yml index 0058ffa8d9..342d7c37b3 100644 --- a/.github/workflows/pr-ready-review.yml +++ b/.github/workflows/pr-ready-review.yml @@ -124,10 +124,12 @@ jobs: run: | { echo 'REVIEW_PROMPT< Date: Mon, 4 May 2026 17:41:54 +0000 Subject: [PATCH 012/313] ci: cross-agent local-review skill (Claude + Pi share one file, Codex via wrapper) (#9037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: make local-review a single-source-of-truth cross-agent skill The repo already had parallel skills directories (.agents/skills/ and .claude/skills/) drifting between agents. Consolidate local-review onto one canonical file in .agents/ and symlink the .claude/ entry to it so Claude Code and Pi share the exact same SKILL.md (Anthropic's Skills format is supported by both, only the discovery directory differs). The canonical SKILL.md now points reviewers at .github/review-prompt-shared.md as the policy source — same shared prompt the GitHub auto-review workflows already use — so local reviews and CI reviews stay in lockstep. Codex CLI doesn't support repo-level slash commands (its prompts live in ~/.codex/prompts/). For Codex parity, ship scripts/local-review.sh which pipes the SKILL + shared policy into 'codex exec' (or 'pi -p' as a uniform entry point). Update AGENTS.md to document the three invocation paths. Co-Authored-By: Claude Opus 4.7 (1M context) * ci: make all skills cross-agent — single source in .agents/, symlink .claude/ Turn every skill into a single canonical file under .agents/skills/ and a symlink under .claude/skills/. Editing any one SKILL.md now updates all three CLIs (Claude Code reads .claude/, Codex and Pi auto-discover .agents/). Per-skill resolution: - local-review: already symlinked (prior PR #9037) - rust-backend, svelte-frontend: identical content → symlink, no edit - refine: only differed in user_invocable frontmatter → add to canonical - native-trigger: .claude/ had a newer Step 17 (sidebar visibility) missing from .agents/ → use Claude content as canonical - commit: .claude/ embedded a Claude-specific Co-Authored-By trailer the harness already injects automatically → drop from canonical, use agent-neutral .agents/ version - pr: generalize "Run /local-review" to "Invoke the local-review skill (/local-review in Claude Code, $local-review in Codex, pi --skill local-review in Pi)" and drop the Claude-specific "Generated with Claude Code" attribution from the PR body template — the harness that invoked the skill can add its own trailer if desired - adding-a-trigger: was only in .claude/ → move to .agents/ canonical - update-sqlx: was only in .agents/ → add .claude/ symlink Also drop scripts/local-review.sh — wrapper is redundant now that all three CLIs natively discover the skill from their respective directories. Update AGENTS.md accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .agents/skills/adding-a-trigger/SKILL.md | 267 ++++++++ .agents/skills/commit/SKILL.md | 1 + .agents/skills/local-review/SKILL.md | 81 +-- .agents/skills/native-trigger/SKILL.md | 13 +- .agents/skills/pr/SKILL.md | 21 +- .agents/skills/refine/SKILL.md | 1 + .claude/skills/adding-a-trigger/SKILL.md | 268 +------- .claude/skills/commit/SKILL.md | 61 +- .claude/skills/local-review/SKILL.md | 72 +- .claude/skills/native-trigger/SKILL.md | 794 +---------------------- .claude/skills/pr/SKILL.md | 112 +--- .claude/skills/refine/SKILL.md | 40 +- .claude/skills/rust-backend/SKILL.md | 108 +-- .claude/skills/svelte-frontend/SKILL.md | 81 +-- .claude/skills/update-sqlx/SKILL.md | 1 + AGENTS.md | 2 +- 16 files changed, 324 insertions(+), 1599 deletions(-) create mode 100644 .agents/skills/adding-a-trigger/SKILL.md mode change 100644 => 120000 .claude/skills/adding-a-trigger/SKILL.md mode change 100644 => 120000 .claude/skills/commit/SKILL.md mode change 100644 => 120000 .claude/skills/local-review/SKILL.md mode change 100644 => 120000 .claude/skills/native-trigger/SKILL.md mode change 100644 => 120000 .claude/skills/pr/SKILL.md mode change 100644 => 120000 .claude/skills/refine/SKILL.md mode change 100644 => 120000 .claude/skills/rust-backend/SKILL.md mode change 100644 => 120000 .claude/skills/svelte-frontend/SKILL.md create mode 120000 .claude/skills/update-sqlx/SKILL.md diff --git a/.agents/skills/adding-a-trigger/SKILL.md b/.agents/skills/adding-a-trigger/SKILL.md new file mode 100644 index 0000000000..7d8643b862 --- /dev/null +++ b/.agents/skills/adding-a-trigger/SKILL.md @@ -0,0 +1,267 @@ +--- +name: adding-a-trigger +description: Checklist for adding a new TriggerCrud-based trigger type to Windmill (Azure, GCP, Kafka, etc.). Use when wiring a new trigger kind across backend, frontend, CLI, and capture infrastructure. +--- + +# Skill: Adding a New Trigger Type + +Use this skill when adding a trigger kind that implements `TriggerCrud` (Kafka, GCP, Azure, MQTT, SQS, NATS, Postgres, Email…). For native triggers (Nextcloud, Google Drive — things wired through `windmill-native-triggers`), use the `native-trigger` skill instead. + +The goal of this doc is to enumerate every file that needs to change. Missing any one of them leads to silent regressions: sync drops the trigger, capture button does nothing, workspace forks lose it, sidebar counters undercount. Follow the checklist top-to-bottom — each section is independent enough to be validated on its own. + +Throughout this doc, substitute `{kind}` for the new trigger kind (`azure`, `kafka`, …), `{Kind}` for PascalCase (`Azure`, `Kafka`), `{KIND}` for SCREAMING (`AZURE`, `KAFKA`). + +## Reference implementations + +- **GCP** — closest analogue to Azure. Has push + pull, OIDC auth, ARM-like resource paths, capture handler. Grep for `gcp_trigger` / `GcpTrigger`. +- **Kafka** — simpler (pull-only, streaming). Good for trivial integrations. +- **Azure** — most recently added (2026). Shared-secret push auth, Event Grid namespaces + basic topics, ARM resource discovery, Namespace-pull data-plane. Grep for `azure_trigger` / `AzureTrigger`. + +## 1. Database migration + +Create a migration: `cargo sqlx migrate add -r add_{kind}_trigger` from `backend/`. Never write timestamps manually. + +The `up.sql` usually defines: +- An optional enum type (e.g. `AZURE_MODE`) if the trigger has sub-kinds +- The `{kind}_trigger` table with at minimum these columns (mirrored from kafka/gcp): + - primary: `(workspace_id, path)` + - `script_path`, `is_flow`, `enabled`, `mode`, `permissioned_as`, `edited_by`, `email` + - `edited_at`, `error`, `server_id`, `last_server_ping` + - `error_handler_path`, `error_handler_args jsonb`, `retry jsonb` + - trigger-specific fields +- Indexes on foreign keys + any frequently-filtered columns +- Foreign key to `workspace` + +Down migration drops the table and any enum types. + +## 2. Backend crate (`windmill-trigger-{kind}`) + +Create a new crate under `backend/windmill-trigger-{kind}/` with: + +- `Cargo.toml`: features `enterprise`, `private` if EE, standard deps +- `src/lib.rs`: `pub use mod_ee::*;` behind `#[cfg(all(feature = "enterprise", feature = "private"))]` +- `src/mod_ee.rs`: core types + helpers +- `src/handler_ee.rs`: `TriggerCrud` impl + route handlers +- `src/listener_ee.rs`: (only if streaming/pull-based) `Listener` trait impl + +Required in `mod_ee.rs`: +- `{Kind}Config` struct (persisted shape, `FromRow`) +- `{Kind}ConfigRequest` struct (what API receives — usually similar to Config but with validation fields) +- `{Kind}Trigger` unit struct (implements the traits) +- `impl TriggerJobArgs for {Kind}Trigger` — sets `TRIGGER_KIND`, `Payload`, `v1_payload_fn` + +Required in `handler_ee.rs`: +- `#[async_trait] impl TriggerCrud for {Kind}Trigger` with: + - `type Trigger = Trigger<{Kind}Config>` + - `type TriggerConfigRequest = {Kind}ConfigRequest` + - `const ROUTE_PREFIX: &'static str = "/{kind}_triggers";` + - `const TABLE_NAME`, `ADDITIONAL_SELECT_FIELDS` + - `get_deployed_object`, `validate_config`, `create_trigger`, `update_trigger`, `delete_trigger`, `test_connection` + - `additional_routes` (optional — mount extra endpoints for things like ARM resource listing, topic discovery) + +Register the crate in `backend/Cargo.toml` as a workspace member and as a dep of `windmill-api` behind the feature flag. + +## 3. Wire into `windmill-api` (feature-gated everywhere) + +**`backend/windmill-api/src/triggers/handler.rs`** — mount the trigger crate: +```rust +#[cfg(all(feature = "enterprise", feature = "{kind}_trigger", feature = "private"))] +{ + use crate::triggers::{kind}::{Kind}Trigger; + router = router.nest({Kind}Trigger::ROUTE_PREFIX, complete_trigger_routes({Kind}Trigger)); +} +``` + +**`backend/windmill-api/src/triggers/{kind}/mod.rs`** — re-export the crate: +```rust +pub use windmill_trigger_{kind}::*; +``` + +**`backend/windmill-api/src/lib.rs`** — if the trigger receives inbound pushes, add a webhook route: +```rust +.nest("/{kind}/w/{workspace_id}", { + #[cfg(all(feature = "enterprise", feature = "{kind}_trigger", feature = "private"))] + { triggers::{kind}::handler_oss::{kind}_push_route_handler() } + #[cfg(not(...))] + { Router::new() } +}) +``` + +## 4. `TriggerKind` enum (`backend/windmill-types/src/triggers.rs`) + +Already has slots for most triggers but verify your variant exists: +- Add `{Kind}` to the `TriggerKind` enum +- Add match arm in `to_key()` +- Add match arm in `from_str` +- Add match arm in `JobTriggerKind` (if jobs need kind tagging) + +## 5. OpenAPI (`backend/windmill-api/openapi.yaml`) + +This file is huge and the single most-forgotten place. Add: + +- `/w/{workspace}/{kind}_triggers/create` + `/update/{path}` + `/delete/{path}` + `/get/{path}` + `/list` + `/exists/{path}` + `/setmode/{path}` + `/test` paths (mirror gcp section) +- Any `additional_routes` your handler exposes (resource discovery, etc.) +- Schemas: `{Kind}Trigger`, `{Kind}TriggerData`, `{Kind}Mode` (if enum), `{Kind}DeliveryConfig`, helper request/response types +- Add `{kind}` to `CaptureTriggerKind` enum +- Add `{kind}_used: boolean` to the `UsedTriggers` response schema + +Regenerate frontend client: `npm run generate-backend-client` from `frontend/`. + +## 6. `UsedTriggers` + workspace export + +**`backend/windmill-api-workspaces/src/workspaces.rs`** — add `{kind}_used: bool` to the `UsedTriggers` struct and add an `EXISTS(SELECT 1 FROM {kind}_trigger …)` to the `get_used_triggers` query. + +**`backend/windmill-api/src/workspaces_export.rs`** — add export block mirroring gcp's (export lists all triggers, serializes them to YAML/JSON). The block re-uses the `trigger_ignore_keys` variable so the new kind automatically participates in fork-export stripping (`mode` field is omitted when the source workspace is a fork — keeps fork→parent merges from flipping the parent's enabled state). + +**Fork cloning (`clone_triggers_and_schedules` in workspaces.rs)** — add an `INSERT INTO {kind}_trigger ... SELECT ...` block that copies all rows from the parent workspace, forcing `mode = 'disabled'::TRIGGER_MODE`. Always runs at fork creation; forgetting this means users can't carry `{kind}` triggers into their forks. + +## 6.5 Hardcoded trigger-kind arrays (silent-failure hotspots) + +Several files keep **hardcoded arrays** of trigger kind strings. Miss one and ACL checks / user offboarding / trash drop your kind: + +- **`backend/windmill-api-groups/src/granular_acls.rs`** — `KINDS: [&str; N]`. **Increment N** (the compile error is cryptic otherwise). Controls which kinds accept granular ACL operations. +- **`backend/windmill-api-users/src/users.rs`** (`extra_perms_tables`) — which tables get `extra_perms` entries cleaned when a user is deleted. +- **`backend/windmill-api/src/offboarding.rs`** — three separate arrays (enumeration, fork-copy, and delete paths). **All three** need the new kind. +- **`backend/windmill-api/src/trash.rs`** — `valid_tables` for the trash / restore API. +- **`backend/windmill-git-sync/src/lib.rs`** — add a test assertion for `DeployedObject::{Kind}Trigger.get_kind() == "{kind}_trigger"` (the `get_kind` match arm itself lives in the enum impl — already required by the Rust compiler). +- **`backend/windmill-api-auth/src/scopes.rs`** — add the `{Kind}Triggers` variant to `ScopeDomain` enum + `as_str` match + `from_str` match. Required for the OAuth/token system to recognise `{kind}_triggers:read|write` scopes. +- **`backend/windmill-api/src/token.rs`** (`build_trigger_scope_domains` → `TRIGGER_DOMAINS`) — add `("{kind}_triggers", "{Kind display name}")` so the CreateToken UI's scope selector surfaces the `read` / `write` checkboxes. + +**OpenAPI enums** to extend (do NOT forget — generated client will allow it but server rejects as 400): +- `CaptureTriggerKind` enum +- Three `kind` enums under `/w/{workspace}/acls/{get,add,remove}/{kind}/{path}` (yes, same list repeated three times) + +After editing any of these, run a full `cargo check` with your feature flag + `gcp_trigger` + other core flags — the `KINDS: [&str; N]` length mismatch only surfaces when the crate compiles. + +## 7. Capture infrastructure (`backend/windmill-api/src/capture.rs`) + +If the trigger supports push delivery, it also needs a capture endpoint so users can test it: + +- `{Kind}TriggerConfig` struct (gated by feature flags) +- `TriggerConfig::{Kind}` variant +- `set_{kind}_trigger_config` function (creates the subscription/equivalent pointing at the capture URL — use your `manage_{kind}_subscription` helper with `trigger_mode=false`) +- Both real + no-op versions behind feature gates +- `TriggerKind::{Kind} => set_{kind}_trigger_config(...)` arm in `set_config` +- `{kind}_payload` async handler — validates auth (if any), processes payload, calls `insert_capture_payload` +- Route: `.route("/{kind}/{runnable_kind}/{*path}", post({kind}_payload))` inside `workspaced_unauthed_service` — and expand the surrounding `#[cfg(any(...))]` to include your feature flag + +## 8. CLI (`cli/`) — easy to miss, breaks sync silently + +Check all of these: + +**`cli/src/types.ts`:** +- Add `"{kind}"` to `TRIGGER_TYPES` array +- Add `"{kind}_trigger"` to `getTypeStrFromPath` return union +- Add match case in `getTypeStrFromPath`'s `typeEnding ===` chain +- Add `pushTrigger("{kind}", ...)` branch in `pushObj` + +**`cli/src/commands/trigger/trigger.ts`:** +- Import `{Kind}Trigger` type +- Add `{kind}: {Kind}Trigger` to the `Trigger` type map +- Add `{kind}: wmill.get{Kind}Trigger`, `update{Kind}Trigger`, `create{Kind}Trigger` to each function map +- Add `{kind}: { ... }` template to `triggerTemplates` +- Add `list{Kind}Triggers` call + spread in the `list` aggregation +- Update `--kind` option descriptions to mention the new kind + +**`cli/src/commands/sync/sync.ts`:** +- Add `path.endsWith(".{kind}_trigger" + ext)` in the file-type filter +- Add `typ == "{kind}_trigger"` in `getTypeOrder` +- Add `"{kind}_trigger"` to the delete-suffix regex (~line 3092) +- Add a `case "{kind}_trigger"` in the delete switch + +**`cli/src/guidance/skills.ts`** — **DO NOT EDIT DIRECTLY**. It's auto-generated by `system_prompts/generate.py`. Instead: +- Edit `system_prompts/utils.py` → append `('{Kind}Trigger', '{kind}_trigger')` to the `SCHEMA_MAPPINGS['triggers']` list (this is the master list — the one in `generate.py` is duplicated and `utils.py` wins) +- Then run `python3 system_prompts/generate.py` — it regenerates `cli/src/guidance/skills.ts` with the schema extracted from `backend/windmill-api/openapi.yaml` +- Commit the regenerated file + +## 9. Frontend — editor + drawer + +Under `frontend/src/lib/components/triggers/{kind}/`: + +- `{Kind}TriggerPanel.svelte` — the tile shown in the triggers listing +- `{Kind}TriggerEditor.svelte` — outer drawer wrapper +- `{Kind}TriggerEditorInner.svelte` — state + business logic; must expose: + - `openEdit(path, isFlow, defaultValues?)` method + - `isEditor` prop, `onConfigChange` + `onCaptureConfigChange` callbacks + - `get{Kind}Config()` + `get{Kind}CaptureConfig()` helpers + - `captureConfig = $derived.by(untrack(() => isEditor) ? get{Kind}CaptureConfig : () => ({}))` + - `$effect(() => { const args = [captureConfig, isValid] as const; untrack(() => onCaptureConfigChange?.(...args)) })` +- `{Kind}TriggerEditorConfigSection.svelte` — form fields; use design-system components (`TextInput`, `Select`, `Toggle`, `ToggleButtonGroup`), never raw `` +- `{Kind}Capture.svelte` — capture panel; wraps `CaptureSection` with `captureType="{kind}"` +- `utils.ts` — `requestBody` builders and any trigger-type-specific helpers + +## 10. Frontend — global integration + +Easy to miss: + +- **`frontend/src/lib/components/triggers.ts`** — add `'{kind}'` to the `TriggerKind` union +- **`frontend/src/lib/components/triggers/CaptureWrapper.svelte`**: + - Import `{Kind}Capture` + - Add to `isStreamingCapture()` array (streaming = pull-style; push-style is typically `false`) + - Add `{:else if captureType === '{kind}'}` branch with the `<{Kind}Capture>` render +- **`frontend/src/lib/components/sidebar/SidebarContent.svelte`** — import the icon, add the nav entry +- **`frontend/src/lib/components/sidebar/OperatorMenu.svelte`** — add the operator-mode entry +- **`frontend/src/routes/(root)/(logged)/+layout.svelte`** — destructure `{kind}_used` from `/get_used_triggers` response, push `'{kind}'` into `usedKinds` +- **`frontend/src/lib/components/search/GlobalSearchModal.svelte`** — import icon, add "Go to {Kind} ..." entry +- **`frontend/src/lib/components/offboarding-utils.ts`** — add mappings `{kind}_trigger: '{kind}_triggers'` and `{kind}_trigger: '{kind} trigger'` +- **`frontend/src/lib/components/icons/{Kind}Icon.svelte`** — single-path SVG, `fill={color ?? 'currentColor'}`, `size` prop default 16 (match existing icons — don't hardcode colors, don't use `width`/`height` props) +- **`frontend/src/routes/(root)/(logged)/{kind}_triggers/+page.svelte`** — listing page (mirror `gcp_triggers/+page.svelte` for push+pull, `kafka_triggers` for pure streaming) +- **`frontend/src/lib/components/CompareWorkspaces.svelte`** — workspace fork / compare tool. Needs: service import, editor import, `{kind}Editor` `$state`, `case '{kind}'` in `openTriggerDetails()`, entry in `triggerServices` object (list/delete/normalize), and `<{Kind}TriggerEditor bind:this={{kind}Editor} />` in the template + +## 10.5 AI system prompts (`system_prompts/`) + +- **`system_prompts/utils.py`** — append `('{Kind}Trigger', '{kind}_trigger')` to `SCHEMA_MAPPINGS['triggers']` (master list used by code generation + CLI skills) +- **`system_prompts/generate.py`** — also has a duplicated `schema_types` list (~line 903) for the AI `triggers` skill content. Add `('{Kind}Trigger', '{kind}_trigger')` there too +- **`system_prompts/generate.py`** `schema_names` (~line 1192) — add `'{Kind}Trigger'` (add `'New{Kind}Trigger'` only if the OpenAPI declares one; GCP and Azure don't) +- Run `python3 system_prompts/generate.py` — this rewrites `cli/src/guidance/skills.ts` and all `auto-generated/` docs. Commit the regenerated files + +## 11. Validation + +Run all of these before declaring done: + +```bash +# Backend +cd backend +cargo check --features enterprise,{kind}_trigger,private # minimal +cargo check --features enterprise,azure_trigger,private,gcp_trigger,http_trigger,mqtt_trigger,postgres_trigger,sqs_trigger,kafka,nats,smtp,websocket # full + +# SQLx offline data (never run `cargo sqlx prepare` directly — use the wrapper) +./update_sqlx.sh + +# Frontend +cd frontend +npm run generate-backend-client +npm run check:fast +``` + +Smoke test in the UI: create a trigger, save, check it appears in sidebar + search, delete, re-create via CLI `wmill sync`. + +## 12. Common pitfalls + +- **Forgetting feature gates in `workspaced_unauthed_service()`** — the surrounding `#[cfg(any(...))]` expression must include your feature flag, not just the inner `#[cfg]` on the route +- **`.route(path, ...).route(path, ...)` with same path and different methods** — older axum replaced; use `.route(path, post(h1).options(h2))` to chain methods on the same `MethodRouter` +- **`on:event` directives** — legacy Svelte 4, no-op in runes mode. Use callback props (`onSelected`, `onConfigChange`) +- **`$bindable(default_value)` on optional props** — banned by project CLAUDE.md. Use `$bindable()` + `$derived(prop ?? default)` instead +- **CORS layer intercepting OPTIONS** — tower-http CorsLayer short-circuits OPTIONS before reaching your handler. For server-to-server webhook endpoints, drop the CORS layer entirely (CORS is browser-only) +- **DeliveryAttributeMappings / custom headers for auth** — prefer HMAC or sha256-hashed shared secrets over opaque JWTs when the provider doesn't support signed tokens natively. Store only the hash; regenerate secret on every save +- **ARM / API resource-listing cascades** — if the trigger's resource type is deep (Azure: subscription → RG → namespace → topic), offer dropdowns in the UI populated from the provider's APIs using the user's credential resource +- **Clearing stale selections on dependency change** — when a dropdown's underlying data reloads (e.g., user changes SP or edition), clear selections that no longer match the new list +- **Workspace-scoped tag compatibility** — if the trigger has tags, verify forked workspaces handle them (see commit `0773b5bc85` for a historical fix) + +## 13. EE file split + +If the trigger is enterprise-only, the code lives in `windmill-ee-private__worktrees/.../windmill-trigger-{kind}/src/*_ee.rs` and is symlinked into the OSS tree. The `windmill-ee-private__worktrees/` directory holds the real files; changes propagate via symlinks. See `docs/enterprise.md` for the workflow. + +## 14. Final checklist before PR + +- [ ] Migration up/down tested (revert + re-apply) +- [ ] `./update_sqlx.sh` committed the updated `.sqlx/` offline data +- [ ] `cargo check` passes with your feature flag + with all trigger features +- [ ] `npm run check:fast` passes +- [ ] Trigger visible in sidebar with correct icon weight (not oversized/colored — use `currentColor`) +- [ ] Create, edit, delete flow all work in the UI +- [ ] Capture button works (if push-capable) +- [ ] Trigger appears in `/get_used_triggers` → sidebar pulse +- [ ] `wmill sync pull` + `wmill sync push` both round-trip the trigger +- [ ] `wmill trigger list` includes it +- [ ] OpenAPI schemas are complete (no `null` in generated types) diff --git a/.agents/skills/commit/SKILL.md b/.agents/skills/commit/SKILL.md index 3f97552466..114531570e 100644 --- a/.agents/skills/commit/SKILL.md +++ b/.agents/skills/commit/SKILL.md @@ -1,5 +1,6 @@ --- name: commit +user_invocable: true description: Create a git commit with conventional commit format. MUST use anytime you want to commit changes. --- diff --git a/.agents/skills/local-review/SKILL.md b/.agents/skills/local-review/SKILL.md index ad701ac367..3477911176 100644 --- a/.agents/skills/local-review/SKILL.md +++ b/.agents/skills/local-review/SKILL.md @@ -1,97 +1,66 @@ --- name: local-review -description: Code review a pull request for bugs and CLAUDE.md compliance. MUST use when asked to review code. +description: Code review the current PR (or branch diff against main) for bugs, security, and AGENTS.md compliance. MUST use when asked to review code. --- -# Local Code Review Skill +# Local Code Review -Review a pull request for real bugs and CLAUDE.md compliance violations. This review targets HIGH SIGNAL issues only. +Run the same review locally that the GitHub auto-review actions run on PRs (Claude / Codex / Pi). The review policy lives in `.github/review-prompt-shared.md` — read that first. -## Review Philosophy +## Steps -- **Only flag issues you are certain about.** If you are not sure an issue is real, do not flag it. False positives erode trust and waste reviewer time. -- Think like a senior engineer doing a final review — flag things that would cause incidents, not things that are merely imperfect. +1. **Read `.github/review-prompt-shared.md`** for the review policy: severity triage (P0 / P1 / P2), the new-public-surface checklist, AGENTS.md compliance, and the test-coverage assessment. -## What to Flag - -- Code that won't compile or parse (syntax errors, type errors, missing imports) -- Code that will definitely produce wrong results regardless of inputs -- Clear, unambiguous CLAUDE.md violations (quote the exact rule being violated) -- Security issues in introduced code (injection, auth bypass, data exposure) -- Incorrect logic that will fail in production - -## What NOT to Flag - -- Code style or quality concerns -- Potential issues that depend on specific inputs or runtime state -- Subjective suggestions or improvements -- Pre-existing issues not introduced by this PR -- Pedantic nitpicks a senior engineer wouldn't flag -- Issues a linter or type checker will catch -- General quality concerns unless explicitly prohibited in CLAUDE.md -- Issues silenced via lint ignore comments - -## Execution Steps - -1. **Determine the PR scope**: - - If an argument is provided, use it as the PR number or branch - - Otherwise, detect from the current branch vs main - - Run `gh pr view` if a PR exists, or use `git diff main...HEAD` - -2. **Find relevant CLAUDE.md files**: - - Read the root `CLAUDE.md` - - Check for CLAUDE.md files in directories containing changed files +2. **Determine the PR scope**: + - If an argument is provided, treat it as a PR number or branch. + - Otherwise, detect from the current branch vs `main`. + - Run `gh pr view` if a PR exists; otherwise compare against `main` with `git diff main...HEAD`. 3. **Get the diff and metadata**: - - `gh pr diff` or `git diff main...HEAD` for the full diff - - `gh pr view` or `git log main..HEAD --oneline` for context + - `gh pr diff` or `git diff main...HEAD` for the full diff. + - `gh pr view` or `git log main..HEAD --oneline` for context. -4. **Read changed files** where the diff alone is insufficient to understand context +4. **Read changed files** when the diff alone is insufficient. -5. **Review for**: - - CLAUDE.md compliance — check each rule against the changed code - - Bugs and logic errors — will this code work correctly? - - Security issues — injection, auth, data exposure in new code +5. **Apply the policy** from `.github/review-prompt-shared.md`. Self-validate each finding before reporting (real issue? would a senior engineer flag it?). -6. **Self-validate each finding**: Before reporting, ask yourself: - - "Is this definitely a real issue, not a false positive?" - - "Would a senior engineer flag this in review?" - - If the answer to either is no, discard the finding +6. **Output findings** to the terminal (default) or post as PR comments (with `--comment` flag). -7. **Output findings** to the terminal (default) or post as PR comments (with `--comment` flag) - -## Output Format +## Output format ``` ## Code review Found N issues: -1. () +1. [P0|P1|P2] -2. () +2. [P0|P1|P2] ``` +End with a `Test coverage` section per the shared policy. + If no issues are found: ``` ## Code review -No issues found. Checked for bugs and CLAUDE.md compliance. +No issues found. Checked for bugs, security, and AGENTS.md compliance. ``` -## Posting Comments (--comment flag) +## Posting comments (`--comment`) -If the user passes `--comment`, post findings as inline PR comments using: +For a top-level PR comment: ```bash gh pr review --comment --body "" ``` -Or for inline comments on specific lines: +For inline comments on specific lines: ```bash -gh api repos/{owner}/{repo}/pulls/{pr}/reviews -f body="" -f event="COMMENT" -f comments="[...]" +gh api repos/{owner}/{repo}/pulls/{pr}/reviews \ + -f body="" -f event="COMMENT" -f comments="[...]" ``` diff --git a/.agents/skills/native-trigger/SKILL.md b/.agents/skills/native-trigger/SKILL.md index 781e200d0c..ae38f70b32 100644 --- a/.agents/skills/native-trigger/SKILL.md +++ b/.agents/skills/native-trigger/SKILL.md @@ -607,7 +607,18 @@ In `frontend/src/lib/components/triggers/TriggersEditor.svelte`: Add your service to the `nativeTriggerServices` map in `deleteDeployedTrigger()`. Native triggers use `NativeTriggerService.deleteNativeTrigger({ workspace, serviceName, externalId })` instead of the standard `path`-based delete. -### Step 17: Update OpenAPI Spec and Regenerate Types +### Step 17: Update `getUsedTriggers` for Sidebar Visibility + +The sidebar (`frontend/src/lib/components/sidebar/SidebarContent.svelte`) shows native-trigger links only if `$usedTriggerKinds` includes the service — without this, your trigger page will never appear in the nav bar even when triggers exist. + +1. **Backend** — add `{service}_used: bool` to the `UsedTriggers` struct and SELECT in `backend/windmill-api-workspaces/src/workspaces.rs::get_used_triggers()`: + ```rust + EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = '{service}'::native_trigger_service) AS "{service}_used!" + ``` +2. **OpenAPI** — add `{service}_used: boolean` to the response schema for `GET /w/{workspace}/workspaces/used_triggers` (under both `properties` and `required`). +3. **Layout** — in `frontend/src/routes/(root)/(logged)/+layout.svelte::loadUsedTriggerKinds()`, destructure `{service}_used` and push `'{service}'` to `usedKinds`. + +### Step 18: Update OpenAPI Spec and Regenerate Types Add to `JobTriggerKind` enum in `backend/windmill-api/openapi.yaml`, then: diff --git a/.agents/skills/pr/SKILL.md b/.agents/skills/pr/SKILL.md index 2efcc4e0a6..ef52d6e110 100644 --- a/.agents/skills/pr/SKILL.md +++ b/.agents/skills/pr/SKILL.md @@ -1,5 +1,6 @@ --- name: pr +user_invocable: true description: Open a draft pull request on GitHub. MUST use when you want to create/open a PR. --- @@ -50,22 +51,22 @@ The body MUST be explicit about what changed. Structure: ## Test plan - [ ] - [ ] - ---- -Generated with [Claude Code](https://claude.com/claude-code) ``` +The harness/tooling that invoked the skill may add its own attribution trailer; the skill itself does not prescribe one. + ## Execution Steps 1. Run `git status` to check for uncommitted changes 2. Run `git log main..HEAD --oneline` to see all commits in this branch 3. Run `git diff main...HEAD` to see the full diff against main -4. Check if remote branch exists and is up to date: +4. **Invoke the `local-review` skill** before creating the PR (`/local-review` in Claude Code, `$local-review` in Codex, `pi --skill local-review` / `/skill:local-review` in Pi). If issues are found, fix them and commit before proceeding. Do not skip this step. +5. Check if remote branch exists and is up to date: ```bash git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || echo "no upstream" ``` -5. Push to remote if needed: `git push -u origin HEAD` -6. Create draft PR using gh CLI: +6. Push to remote if needed: `git push -u origin HEAD` +7. Create draft PR using gh CLI: ```bash gh pr create --draft --title ": " --body "$(cat <<'EOF' ## Summary @@ -78,13 +79,10 @@ Generated with [Claude Code](https://claude.com/claude-code) ## Test plan - [ ] - [ ] - - --- - Generated with [Claude Code](https://claude.com/claude-code) EOF )" ``` -7. Return the PR URL to the user +8. Return the PR URL to the user ## EE Companion PR (when `*_ee.rs` files were modified) @@ -100,9 +98,6 @@ Follow the full EE PR workflow in `docs/enterprise.md`. The key PR-specific deta ```bash gh pr create --draft --repo windmill-labs/windmill-ee-private --title ": " --body "$(cat <<'EOF' Companion PR for windmill-labs/windmill# - - --- - Generated with [Claude Code](https://claude.com/claude-code) EOF )" ``` diff --git a/.agents/skills/refine/SKILL.md b/.agents/skills/refine/SKILL.md index b96e97e8a2..aaf747cd29 100644 --- a/.agents/skills/refine/SKILL.md +++ b/.agents/skills/refine/SKILL.md @@ -1,5 +1,6 @@ --- name: refine +user_invocable: true description: End-of-session reflection. Reviews friction encountered during the session and proposes updates to docs/ to capture lessons learned. --- diff --git a/.claude/skills/adding-a-trigger/SKILL.md b/.claude/skills/adding-a-trigger/SKILL.md deleted file mode 100644 index 7d8643b862..0000000000 --- a/.claude/skills/adding-a-trigger/SKILL.md +++ /dev/null @@ -1,267 +0,0 @@ ---- -name: adding-a-trigger -description: Checklist for adding a new TriggerCrud-based trigger type to Windmill (Azure, GCP, Kafka, etc.). Use when wiring a new trigger kind across backend, frontend, CLI, and capture infrastructure. ---- - -# Skill: Adding a New Trigger Type - -Use this skill when adding a trigger kind that implements `TriggerCrud` (Kafka, GCP, Azure, MQTT, SQS, NATS, Postgres, Email…). For native triggers (Nextcloud, Google Drive — things wired through `windmill-native-triggers`), use the `native-trigger` skill instead. - -The goal of this doc is to enumerate every file that needs to change. Missing any one of them leads to silent regressions: sync drops the trigger, capture button does nothing, workspace forks lose it, sidebar counters undercount. Follow the checklist top-to-bottom — each section is independent enough to be validated on its own. - -Throughout this doc, substitute `{kind}` for the new trigger kind (`azure`, `kafka`, …), `{Kind}` for PascalCase (`Azure`, `Kafka`), `{KIND}` for SCREAMING (`AZURE`, `KAFKA`). - -## Reference implementations - -- **GCP** — closest analogue to Azure. Has push + pull, OIDC auth, ARM-like resource paths, capture handler. Grep for `gcp_trigger` / `GcpTrigger`. -- **Kafka** — simpler (pull-only, streaming). Good for trivial integrations. -- **Azure** — most recently added (2026). Shared-secret push auth, Event Grid namespaces + basic topics, ARM resource discovery, Namespace-pull data-plane. Grep for `azure_trigger` / `AzureTrigger`. - -## 1. Database migration - -Create a migration: `cargo sqlx migrate add -r add_{kind}_trigger` from `backend/`. Never write timestamps manually. - -The `up.sql` usually defines: -- An optional enum type (e.g. `AZURE_MODE`) if the trigger has sub-kinds -- The `{kind}_trigger` table with at minimum these columns (mirrored from kafka/gcp): - - primary: `(workspace_id, path)` - - `script_path`, `is_flow`, `enabled`, `mode`, `permissioned_as`, `edited_by`, `email` - - `edited_at`, `error`, `server_id`, `last_server_ping` - - `error_handler_path`, `error_handler_args jsonb`, `retry jsonb` - - trigger-specific fields -- Indexes on foreign keys + any frequently-filtered columns -- Foreign key to `workspace` - -Down migration drops the table and any enum types. - -## 2. Backend crate (`windmill-trigger-{kind}`) - -Create a new crate under `backend/windmill-trigger-{kind}/` with: - -- `Cargo.toml`: features `enterprise`, `private` if EE, standard deps -- `src/lib.rs`: `pub use mod_ee::*;` behind `#[cfg(all(feature = "enterprise", feature = "private"))]` -- `src/mod_ee.rs`: core types + helpers -- `src/handler_ee.rs`: `TriggerCrud` impl + route handlers -- `src/listener_ee.rs`: (only if streaming/pull-based) `Listener` trait impl - -Required in `mod_ee.rs`: -- `{Kind}Config` struct (persisted shape, `FromRow`) -- `{Kind}ConfigRequest` struct (what API receives — usually similar to Config but with validation fields) -- `{Kind}Trigger` unit struct (implements the traits) -- `impl TriggerJobArgs for {Kind}Trigger` — sets `TRIGGER_KIND`, `Payload`, `v1_payload_fn` - -Required in `handler_ee.rs`: -- `#[async_trait] impl TriggerCrud for {Kind}Trigger` with: - - `type Trigger = Trigger<{Kind}Config>` - - `type TriggerConfigRequest = {Kind}ConfigRequest` - - `const ROUTE_PREFIX: &'static str = "/{kind}_triggers";` - - `const TABLE_NAME`, `ADDITIONAL_SELECT_FIELDS` - - `get_deployed_object`, `validate_config`, `create_trigger`, `update_trigger`, `delete_trigger`, `test_connection` - - `additional_routes` (optional — mount extra endpoints for things like ARM resource listing, topic discovery) - -Register the crate in `backend/Cargo.toml` as a workspace member and as a dep of `windmill-api` behind the feature flag. - -## 3. Wire into `windmill-api` (feature-gated everywhere) - -**`backend/windmill-api/src/triggers/handler.rs`** — mount the trigger crate: -```rust -#[cfg(all(feature = "enterprise", feature = "{kind}_trigger", feature = "private"))] -{ - use crate::triggers::{kind}::{Kind}Trigger; - router = router.nest({Kind}Trigger::ROUTE_PREFIX, complete_trigger_routes({Kind}Trigger)); -} -``` - -**`backend/windmill-api/src/triggers/{kind}/mod.rs`** — re-export the crate: -```rust -pub use windmill_trigger_{kind}::*; -``` - -**`backend/windmill-api/src/lib.rs`** — if the trigger receives inbound pushes, add a webhook route: -```rust -.nest("/{kind}/w/{workspace_id}", { - #[cfg(all(feature = "enterprise", feature = "{kind}_trigger", feature = "private"))] - { triggers::{kind}::handler_oss::{kind}_push_route_handler() } - #[cfg(not(...))] - { Router::new() } -}) -``` - -## 4. `TriggerKind` enum (`backend/windmill-types/src/triggers.rs`) - -Already has slots for most triggers but verify your variant exists: -- Add `{Kind}` to the `TriggerKind` enum -- Add match arm in `to_key()` -- Add match arm in `from_str` -- Add match arm in `JobTriggerKind` (if jobs need kind tagging) - -## 5. OpenAPI (`backend/windmill-api/openapi.yaml`) - -This file is huge and the single most-forgotten place. Add: - -- `/w/{workspace}/{kind}_triggers/create` + `/update/{path}` + `/delete/{path}` + `/get/{path}` + `/list` + `/exists/{path}` + `/setmode/{path}` + `/test` paths (mirror gcp section) -- Any `additional_routes` your handler exposes (resource discovery, etc.) -- Schemas: `{Kind}Trigger`, `{Kind}TriggerData`, `{Kind}Mode` (if enum), `{Kind}DeliveryConfig`, helper request/response types -- Add `{kind}` to `CaptureTriggerKind` enum -- Add `{kind}_used: boolean` to the `UsedTriggers` response schema - -Regenerate frontend client: `npm run generate-backend-client` from `frontend/`. - -## 6. `UsedTriggers` + workspace export - -**`backend/windmill-api-workspaces/src/workspaces.rs`** — add `{kind}_used: bool` to the `UsedTriggers` struct and add an `EXISTS(SELECT 1 FROM {kind}_trigger …)` to the `get_used_triggers` query. - -**`backend/windmill-api/src/workspaces_export.rs`** — add export block mirroring gcp's (export lists all triggers, serializes them to YAML/JSON). The block re-uses the `trigger_ignore_keys` variable so the new kind automatically participates in fork-export stripping (`mode` field is omitted when the source workspace is a fork — keeps fork→parent merges from flipping the parent's enabled state). - -**Fork cloning (`clone_triggers_and_schedules` in workspaces.rs)** — add an `INSERT INTO {kind}_trigger ... SELECT ...` block that copies all rows from the parent workspace, forcing `mode = 'disabled'::TRIGGER_MODE`. Always runs at fork creation; forgetting this means users can't carry `{kind}` triggers into their forks. - -## 6.5 Hardcoded trigger-kind arrays (silent-failure hotspots) - -Several files keep **hardcoded arrays** of trigger kind strings. Miss one and ACL checks / user offboarding / trash drop your kind: - -- **`backend/windmill-api-groups/src/granular_acls.rs`** — `KINDS: [&str; N]`. **Increment N** (the compile error is cryptic otherwise). Controls which kinds accept granular ACL operations. -- **`backend/windmill-api-users/src/users.rs`** (`extra_perms_tables`) — which tables get `extra_perms` entries cleaned when a user is deleted. -- **`backend/windmill-api/src/offboarding.rs`** — three separate arrays (enumeration, fork-copy, and delete paths). **All three** need the new kind. -- **`backend/windmill-api/src/trash.rs`** — `valid_tables` for the trash / restore API. -- **`backend/windmill-git-sync/src/lib.rs`** — add a test assertion for `DeployedObject::{Kind}Trigger.get_kind() == "{kind}_trigger"` (the `get_kind` match arm itself lives in the enum impl — already required by the Rust compiler). -- **`backend/windmill-api-auth/src/scopes.rs`** — add the `{Kind}Triggers` variant to `ScopeDomain` enum + `as_str` match + `from_str` match. Required for the OAuth/token system to recognise `{kind}_triggers:read|write` scopes. -- **`backend/windmill-api/src/token.rs`** (`build_trigger_scope_domains` → `TRIGGER_DOMAINS`) — add `("{kind}_triggers", "{Kind display name}")` so the CreateToken UI's scope selector surfaces the `read` / `write` checkboxes. - -**OpenAPI enums** to extend (do NOT forget — generated client will allow it but server rejects as 400): -- `CaptureTriggerKind` enum -- Three `kind` enums under `/w/{workspace}/acls/{get,add,remove}/{kind}/{path}` (yes, same list repeated three times) - -After editing any of these, run a full `cargo check` with your feature flag + `gcp_trigger` + other core flags — the `KINDS: [&str; N]` length mismatch only surfaces when the crate compiles. - -## 7. Capture infrastructure (`backend/windmill-api/src/capture.rs`) - -If the trigger supports push delivery, it also needs a capture endpoint so users can test it: - -- `{Kind}TriggerConfig` struct (gated by feature flags) -- `TriggerConfig::{Kind}` variant -- `set_{kind}_trigger_config` function (creates the subscription/equivalent pointing at the capture URL — use your `manage_{kind}_subscription` helper with `trigger_mode=false`) -- Both real + no-op versions behind feature gates -- `TriggerKind::{Kind} => set_{kind}_trigger_config(...)` arm in `set_config` -- `{kind}_payload` async handler — validates auth (if any), processes payload, calls `insert_capture_payload` -- Route: `.route("/{kind}/{runnable_kind}/{*path}", post({kind}_payload))` inside `workspaced_unauthed_service` — and expand the surrounding `#[cfg(any(...))]` to include your feature flag - -## 8. CLI (`cli/`) — easy to miss, breaks sync silently - -Check all of these: - -**`cli/src/types.ts`:** -- Add `"{kind}"` to `TRIGGER_TYPES` array -- Add `"{kind}_trigger"` to `getTypeStrFromPath` return union -- Add match case in `getTypeStrFromPath`'s `typeEnding ===` chain -- Add `pushTrigger("{kind}", ...)` branch in `pushObj` - -**`cli/src/commands/trigger/trigger.ts`:** -- Import `{Kind}Trigger` type -- Add `{kind}: {Kind}Trigger` to the `Trigger` type map -- Add `{kind}: wmill.get{Kind}Trigger`, `update{Kind}Trigger`, `create{Kind}Trigger` to each function map -- Add `{kind}: { ... }` template to `triggerTemplates` -- Add `list{Kind}Triggers` call + spread in the `list` aggregation -- Update `--kind` option descriptions to mention the new kind - -**`cli/src/commands/sync/sync.ts`:** -- Add `path.endsWith(".{kind}_trigger" + ext)` in the file-type filter -- Add `typ == "{kind}_trigger"` in `getTypeOrder` -- Add `"{kind}_trigger"` to the delete-suffix regex (~line 3092) -- Add a `case "{kind}_trigger"` in the delete switch - -**`cli/src/guidance/skills.ts`** — **DO NOT EDIT DIRECTLY**. It's auto-generated by `system_prompts/generate.py`. Instead: -- Edit `system_prompts/utils.py` → append `('{Kind}Trigger', '{kind}_trigger')` to the `SCHEMA_MAPPINGS['triggers']` list (this is the master list — the one in `generate.py` is duplicated and `utils.py` wins) -- Then run `python3 system_prompts/generate.py` — it regenerates `cli/src/guidance/skills.ts` with the schema extracted from `backend/windmill-api/openapi.yaml` -- Commit the regenerated file - -## 9. Frontend — editor + drawer - -Under `frontend/src/lib/components/triggers/{kind}/`: - -- `{Kind}TriggerPanel.svelte` — the tile shown in the triggers listing -- `{Kind}TriggerEditor.svelte` — outer drawer wrapper -- `{Kind}TriggerEditorInner.svelte` — state + business logic; must expose: - - `openEdit(path, isFlow, defaultValues?)` method - - `isEditor` prop, `onConfigChange` + `onCaptureConfigChange` callbacks - - `get{Kind}Config()` + `get{Kind}CaptureConfig()` helpers - - `captureConfig = $derived.by(untrack(() => isEditor) ? get{Kind}CaptureConfig : () => ({}))` - - `$effect(() => { const args = [captureConfig, isValid] as const; untrack(() => onCaptureConfigChange?.(...args)) })` -- `{Kind}TriggerEditorConfigSection.svelte` — form fields; use design-system components (`TextInput`, `Select`, `Toggle`, `ToggleButtonGroup`), never raw `` -- `{Kind}Capture.svelte` — capture panel; wraps `CaptureSection` with `captureType="{kind}"` -- `utils.ts` — `requestBody` builders and any trigger-type-specific helpers - -## 10. Frontend — global integration - -Easy to miss: - -- **`frontend/src/lib/components/triggers.ts`** — add `'{kind}'` to the `TriggerKind` union -- **`frontend/src/lib/components/triggers/CaptureWrapper.svelte`**: - - Import `{Kind}Capture` - - Add to `isStreamingCapture()` array (streaming = pull-style; push-style is typically `false`) - - Add `{:else if captureType === '{kind}'}` branch with the `<{Kind}Capture>` render -- **`frontend/src/lib/components/sidebar/SidebarContent.svelte`** — import the icon, add the nav entry -- **`frontend/src/lib/components/sidebar/OperatorMenu.svelte`** — add the operator-mode entry -- **`frontend/src/routes/(root)/(logged)/+layout.svelte`** — destructure `{kind}_used` from `/get_used_triggers` response, push `'{kind}'` into `usedKinds` -- **`frontend/src/lib/components/search/GlobalSearchModal.svelte`** — import icon, add "Go to {Kind} ..." entry -- **`frontend/src/lib/components/offboarding-utils.ts`** — add mappings `{kind}_trigger: '{kind}_triggers'` and `{kind}_trigger: '{kind} trigger'` -- **`frontend/src/lib/components/icons/{Kind}Icon.svelte`** — single-path SVG, `fill={color ?? 'currentColor'}`, `size` prop default 16 (match existing icons — don't hardcode colors, don't use `width`/`height` props) -- **`frontend/src/routes/(root)/(logged)/{kind}_triggers/+page.svelte`** — listing page (mirror `gcp_triggers/+page.svelte` for push+pull, `kafka_triggers` for pure streaming) -- **`frontend/src/lib/components/CompareWorkspaces.svelte`** — workspace fork / compare tool. Needs: service import, editor import, `{kind}Editor` `$state`, `case '{kind}'` in `openTriggerDetails()`, entry in `triggerServices` object (list/delete/normalize), and `<{Kind}TriggerEditor bind:this={{kind}Editor} />` in the template - -## 10.5 AI system prompts (`system_prompts/`) - -- **`system_prompts/utils.py`** — append `('{Kind}Trigger', '{kind}_trigger')` to `SCHEMA_MAPPINGS['triggers']` (master list used by code generation + CLI skills) -- **`system_prompts/generate.py`** — also has a duplicated `schema_types` list (~line 903) for the AI `triggers` skill content. Add `('{Kind}Trigger', '{kind}_trigger')` there too -- **`system_prompts/generate.py`** `schema_names` (~line 1192) — add `'{Kind}Trigger'` (add `'New{Kind}Trigger'` only if the OpenAPI declares one; GCP and Azure don't) -- Run `python3 system_prompts/generate.py` — this rewrites `cli/src/guidance/skills.ts` and all `auto-generated/` docs. Commit the regenerated files - -## 11. Validation - -Run all of these before declaring done: - -```bash -# Backend -cd backend -cargo check --features enterprise,{kind}_trigger,private # minimal -cargo check --features enterprise,azure_trigger,private,gcp_trigger,http_trigger,mqtt_trigger,postgres_trigger,sqs_trigger,kafka,nats,smtp,websocket # full - -# SQLx offline data (never run `cargo sqlx prepare` directly — use the wrapper) -./update_sqlx.sh - -# Frontend -cd frontend -npm run generate-backend-client -npm run check:fast -``` - -Smoke test in the UI: create a trigger, save, check it appears in sidebar + search, delete, re-create via CLI `wmill sync`. - -## 12. Common pitfalls - -- **Forgetting feature gates in `workspaced_unauthed_service()`** — the surrounding `#[cfg(any(...))]` expression must include your feature flag, not just the inner `#[cfg]` on the route -- **`.route(path, ...).route(path, ...)` with same path and different methods** — older axum replaced; use `.route(path, post(h1).options(h2))` to chain methods on the same `MethodRouter` -- **`on:event` directives** — legacy Svelte 4, no-op in runes mode. Use callback props (`onSelected`, `onConfigChange`) -- **`$bindable(default_value)` on optional props** — banned by project CLAUDE.md. Use `$bindable()` + `$derived(prop ?? default)` instead -- **CORS layer intercepting OPTIONS** — tower-http CorsLayer short-circuits OPTIONS before reaching your handler. For server-to-server webhook endpoints, drop the CORS layer entirely (CORS is browser-only) -- **DeliveryAttributeMappings / custom headers for auth** — prefer HMAC or sha256-hashed shared secrets over opaque JWTs when the provider doesn't support signed tokens natively. Store only the hash; regenerate secret on every save -- **ARM / API resource-listing cascades** — if the trigger's resource type is deep (Azure: subscription → RG → namespace → topic), offer dropdowns in the UI populated from the provider's APIs using the user's credential resource -- **Clearing stale selections on dependency change** — when a dropdown's underlying data reloads (e.g., user changes SP or edition), clear selections that no longer match the new list -- **Workspace-scoped tag compatibility** — if the trigger has tags, verify forked workspaces handle them (see commit `0773b5bc85` for a historical fix) - -## 13. EE file split - -If the trigger is enterprise-only, the code lives in `windmill-ee-private__worktrees/.../windmill-trigger-{kind}/src/*_ee.rs` and is symlinked into the OSS tree. The `windmill-ee-private__worktrees/` directory holds the real files; changes propagate via symlinks. See `docs/enterprise.md` for the workflow. - -## 14. Final checklist before PR - -- [ ] Migration up/down tested (revert + re-apply) -- [ ] `./update_sqlx.sh` committed the updated `.sqlx/` offline data -- [ ] `cargo check` passes with your feature flag + with all trigger features -- [ ] `npm run check:fast` passes -- [ ] Trigger visible in sidebar with correct icon weight (not oversized/colored — use `currentColor`) -- [ ] Create, edit, delete flow all work in the UI -- [ ] Capture button works (if push-capable) -- [ ] Trigger appears in `/get_used_triggers` → sidebar pulse -- [ ] `wmill sync pull` + `wmill sync push` both round-trip the trigger -- [ ] `wmill trigger list` includes it -- [ ] OpenAPI schemas are complete (no `null` in generated types) diff --git a/.claude/skills/adding-a-trigger/SKILL.md b/.claude/skills/adding-a-trigger/SKILL.md new file mode 120000 index 0000000000..a2060ad897 --- /dev/null +++ b/.claude/skills/adding-a-trigger/SKILL.md @@ -0,0 +1 @@ +../../../.agents/skills/adding-a-trigger/SKILL.md \ No newline at end of file diff --git a/.claude/skills/commit/SKILL.md b/.claude/skills/commit/SKILL.md deleted file mode 100644 index 2094dbab06..0000000000 --- a/.claude/skills/commit/SKILL.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -name: commit -user_invocable: true -description: Create a git commit with conventional commit format. MUST use anytime you want to commit changes. ---- - -# Git Commit Skill - -Create a focused, single-line commit following conventional commit conventions. - -## Instructions - -1. **Analyze changes**: Run `git status` and `git diff` to understand what was modified -2. **Stage only modified files**: Add files individually by name. NEVER use `git add -A` or `git add .` -3. **Write commit message**: Follow the conventional commit format as a single line - -## Conventional Commit Format - -``` -: -``` - -### Types -- `feat`: New feature or capability -- `fix`: Bug fix -- `refactor`: Code change that neither fixes a bug nor adds a feature -- `docs`: Documentation only changes -- `style`: Formatting, missing semicolons, etc (no code change) -- `test`: Adding or correcting tests -- `chore`: Maintenance tasks, dependency updates, etc -- `perf`: Performance improvement - -### Rules -- Message MUST be a single line (no multi-line messages) -- Description should be lowercase, imperative mood ("add" not "added") -- No period at the end -- Keep under 72 characters total - -### Examples -``` -feat: add token usage tracking for AI providers -fix: resolve null pointer in job executor -refactor: extract common validation logic -docs: update API endpoint documentation -chore: upgrade sqlx to 0.7 -``` - -## Execution Steps - -1. Run `git status` to see all changes -2. Run `git diff` to understand the changes in detail -3. Run `git log --oneline -5` to see recent commit style -4. Stage ONLY the modified/relevant files: `git add ...` -5. Create the commit with conventional format: - ```bash - git commit -m ": - - Co-Authored-By: Claude Opus 4.5 " - ``` -6. Run `git status` to verify the commit succeeded diff --git a/.claude/skills/commit/SKILL.md b/.claude/skills/commit/SKILL.md new file mode 120000 index 0000000000..11493a3d1e --- /dev/null +++ b/.claude/skills/commit/SKILL.md @@ -0,0 +1 @@ +../../../.agents/skills/commit/SKILL.md \ No newline at end of file diff --git a/.claude/skills/local-review/SKILL.md b/.claude/skills/local-review/SKILL.md deleted file mode 100644 index 58a9d473e2..0000000000 --- a/.claude/skills/local-review/SKILL.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -name: local-review -user_invocable: true -description: Code review a pull request for bugs and CLAUDE.md compliance. MUST use when asked to review code. ---- - -# Local Code Review Skill - -Run the same review locally that the GitHub Claude Auto Review action runs on PRs. The review policy lives in `.github/review-prompt-shared.md` (severity triage, public-surface checklist, `AGENTS.md` compliance, test-coverage assessment); `.claude/review-prompt.md` holds Claude-specific output preferences. Read both before reviewing. - -## Execution Steps - -1. **Read `.github/review-prompt-shared.md`** for the review policy and `.claude/review-prompt.md` for the Claude output format - -2. **Determine the PR scope**: - - If an argument is provided, use it as the PR number or branch - - Otherwise, detect from the current branch vs main - - Run `gh pr view` if a PR exists, or use `git diff main...HEAD` - -3. **Get the diff and metadata**: - - `gh pr diff` or `git diff main...HEAD` for the full diff - - `gh pr view` or `git log main..HEAD --oneline` for context - -4. **Read changed files** where the diff alone is insufficient to understand context - -5. **Apply the review policy from `.github/review-prompt-shared.md`** (and the output format from `.claude/review-prompt.md`) - -6. **Self-validate each finding**: Before reporting, ask yourself: - - "Is this definitely a real issue, not a false positive?" - - "Would a senior engineer flag this in review?" - - If the answer to either is no, discard the finding - -7. **Output findings** to the terminal (default) or post as PR comments (with `--comment` flag) - -## Output Format - -``` -## Code review - -Found N issues: - -1. [P0|P1|P2] - - -2. [P0|P1|P2] - -``` - -End with a Test coverage section per `.github/review-prompt-shared.md`. - -If no issues are found: - -``` -## Code review - -No issues found. Checked for bugs, security, and AGENTS.md compliance. -``` - -## Posting Comments (--comment flag) - -If the user passes `--comment`, post findings as inline PR comments using: - -```bash -gh pr review --comment --body "" -``` - -Or for inline comments on specific lines: - -```bash -gh api repos/{owner}/{repo}/pulls/{pr}/reviews -f body="" -f event="COMMENT" -f comments="[...]" -``` diff --git a/.claude/skills/local-review/SKILL.md b/.claude/skills/local-review/SKILL.md new file mode 120000 index 0000000000..8072aff10d --- /dev/null +++ b/.claude/skills/local-review/SKILL.md @@ -0,0 +1 @@ +../../../.agents/skills/local-review/SKILL.md \ No newline at end of file diff --git a/.claude/skills/native-trigger/SKILL.md b/.claude/skills/native-trigger/SKILL.md deleted file mode 100644 index ae38f70b32..0000000000 --- a/.claude/skills/native-trigger/SKILL.md +++ /dev/null @@ -1,793 +0,0 @@ ---- -name: native-trigger -description: Guidance for adding native trigger services to Windmill. Use when implementing or modifying native trigger integrations across the backend and frontend. ---- - -# Skill: Adding Native Trigger Services - -This skill provides comprehensive guidance for adding new native trigger services to Windmill. Native triggers allow external services (like Nextcloud, Google Drive, etc.) to trigger Windmill scripts/flows via webhooks or push notifications. - -## Architecture Overview - -The native trigger system consists of: - -1. **Database Layer** - PostgreSQL tables and enum types -2. **Backend Rust Implementation** - Core trait, handlers, and service modules in the `windmill-native-triggers` crate -3. **Frontend Svelte Components** - Configuration forms and UI components - -### Key Files - -| Component | Path | -|-----------|------| -| Core module with `External` trait | `backend/windmill-native-triggers/src/lib.rs` | -| Generic CRUD handlers | `backend/windmill-native-triggers/src/handler.rs` | -| Background sync logic | `backend/windmill-native-triggers/src/sync.rs` | -| OAuth/workspace integration | `backend/windmill-native-triggers/src/workspace_integrations.rs` | -| Re-export shim (windmill-api) | `backend/windmill-api/src/native_triggers/mod.rs` | -| TriggerKind enum | `backend/windmill-common/src/triggers.rs` | -| JobTriggerKind enum | `backend/windmill-common/src/jobs.rs` | -| Frontend service registry | `frontend/src/lib/components/triggers/native/utils.ts` | -| Frontend trigger utilities | `frontend/src/lib/components/triggers/utils.ts` | -| Trigger badges (icons + counts) | `frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte` | -| Workspace integrations UI | `frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte` | -| OAuth config form component | `frontend/src/lib/components/workspaceSettings/OAuthClientConfig.svelte` | -| OpenAPI spec | `backend/windmill-api/openapi.yaml` | -| Reference: Nextcloud module | `backend/windmill-native-triggers/src/nextcloud/` | -| Reference: Google module | `backend/windmill-native-triggers/src/google/` | - -### Crate Structure - -The native trigger code lives in the `windmill-native-triggers` crate (`backend/windmill-native-triggers/`). The `windmill-api` crate re-exports everything via a shim: - -```rust -// backend/windmill-api/src/native_triggers/mod.rs -pub use windmill_native_triggers::*; -``` - -All new service modules go in `backend/windmill-native-triggers/src/`. - ---- - -## Core Concepts - -### The `External` Trait - -Every native trigger service implements the `External` trait defined in `lib.rs`: - -```rust -#[async_trait] -pub trait External: Send + Sync + 'static { - // Associated types: - type ServiceConfig: Debug + DeserializeOwned + Serialize + Send + Sync; - type TriggerData: Debug + Serialize + Send + Sync; - type OAuthData: DeserializeOwned + Serialize + Clone + Send + Sync; - type CreateResponse: DeserializeOwned + Send + Sync; - - // Constants: - const SUPPORT_WEBHOOK: bool; - const SERVICE_NAME: ServiceName; - const DISPLAY_NAME: &'static str; - const TOKEN_ENDPOINT: &'static str; - const REFRESH_ENDPOINT: &'static str; - const AUTH_ENDPOINT: &'static str; - - // Required methods: - async fn create(&self, w_id, oauth_data, webhook_token, data, db, tx) -> Result; - async fn update(&self, w_id, oauth_data, external_id, webhook_token, data, db, tx) -> Result; - async fn get(&self, w_id, oauth_data, external_id, db, tx) -> Result; - async fn delete(&self, w_id, oauth_data, external_id, db, tx) -> Result<()>; - async fn exists(&self, w_id, oauth_data, external_id, db, tx) -> Result; - async fn maintain_triggers(&self, db, workspace_id, triggers, oauth_data, synced, errors); - fn external_id_and_metadata_from_response(&self, resp) -> (String, Option); - - // Methods with defaults: - async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result; - fn service_config_from_create_response(&self, data, resp) -> Option; - fn additional_routes(&self) -> axum::Router; - async fn http_client_request(&self, url, method, workspace_id, tx, db, headers, body) -> Result; -} -``` - -Key design points: -- **`update()` returns `serde_json::Value`** - the resolved service_config to store. Each service is responsible for building the final config. -- **`maintain_triggers()`** - periodic background maintenance. Each service implements its own strategy (Nextcloud: reconcile with external state; Google: renew expiring channels). -- **No `list_all()` in the trait** - services that need it (Nextcloud) implement it privately; services that don't (Google) use different maintenance strategies. -- **No `get_external_id_from_trigger_data()` or `extract_service_config_from_trigger_data()`** - removed in favor of the `maintain_triggers` pattern. - -### Create Lifecycle: Two Paths - -The `create_native_trigger` handler in `handler.rs` supports two creation flows, controlled by `service_config_from_create_response()`: - -**Path A: Short (Google pattern)** - `service_config_from_create_response()` returns `Some(config)`: -1. `create()` registers on external service -2. `external_id_and_metadata_from_response()` extracts the ID -3. `service_config_from_create_response()` builds the config directly from input data + response metadata -4. Stores trigger in DB -- done, no extra round-trip - -Use this when the external_id is known before the create call (e.g., Google generates the channel_id as a UUID upfront and includes it in the webhook URL). - -**Path B: Long (Nextcloud pattern)** - `service_config_from_create_response()` returns `None` (default): -1. `create()` registers on external service (webhook URL has no external_id yet) -2. `external_id_and_metadata_from_response()` extracts the ID -3. `update()` is called to fix the webhook URL with the now-known external_id -4. `update()` returns the resolved service_config -5. Stores trigger in DB - -Use this when the external_id is assigned by the remote service and the webhook URL needs to be corrected after creation. - -### OAuth Token Storage (Three-Table Pattern) - -OAuth tokens are stored across three tables, NOT in `workspace_integrations.oauth_data` directly: - -| Table | What's Stored | -|-------|---------------| -| `workspace_integrations` | `oauth_data` JSON with `base_url`, `client_id`, `client_secret`, `instance_shared` flag; `resource_path` pointing to the variable | -| `variable` | Encrypted `access_token` (at the path stored in `resource_path`), linked to `account` via `account` column | -| `account` | `refresh_token`, keyed by `workspace_id` + `client` (service name) + `is_workspace_integration = true` | - -The `decrypt_oauth_data()` function in `lib.rs` assembles these into a unified struct: -```rust -pub struct OAuthConfig { - pub base_url: String, - pub access_token: String, // decrypted from variable - pub refresh_token: Option, // from account table - pub client_id: String, // from oauth_data or instance settings - pub client_secret: String, // from oauth_data or instance settings -} -``` - -Instance-level sharing: when `oauth_data.instance_shared == true`, `client_id` and `client_secret` are read from global settings instead of workspace_integrations. - -### URL Resolution - -The `resolve_endpoint()` helper handles both absolute and relative OAuth URLs: - -```rust -pub fn resolve_endpoint(base_url: &str, endpoint: &str) -> String { - if endpoint.starts_with("http://") || endpoint.starts_with("https://") { - endpoint.to_string() // Google: absolute URLs - } else { - format!("{}{}", base_url, endpoint) // Nextcloud: relative paths - } -} -``` - -### ServiceName Methods - -`ServiceName` is the central registry enum. Each variant must implement these match arms: - -| Method | Purpose | -|--------|---------| -| `as_str()` | Lowercase identifier (e.g., `"google"`) | -| `as_trigger_kind()` | Maps to `TriggerKind` enum | -| `as_job_trigger_kind()` | Maps to `JobTriggerKind` enum | -| `token_endpoint()` | OAuth token endpoint (relative or absolute) | -| `auth_endpoint()` | OAuth authorization endpoint | -| `oauth_scopes()` | Space-separated OAuth scopes | -| `resource_type()` | Resource type for token storage (e.g., `"gworkspace"`) | -| `extra_auth_params()` | Extra OAuth params (e.g., Google needs `access_type=offline`, `prompt=consent`) | -| `integration_service()` | Maps to the workspace integration service (usually `*self`) | -| `TryFrom` | Parse from string | -| `Display` | Delegates to `as_str()` | - ---- - -## Step-by-Step Implementation Guide - -### Step 1: Database Migration - -Create a new migration file: `backend/migrations/YYYYMMDDHHMMSS_newservice_trigger.up.sql` - -```sql --- Add the service to the native_trigger_service enum -ALTER TYPE native_trigger_service ADD VALUE IF NOT EXISTS 'newservice'; - --- Add to TRIGGER_KIND enum (used for trigger tracking) -ALTER TYPE TRIGGER_KIND ADD VALUE IF NOT EXISTS 'newservice'; - --- Add to job_trigger_kind enum (used for job tracking) -ALTER TYPE job_trigger_kind ADD VALUE IF NOT EXISTS 'newservice'; -``` - -Also create the corresponding down migration. - -### Step 2: Update windmill-common Enums - -#### `backend/windmill-common/src/triggers.rs` - -Add variant to `TriggerKind` enum, and update `to_key()` and `fmt()` implementations. - -#### `backend/windmill-common/src/jobs.rs` - -Add variant to `JobTriggerKind` enum and update the `Display` implementation. - -### Step 3: Backend Service Module - -Create a new directory: `backend/windmill-native-triggers/src/newservice/` - -#### `mod.rs` - Type Definitions - -```rust -use serde::{Deserialize, Serialize}; - -pub mod external; -// pub mod routes; // Only if you need additional service-specific routes - -/// OAuth data deserialized from the three-table pattern. -/// The actual structure is built by decrypt_oauth_data() from variable + account + workspace_integrations. -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct NewServiceOAuthData { - pub base_url: String, // from workspace_integrations.oauth_data - pub access_token: String, // decrypted from variable table - pub refresh_token: Option, // from account table - // Note: client_id and client_secret are in OAuthConfig, not here - // unless the service needs them at runtime for API calls -} - -/// Configuration provided by user when creating/updating a trigger. -/// Stored as JSON in native_trigger.service_config. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NewServiceConfig { - // Service-specific configuration fields - pub folder_path: String, - pub file_filter: Option, -} - -/// Data retrieved from the external service about a trigger. -/// Returned by the get() method and shown in the UI. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NewServiceTriggerData { - pub folder_path: String, - pub file_filter: Option, - // Fields that shouldn't affect service_config comparison should use #[serde(skip_serializing)] -} - -/// Response from external service when creating a trigger/webhook. -#[derive(Debug, Deserialize)] -pub struct CreateTriggerResponse { - pub id: String, -} - -/// Handler struct (stateless, used for routing) -#[derive(Copy, Clone)] -pub struct NewService; -``` - -#### `external.rs` - External Trait Implementation - -```rust -use async_trait::async_trait; -use reqwest::Method; -use sqlx::PgConnection; -use std::collections::HashMap; -use windmill_common::{ - error::{Error, Result}, - BASE_URL, DB, -}; - -use crate::{ - generate_webhook_service_url, External, NativeTrigger, NativeTriggerData, ServiceName, - sync::{SyncError, TriggerSyncInfo}, -}; -use super::{NewService, NewServiceConfig, NewServiceOAuthData, NewServiceTriggerData, CreateTriggerResponse}; - -#[async_trait] -impl External for NewService { - type ServiceConfig = NewServiceConfig; - type TriggerData = NewServiceTriggerData; - type OAuthData = NewServiceOAuthData; - type CreateResponse = CreateTriggerResponse; - - const SERVICE_NAME: ServiceName = ServiceName::NewService; - const DISPLAY_NAME: &'static str = "New Service"; - const SUPPORT_WEBHOOK: bool = true; - const TOKEN_ENDPOINT: &'static str = "/oauth/token"; - const REFRESH_ENDPOINT: &'static str = "/oauth/token"; - const AUTH_ENDPOINT: &'static str = "/oauth/authorize"; - - async fn create( - &self, - w_id: &str, - oauth_data: &Self::OAuthData, - webhook_token: &str, - data: &NativeTriggerData, - db: &DB, - tx: &mut PgConnection, - ) -> Result { - let base_url = &*BASE_URL.read().await; - - // external_id is None during create (we get it from the response) - let webhook_url = generate_webhook_service_url( - base_url, w_id, &data.script_path, data.is_flow, - None, Self::SERVICE_NAME, webhook_token, - ); - - let url = format!("{}/api/webhooks/create", oauth_data.base_url); - let payload = serde_json::json!({ - "callback_url": webhook_url, - "folder_path": data.service_config.folder_path, - }); - - let response: CreateTriggerResponse = self - .http_client_request(&url, Method::POST, w_id, tx, db, None, Some(&payload)) - .await?; - - Ok(response) - } - - /// Update returns the resolved service_config as JSON. - /// For services using the update+get pattern, call self.get() and serialize. - async fn update( - &self, - w_id: &str, - oauth_data: &Self::OAuthData, - external_id: &str, - webhook_token: &str, - data: &NativeTriggerData, - db: &DB, - tx: &mut PgConnection, - ) -> Result { - let base_url = &*BASE_URL.read().await; - - let webhook_url = generate_webhook_service_url( - base_url, w_id, &data.script_path, data.is_flow, - Some(external_id), Self::SERVICE_NAME, webhook_token, - ); - - let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id); - let payload = serde_json::json!({ - "callback_url": webhook_url, - "folder_path": data.service_config.folder_path, - }); - - let _: serde_json::Value = self - .http_client_request(&url, Method::PUT, w_id, tx, db, None, Some(&payload)) - .await?; - - // Fetch back the updated state to get the resolved config - let trigger_data = self.get(w_id, oauth_data, external_id, db, tx).await?; - serde_json::to_value(&trigger_data) - .map_err(|e| Error::InternalErr(format!("Failed to serialize trigger data: {}", e))) - } - - async fn get( - &self, - w_id: &str, - oauth_data: &Self::OAuthData, - external_id: &str, - db: &DB, - tx: &mut PgConnection, - ) -> Result { - let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id); - self.http_client_request::<_, ()>(&url, Method::GET, w_id, tx, db, None, None).await - } - - async fn delete( - &self, - w_id: &str, - oauth_data: &Self::OAuthData, - external_id: &str, - db: &DB, - tx: &mut PgConnection, - ) -> Result<()> { - let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id); - let _: serde_json::Value = self - .http_client_request::<_, ()>(&url, Method::DELETE, w_id, tx, db, None, None) - .await - .or_else(|e| match &e { - Error::InternalErr(msg) if msg.contains("404") => Ok(serde_json::Value::Null), - _ => Err(e), - })?; - Ok(()) - } - - async fn exists( - &self, - w_id: &str, - oauth_data: &Self::OAuthData, - external_id: &str, - db: &DB, - tx: &mut PgConnection, - ) -> Result { - match self.get(w_id, oauth_data, external_id, db, tx).await { - Ok(_) => Ok(true), - Err(Error::NotFound(_)) => Ok(false), - Err(e) => Err(e), - } - } - - /// Background maintenance. Choose the right pattern for your service: - /// - For services with queryable external state: use reconcile_with_external_state() - /// - For channel-based services with expiration: implement renewal logic - async fn maintain_triggers( - &self, - db: &DB, - workspace_id: &str, - triggers: &[NativeTrigger], - oauth_data: &Self::OAuthData, - synced: &mut Vec, - errors: &mut Vec, - ) { - // Option A: Reconcile with external state (Nextcloud pattern) - // Fetch all triggers from external service and compare with DB - let external_triggers = match self.list_all(workspace_id, oauth_data, db).await { - Ok(triggers) => triggers, - Err(e) => { - errors.push(SyncError { - resource_path: format!("workspace:{}", workspace_id), - error_message: format!("Failed to list triggers: {}", e), - error_type: "api_error".to_string(), - }); - return; - } - }; - - // Convert to (external_id, config_json) pairs - let external_pairs: Vec<(String, serde_json::Value)> = external_triggers - .into_iter() - .map(|t| (t.id.clone(), serde_json::to_value(&t).unwrap_or_default())) - .collect(); - - crate::sync::reconcile_with_external_state( - db, workspace_id, Self::SERVICE_NAME, triggers, &external_pairs, synced, errors, - ).await; - } - - fn external_id_and_metadata_from_response( - &self, - resp: &Self::CreateResponse, - ) -> (String, Option) { - (resp.id.clone(), None) - } - - // service_config_from_create_response: NOT overridden (returns None). - // This means the handler uses the update+get pattern after create. - // Override and return Some(...) to skip the update+get cycle (Google pattern). -} - -impl NewService { - /// Private helper to list all triggers from the external service. - async fn list_all( - &self, - w_id: &str, - oauth_data: &::OAuthData, - db: &DB, - ) -> Result::TriggerData>> { - // Implementation depends on the external service's API - todo!() - } -} -``` - -### Step 4: Update lib.rs Registry - -In `backend/windmill-native-triggers/src/lib.rs`: - -```rust -// Service modules - add new services here: -#[cfg(feature = "native_trigger")] -pub mod newservice; // <-- Add this - -// ServiceName enum - add variant: -pub enum ServiceName { - Nextcloud, - Google, - NewService, // <-- Add this -} - -// Then add match arms in ALL ServiceName methods: -// as_str(), as_trigger_kind(), as_job_trigger_kind(), token_endpoint(), -// auth_endpoint(), oauth_scopes(), resource_type(), extra_auth_params(), -// integration_service(), TryFrom, Display -``` - -### Step 5: Update handler.rs Routes - -In `backend/windmill-native-triggers/src/handler.rs`: - -```rust -pub fn generate_native_trigger_routers() -> Router { - // ... - #[cfg(feature = "native_trigger")] - { - use crate::newservice::NewService; - return router - .nest("/nextcloud", service_routes(NextCloud)) - .nest("/google", service_routes(Google)) - .nest("/newservice", service_routes(NewService)); // <-- Add this - } - // ... -} -``` - -### Step 6: Update sync.rs - -In `backend/windmill-native-triggers/src/sync.rs`: - -```rust -pub async fn sync_all_triggers(db: &DB) -> Result { - // ... - #[cfg(feature = "native_trigger")] - { - use crate::newservice::NewService; - - // ... existing service syncs ... - - // New service sync - let (service_name, result) = sync_service_triggers(db, NewService).await; - total_synced += result.synced_triggers.len(); - total_errors += result.errors.len(); - service_results.insert(service_name, result); - } - // ... -} -``` - -### Step 7: Frontend Service Registry - -In `frontend/src/lib/components/triggers/native/utils.ts`: - -Add to `NATIVE_TRIGGER_SERVICES`, `getTriggerIconName()`, and `getServiceIcon()`. - -### Step 8: Frontend Trigger Form Component - -Create: `frontend/src/lib/components/triggers/native/services/newservice/NewServiceTriggerForm.svelte` - -### Step 9: Frontend Icon Component - -Create: `frontend/src/lib/components/icons/NewServiceIcon.svelte` - -### Step 10: Update NativeTriggerEditor - -Check `frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte` to ensure it dynamically loads form components based on service name. - -### Step 11: Workspace Integration UI - -Add your service to the `supportedServices` map in `frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte`: - -```typescript -const supportedServices: Record = { - // ... existing services ... - newservice: { - name: 'newservice', - displayName: 'New Service', - description: 'Connect to New Service for triggers', - icon: NewServiceIcon, - docsUrl: 'https://www.windmill.dev/docs/integrations/newservice', - requiresBaseUrl: false, // false for cloud services, true for self-hosted - setupInstructions: [ - 'Step 1: Create an OAuth app on the service', - 'Step 2: Configure the redirect URI shown below', - 'Step 3: Enter the client credentials below' - ] - } -} -``` - -### Step 12: Update `frontend/src/lib/components/triggers/utils.ts` - -Update ALL of these maps/functions: -1. `triggerIconMap` - import and add icon -2. `triggerDisplayNamesMap` - add display name -3. `triggerTypeOrder` in `sortTriggers()` - add type -4. `getLightConfig()` - add case for your service -5. `getTriggerLabel()` - add case for your service -6. `jobTriggerKinds` - add to array -7. `countPropertyMap` - add count property -8. `triggerSaveFunctions` - add save function - -### Step 13: Update TriggersBadge Component - -In `frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte`: - -1. Import the icon -2. Add to `baseConfig` with `countKey` (the dynamic `availableNativeServices` loop does NOT set `countKey`) -3. Add to the `allTypes` array - -### Step 14: Update TriggersWrapper.svelte - -In `frontend/src/lib/components/triggers/TriggersWrapper.svelte`: - -Add a `{:else if selectedTrigger.type === 'yourservice'}` case that renders `` with the same props pattern as the existing native trigger cases (e.g., `nextcloud`). - -### Step 15: Update AddTriggersButton.svelte - -In `frontend/src/lib/components/triggers/AddTriggersButton.svelte`: - -1. Add `yourserviceAvailable` state variable -2. Add `setYourserviceState()` async function using `isServiceAvailable('yourservice', $workspaceStore!)` -3. Call it at module level -4. Add a dropdown entry to `addTriggerItems` with `hidden: !yourserviceAvailable` - -### Step 16: Update TriggersEditor.svelte Delete Handling - -In `frontend/src/lib/components/triggers/TriggersEditor.svelte`: - -Add your service to the `nativeTriggerServices` map in `deleteDeployedTrigger()`. Native triggers use `NativeTriggerService.deleteNativeTrigger({ workspace, serviceName, externalId })` instead of the standard `path`-based delete. - -### Step 17: Update `getUsedTriggers` for Sidebar Visibility - -The sidebar (`frontend/src/lib/components/sidebar/SidebarContent.svelte`) shows native-trigger links only if `$usedTriggerKinds` includes the service — without this, your trigger page will never appear in the nav bar even when triggers exist. - -1. **Backend** — add `{service}_used: bool` to the `UsedTriggers` struct and SELECT in `backend/windmill-api-workspaces/src/workspaces.rs::get_used_triggers()`: - ```rust - EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = '{service}'::native_trigger_service) AS "{service}_used!" - ``` -2. **OpenAPI** — add `{service}_used: boolean` to the response schema for `GET /w/{workspace}/workspaces/used_triggers` (under both `properties` and `required`). -3. **Layout** — in `frontend/src/routes/(root)/(logged)/+layout.svelte::loadUsedTriggerKinds()`, destructure `{service}_used` and push `'{service}'` to `usedKinds`. - -### Step 18: Update OpenAPI Spec and Regenerate Types - -Add to `JobTriggerKind` enum in `backend/windmill-api/openapi.yaml`, then: - -```bash -cd frontend && npm run generate-backend-client -``` - ---- - -## Special Patterns - -### Unified Service with `trigger_type` (Google Pattern) - -When a single service handles multiple trigger types (e.g., Google Drive + Calendar share OAuth and API patterns), use a single `ServiceName` variant with a discriminator field: - -```rust -pub enum GoogleTriggerType { Drive, Calendar } - -pub struct GoogleServiceConfig { - pub trigger_type: GoogleTriggerType, - // Drive-specific fields (only used when trigger_type = Drive) - pub resource_id: Option, - pub resource_name: Option, - // Calendar-specific fields (only used when trigger_type = Calendar) - pub calendar_id: Option, - pub calendar_name: Option, - // Metadata set after creation - pub google_resource_id: Option, - pub expiration: Option, -} -``` - -Branch in trait methods based on `trigger_type`. Frontend uses a `ToggleButtonGroup` to switch between types. This keeps the codebase simpler (one service, one OAuth flow, one set of routes). - -See `backend/windmill-native-triggers/src/google/` for the reference implementation. - -### Skipping update+get After Create (Google Pattern) - -Override `service_config_from_create_response()` to return `Some(config)` when the external_id is known before the create call: - -```rust -fn service_config_from_create_response( - &self, - data: &NativeTriggerData, - resp: &Self::CreateResponse, -) -> Option { - // Clone input config, add metadata from response - let mut config = data.service_config.clone(); - config.google_resource_id = Some(resp.resource_id.clone()); - config.expiration = Some(resp.expiration.clone()); - Some(serde_json::to_value(&config).unwrap()) -} -``` - -### Services with Absolute OAuth Endpoints (Google) - -Unlike self-hosted services where OAuth endpoints are relative paths appended to `base_url`, services like Google have absolute URLs: - -```rust -// Nextcloud: relative paths -ServiceName::Nextcloud => "/apps/oauth2/api/v1/token", -// Google: absolute URLs -ServiceName::Google => "https://oauth2.googleapis.com/token", -``` - -The `resolve_endpoint()` function handles both. For services with absolute endpoints: -- `base_url` can be empty -- `requiresBaseUrl: false` in the frontend workspace integration config -- Add `extra_auth_params()` if needed (Google requires `access_type=offline` and `prompt=consent`) - -### Channel-Based Push Notifications with Renewal (Google Pattern) - -For services using expiring watch channels instead of persistent webhooks: - -1. Store expiration in `service_config` (as part of `ServiceConfig`) -2. In `maintain_triggers()`, implement renewal logic instead of using `reconcile_with_external_state()`: - ```rust - async fn maintain_triggers(&self, db, workspace_id, triggers, oauth_data, synced, errors) { - for trigger in triggers { - if should_renew_channel(trigger) { - self.renew_channel(db, trigger, oauth_data).await; - } - } - } - ``` -3. Renewal: best-effort stop old channel, create new one with same external_id, update service_config with new expiration -4. Google example: Drive channels expire in 24h (renew when <1h left), Calendar channels expire in 7 days (renew when <1 day left) - -### reconcile_with_external_state (Nextcloud Pattern) - -The reusable function in `sync.rs` compares external triggers with DB state: -- Triggers missing externally: sets error "Trigger no longer exists on external service" -- Triggers present externally: clears errors, updates service_config if it differs - -Usage in `maintain_triggers()`: -```rust -let external_pairs: Vec<(String, serde_json::Value)> = /* fetch from external */; -crate::sync::reconcile_with_external_state( - db, workspace_id, Self::SERVICE_NAME, triggers, &external_pairs, synced, errors, -).await; -``` - -### Webhook Payload Processing - -Override `prepare_webhook()` to parse service-specific payloads into script/flow args: - -```rust -async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result { - let mut args = HashMap::new(); - args.insert("event_type".to_string(), Box::new(headers.get("x-event-type").cloned()) as _); - args.insert("payload".to_string(), Box::new(serde_json::from_str::(&body)?) as _); - Ok(PushArgsOwned { extra: None, args }) -} -``` - -Then register in `prepare_native_trigger_args()` in `lib.rs`: -```rust -pub async fn prepare_native_trigger_args(service_name, db, w_id, headers, body) -> Result> { - match service_name { - ServiceName::Google => { /* ... */ Ok(Some(args)) } - ServiceName::NewService => { /* ... */ Ok(Some(args)) } - ServiceName::Nextcloud => Ok(None), // Uses default body parsing - } -} -``` - -### Instance-Level OAuth Credentials - -When `workspace_integrations.oauth_data.instance_shared == true`, `decrypt_oauth_data()` reads `client_id` and `client_secret` from instance-level global settings instead of workspace-level. This allows admins to share OAuth app credentials across workspaces. - -The frontend handles this via the `generate_instance_connect_url` endpoint in `workspace_integrations.rs`. - ---- - -## Testing Checklist - -- [ ] Database migration runs successfully -- [ ] `cargo check -p windmill-native-triggers --features native_trigger` passes -- [ ] `npx svelte-check --threshold error` passes (in frontend/) -- [ ] Service appears in workspace integrations list -- [ ] OAuth flow completes successfully -- [ ] Can create a new trigger -- [ ] Can view trigger details -- [ ] Can update trigger configuration -- [ ] Can delete trigger -- [ ] Webhook receives and processes payloads -- [ ] Background sync works correctly (reconciliation or channel renewal) -- [ ] Error handling works (expired tokens, service unavailable) - ---- - -## Reference Implementations - -### Nextcloud (Self-Hosted, Update+Get Pattern) - -| File | Purpose | -|------|---------| -| `nextcloud/mod.rs` | Types: NextCloudOAuthData, NextcloudServiceConfig, NextCloudTriggerData | -| `nextcloud/external.rs` | External trait: uses update+get pattern, reconcile_with_external_state for sync | -| `nextcloud/routes.rs` | Additional route: `GET /events` | - -Key patterns: relative OAuth endpoints, base_url required, list_all + reconcile for sync, update returns JSON from get(). - -### Google (Cloud, Unified Service, Short Create) - -| File | Purpose | -|------|---------| -| `google/mod.rs` | Types: GoogleServiceConfig with trigger_type discriminator, GoogleTriggerType enum | -| `google/external.rs` | External trait: overrides service_config_from_create_response, channel renewal for sync | -| `google/routes.rs` | Additional routes: `GET /calendars`, `GET /drive/files`, `GET /drive/shared_drives` | - -Key patterns: absolute OAuth endpoints, empty base_url, trigger_type for Drive/Calendar, expiring watch channels with renewal, service_config_from_create_response skips update+get, get() reconstructs data from stored service_config (no external "get channel" API). diff --git a/.claude/skills/native-trigger/SKILL.md b/.claude/skills/native-trigger/SKILL.md new file mode 120000 index 0000000000..18548efdba --- /dev/null +++ b/.claude/skills/native-trigger/SKILL.md @@ -0,0 +1 @@ +../../../.agents/skills/native-trigger/SKILL.md \ No newline at end of file diff --git a/.claude/skills/pr/SKILL.md b/.claude/skills/pr/SKILL.md deleted file mode 100644 index 2c7bd691ca..0000000000 --- a/.claude/skills/pr/SKILL.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -name: pr -user_invocable: true -description: Open a draft pull request on GitHub. MUST use when you want to create/open a PR. ---- - -# Pull Request Skill - -Create a draft pull request with a clear title and explicit description of changes. - -## Instructions - -1. **Analyze branch changes**: Understand all commits since diverging from main -2. **Push to remote**: Ensure all commits are pushed -3. **Create draft PR**: Always open as draft for review before merging - -## PR Title Format - -Follow conventional commit format for the PR title: -``` -: -``` - -### Types -- `feat`: New feature or capability -- `fix`: Bug fix -- `refactor`: Code restructuring -- `docs`: Documentation changes -- `chore`: Maintenance tasks -- `perf`: Performance improvements - -### Title Rules -- Keep under 70 characters -- Use lowercase, imperative mood -- No period at the end -- If `*_ee.rs` files were modified, prefix with `[ee]`: `[ee] : ` - -## PR Body Format - -The body MUST be explicit about what changed. Structure: - -```markdown -## Summary - - -## Changes -- -- -- - -## Test plan -- [ ] -- [ ] - ---- -Generated with [Claude Code](https://claude.com/claude-code) -``` - -## Execution Steps - -1. Run `git status` to check for uncommitted changes -2. Run `git log main..HEAD --oneline` to see all commits in this branch -3. Run `git diff main...HEAD` to see the full diff against main -4. **Run `/local-review`** before creating the PR. If issues are found, fix them and commit before proceeding. Do not skip this step. -5. Check if remote branch exists and is up to date: - ```bash - git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || echo "no upstream" - ``` -6. Push to remote if needed: `git push -u origin HEAD` -7. Create draft PR using gh CLI: - ```bash - gh pr create --draft --title ": " --body "$(cat <<'EOF' - ## Summary - - - ## Changes - - - - - - ## Test plan - - [ ] - - [ ] - - --- - Generated with [Claude Code](https://claude.com/claude-code) - EOF - )" - ``` -8. Return the PR URL to the user - -## EE Companion PR (when `*_ee.rs` files were modified) - -The `*_ee.rs` files in the windmill repo are **symlinks** to `windmill-ee-private` — changes won't appear in `git diff` of the windmill repo. Instead, check the EE repo for uncommitted or unpushed changes. - -Follow the full EE PR workflow in `docs/enterprise.md`. The key PR-specific details: - -1. Find the EE repo/worktree: see "Finding the EE Repo" in `docs/enterprise.md` -2. Check for changes: `git -C status --short` - - If there are no changes in the EE repo, skip this entire section -3. Follow steps 1–5 from the "EE PR Workflow" in `docs/enterprise.md` -4. Create the companion PR (title does NOT get the `[ee]` prefix): - ```bash - gh pr create --draft --repo windmill-labs/windmill-ee-private --title ": " --body "$(cat <<'EOF' - Companion PR for windmill-labs/windmill# - - --- - Generated with [Claude Code](https://claude.com/claude-code) - EOF - )" - ``` -5. Commit `ee-repo-ref.txt` and push the updated windmill branch diff --git a/.claude/skills/pr/SKILL.md b/.claude/skills/pr/SKILL.md new file mode 120000 index 0000000000..9458ad7097 --- /dev/null +++ b/.claude/skills/pr/SKILL.md @@ -0,0 +1 @@ +../../../.agents/skills/pr/SKILL.md \ No newline at end of file diff --git a/.claude/skills/refine/SKILL.md b/.claude/skills/refine/SKILL.md deleted file mode 100644 index aaf747cd29..0000000000 --- a/.claude/skills/refine/SKILL.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: refine -user_invocable: true -description: End-of-session reflection. Reviews friction encountered during the session and proposes updates to docs/ to capture lessons learned. ---- - -# Refine Skill - -Reflect on the current session and update documentation with lessons learned. - -## Instructions - -1. **Identify friction**: Review what happened in this session: - - Run `git diff main...HEAD --stat` to see what files were touched - - Think about: what was slow, what failed, what required multiple attempts, what information was missing or hard to find - -2. **Read current docs**: Read the docs that were relevant to this session: - - `docs/validation.md` - - `docs/enterprise.md` - - `docs/autonomous-mode.md` - - Any skills that were invoked - -3. **Propose updates**: For each piece of friction, decide if it warrants a doc update: - - **Missing knowledge**: Information you had to discover that should be documented - - **Wrong guidance**: Instructions that led you astray - - **Missing validation rule**: A check that should be in the validation matrix - - **New pattern**: A codebase pattern worth capturing for next time - -4. **Apply updates**: Edit the relevant `docs/` files. Keep changes minimal and specific — add only what would have saved time this session. - -5. **Report**: Summarize what was added/changed and why. - -## Rules - -- Only add knowledge confirmed by this session — no speculative additions -- Keep docs concise — add a line or two, not a paragraph -- If a whole new doc is needed, create it in `docs/` and add a pointer in `CLAUDE.md` -- Don't update skills unless a coding pattern was genuinely wrong -- Don't add things Claude already knows — only Windmill-specific knowledge diff --git a/.claude/skills/refine/SKILL.md b/.claude/skills/refine/SKILL.md new file mode 120000 index 0000000000..39580df5d0 --- /dev/null +++ b/.claude/skills/refine/SKILL.md @@ -0,0 +1 @@ +../../../.agents/skills/refine/SKILL.md \ No newline at end of file diff --git a/.claude/skills/rust-backend/SKILL.md b/.claude/skills/rust-backend/SKILL.md deleted file mode 100644 index f0c52002bc..0000000000 --- a/.claude/skills/rust-backend/SKILL.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -name: rust-backend -description: Rust coding guidelines for the Windmill backend. MUST use when writing or modifying Rust code in the backend directory. ---- - -# Windmill Rust Patterns - -Apply these Windmill-specific patterns when writing Rust code in `backend/`. - -## Error Handling - -Use `Error` from `windmill_common::error`. Return `Result` or `JsonResult`: - -```rust -use windmill_common::error::{Error, Result}; - -pub async fn get_job(db: &DB, id: Uuid) -> Result { - sqlx::query_as!(Job, "SELECT id, workspace_id FROM v2_job WHERE id = $1", id) - .fetch_optional(db) - .await? - .ok_or_else(|| Error::NotFound("job not found".to_string()))?; -} -``` - -Never panic in library code. Reserve `.unwrap()` for compile-time guarantees. - -## SQLx Patterns - -**Never use `SELECT *`** — always list columns explicitly. Critical for backwards compatibility when workers lag behind API version: - -```rust -// Correct -sqlx::query_as!(Job, "SELECT id, workspace_id, path FROM v2_job WHERE id = $1", id) - -// Wrong — breaks when columns are added -sqlx::query_as!(Job, "SELECT * FROM v2_job WHERE id = $1", id) -``` - -Use batch operations to avoid N+1: - -```rust -// Preferred — single query with IN clause -sqlx::query!("SELECT ... WHERE id = ANY($1)", &ids[..]).fetch_all(db).await? -``` - -Use transactions for multi-step operations. Parameterize all queries. - -## JSON Handling - -Prefer `Box` over `serde_json::Value` when storing/passing JSON without inspection: - -```rust -pub struct Job { - pub args: Option>, -} -``` - -Only use `serde_json::Value` when you need to inspect or modify the JSON. - -## Serde Optimizations - -```rust -#[derive(Serialize, Deserialize)] -pub struct Job { - #[serde(skip_serializing_if = "Option::is_none")] - pub parent_job: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub tags: Vec, - #[serde(default)] - pub priority: i32, -} -``` - -## Async & Concurrency - -Never block the async runtime. Use `spawn_blocking` for CPU-intensive work: - -```rust -let result = tokio::task::spawn_blocking(move || expensive_computation(&data)).await?; -``` - -**Mutex selection**: Prefer `std::sync::Mutex` (or `parking_lot::Mutex`) for data protection. Only use `tokio::sync::Mutex` when holding locks across `.await` points. - -Use `tokio::sync::mpsc` (bounded) for channels. Avoid `std::thread::sleep` in async contexts. - -## Module Structure & Visibility - -- Use `pub(crate)` instead of `pub` when possible -- Place new code in the appropriate crate based on functionality -- API endpoints go in `windmill-api/src/` organized by domain -- Shared functionality goes in `windmill-common/src/` - -## Code Navigation - -Always use rust-analyzer LSP for go-to-definition, find-references, and type info. Do not guess at module paths. - -## Axum Handlers - -Destructure extractors directly in function signatures: - -```rust -async fn process_job( - Extension(db): Extension, - Path((workspace, job_id)): Path<(String, Uuid)>, - Query(pagination): Query, -) -> Result> { ... } -``` diff --git a/.claude/skills/rust-backend/SKILL.md b/.claude/skills/rust-backend/SKILL.md new file mode 120000 index 0000000000..2500c55046 --- /dev/null +++ b/.claude/skills/rust-backend/SKILL.md @@ -0,0 +1 @@ +../../../.agents/skills/rust-backend/SKILL.md \ No newline at end of file diff --git a/.claude/skills/svelte-frontend/SKILL.md b/.claude/skills/svelte-frontend/SKILL.md deleted file mode 100644 index 57cac70302..0000000000 --- a/.claude/skills/svelte-frontend/SKILL.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -name: svelte-frontend -description: Svelte coding guidelines for the Windmill frontend. MUST use when writing or modifying code in the frontend directory. ---- - -# Windmill Svelte Patterns - -Apply these Windmill-specific patterns when writing Svelte code in `frontend/`. For general Svelte 5 syntax (runes, snippets, event handling), use the Svelte MCP server. - -## Windmill UI Components (MUST use) - -Always use Windmill's design-system components. Never use raw HTML elements. - -### Buttons — ` - +
+ {/each} + +{/if} diff --git a/frontend/src/lib/components/copilot/chat/createdResourceActions.svelte.ts b/frontend/src/lib/components/copilot/chat/createdResourceActions.svelte.ts new file mode 100644 index 0000000000..725385d3ee --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/createdResourceActions.svelte.ts @@ -0,0 +1,40 @@ +import { sendUserToast } from '$lib/toast' +import type { ToolDisplayAction } from './shared' + +type MaybePromise = T | Promise +type ToolDisplayActionHandler = (action: ToolDisplayAction) => MaybePromise + +const toolDisplayActionHandlers = $state>({}) + +function formatUnknownError(error: unknown): string { + if (error instanceof Error) { + return error.message + } + return String(error) +} + +export function registerToolDisplayActionHandler( + type: ToolDisplayAction['type'], + handler: ToolDisplayActionHandler +): () => void { + toolDisplayActionHandlers[type] = handler + return () => { + if (toolDisplayActionHandlers[type] === handler) { + delete toolDisplayActionHandlers[type] + } + } +} + +export async function runToolDisplayAction(action: ToolDisplayAction): Promise { + const handler = toolDisplayActionHandlers[action.type] + if (!handler) { + sendUserToast('This action is not available right now.', true) + return + } + + try { + await handler(action) + } catch (error) { + sendUserToast(`Could not run action "${action.label}": ${formatUnknownError(error)}`, true) + } +} diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index 76b1503225..1cb0b3edc0 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -277,7 +277,17 @@ describe('processToolCall', () => { target_path: 'f/scripts/current', target_kind: 'script', backend_result: 'schedule-created' - }) + }), + actions: [ + expect.objectContaining({ + id: 'open-created-schedule:f/schedules/current', + type: 'open_created_resource', + label: 'Open schedule', + resource: 'schedule', + path: 'f/schedules/current', + targetKind: 'script' + }) + ] }) ) expect(JSON.parse(scheduleResult.content as string)).toEqual( @@ -342,7 +352,18 @@ describe('processToolCall', () => { target_path: 'f/flows/current', target_kind: 'flow', backend_result: 'trigger-created' - }) + }), + actions: [ + expect.objectContaining({ + id: 'open-created-trigger:http:f/triggers/current', + type: 'open_created_resource', + label: 'Open HTTP trigger', + resource: 'trigger', + triggerKind: 'http', + path: 'f/triggers/current', + targetKind: 'flow' + }) + ] }) ) expect(JSON.parse(triggerResult.content as string)).toEqual( diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 521c25fdaf..59eba6a834 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -409,6 +409,29 @@ export type UserDisplayMessage = BaseDisplayMessage & { error?: boolean } +export type CreatedResourceTriggerKind = + | 'http' + | 'websocket' + | 'kafka' + | 'nats' + | 'postgres' + | 'mqtt' + | 'sqs' + | 'gcp' + | 'azure' + +export type CreatedResourceAction = { + id: string + type: 'open_created_resource' + label: string + resource: 'schedule' | 'trigger' + path: string + targetKind: 'script' | 'flow' + triggerKind?: CreatedResourceTriggerKind +} + +export type ToolDisplayAction = CreatedResourceAction + export type ToolDisplayMessage = { role: 'tool' tool_call_id: string @@ -423,6 +446,7 @@ export type ToolDisplayMessage = { isStreamingArguments?: boolean toolName?: string showFade?: boolean + actions?: ToolDisplayAction[] } export type AssistantDisplayMessage = BaseDisplayMessage & { diff --git a/frontend/src/lib/components/copilot/chat/workspaceTools.ts b/frontend/src/lib/components/copilot/chat/workspaceTools.ts index 37b98482f1..910b7ccbc7 100644 --- a/frontend/src/lib/components/copilot/chat/workspaceTools.ts +++ b/frontend/src/lib/components/copilot/chat/workspaceTools.ts @@ -26,7 +26,13 @@ import { triggerRequestSchemas } from './workspaceToolsZod.gen' import { z } from 'zod' -import { createToolDef, type Tool, type ToolCallbacks } from './shared' +import { + createToolDef, + type CreatedResourceTriggerKind, + type Tool, + type ToolCallbacks, + type ToolDisplayAction +} from './shared' import { emptyString } from '$lib/utils' type TriggerKind = keyof typeof triggerRequestSchemas @@ -60,7 +66,9 @@ function getWorkspaceMutationTarget(helpers: unknown): WorkspaceMutationTarget | return (helpers as WorkspaceMutationHelpers | undefined)?.getWorkspaceMutationTarget?.() } -function getWorkspaceMutationTargetError(target: WorkspaceMutationTarget | undefined): string | undefined { +function getWorkspaceMutationTargetError( + target: WorkspaceMutationTarget | undefined +): string | undefined { if (!target) { return 'the script or flow needs to be deployed before doing this action' } @@ -74,7 +82,9 @@ function validateWorkspaceMutationTarget(helpers: unknown): string | undefined { return getWorkspaceMutationTargetError(getWorkspaceMutationTarget(helpers)) } -function requireWorkspaceMutationTarget(helpers: unknown): WorkspaceMutationTarget & { path: string } { +function requireWorkspaceMutationTarget( + helpers: unknown +): WorkspaceMutationTarget & { path: string } { const target = getWorkspaceMutationTarget(helpers) const error = getWorkspaceMutationTargetError(target) if (error) { @@ -83,7 +93,9 @@ function requireWorkspaceMutationTarget(helpers: unknown): WorkspaceMutationTarg return target as WorkspaceMutationTarget & { path: string } } -function getWorkspaceMutationTargetFields(helpers: unknown): Pick { +function getWorkspaceMutationTargetFields( + helpers: unknown +): Pick { const target = requireWorkspaceMutationTarget(helpers) return { script_path: target.path, @@ -168,6 +180,38 @@ const triggerConfigs = { } } +function getActionTargetKind(isFlow: boolean): 'script' | 'flow' { + return isFlow ? 'flow' : 'script' +} + +function createOpenScheduleAction(path: string, targetKind: 'script' | 'flow'): ToolDisplayAction { + return { + id: `open-created-schedule:${path}`, + type: 'open_created_resource', + label: 'Open schedule', + resource: 'schedule', + path, + targetKind + } +} + +function createOpenTriggerAction( + kind: TriggerKind, + path: string, + targetKind: 'script' | 'flow', + label: string +): ToolDisplayAction { + return { + id: `open-created-trigger:${kind}:${path}`, + type: 'open_created_resource', + label: `Open ${label}`, + resource: 'trigger', + triggerKind: kind as CreatedResourceTriggerKind, + path, + targetKind + } +} + function formatPath(path: (string | number | symbol)[]): string { if (path.length === 0) { return 'value' @@ -265,16 +309,18 @@ const createScheduleTool: Tool = { }) try { const result = await ScheduleService.createSchedule({ workspace, requestBody }) + const targetKind = getActionTargetKind(requestBody.is_flow) const toolResult = { success: true, path: requestBody.path, target_path: requestBody.script_path, - target_kind: requestBody.is_flow ? 'flow' : 'script', + target_kind: targetKind, backend_result: result } toolCallbacks.setToolStatus(toolId, { content: `Created schedule "${requestBody.path}"`, - result: toolResult + result: toolResult, + actions: [createOpenScheduleAction(requestBody.path, targetKind)] }) return JSON.stringify(toolResult) } catch (error) { @@ -311,17 +357,26 @@ const createTriggerTool: Tool = { }) try { const result = await triggerConfig.create({ workspace, requestBody } as never) + const targetKind = getActionTargetKind(requestBody.is_flow) const toolResult = { success: true, kind: parsedArgs.kind, path: requestBody.path, target_path: requestBody.script_path, - target_kind: requestBody.is_flow ? 'flow' : 'script', + target_kind: targetKind, backend_result: result } toolCallbacks.setToolStatus(toolId, { content: `Created ${triggerConfig.label} "${requestBody.path}"`, - result: toolResult + result: toolResult, + actions: [ + createOpenTriggerAction( + parsedArgs.kind, + requestBody.path, + targetKind, + triggerConfig.label + ) + ] }) return JSON.stringify(toolResult) } catch (error) { From b86f8960fcd8a66bc6849638178ef45ac49e06f1 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 5 May 2026 20:39:06 +0200 Subject: [PATCH 024/313] fix(windmill-utils-internal): move config to subpath export (#9045) The config module imports node:fs/promises (stat, mkdir), which breaks non-Node bundlers like the Cloudflare Workers build of the hub. The windmill SPA frontend got away with it via tree-shaking, but stricter runtimes choke on the bare node: import even when unused. Stop re-exporting ./config from the main entry and expose it via a windmill-utils-internal/config subpath instead. CLI code already deep-imports the source file, so it is unaffected. Bumps the package to 1.4.0 and updates the frontend dependency to match. Co-authored-by: Claude Opus 4.7 (1M context) --- cli/windmill-utils-internal/package-lock.json | 4 +- cli/windmill-utils-internal/package.json | 7 ++- cli/windmill-utils-internal/src/index.ts | 1 - frontend/package-lock.json | 58 ++----------------- frontend/package.json | 2 +- 5 files changed, 15 insertions(+), 57 deletions(-) diff --git a/cli/windmill-utils-internal/package-lock.json b/cli/windmill-utils-internal/package-lock.json index e9ce5e3c9a..36a8962fa9 100644 --- a/cli/windmill-utils-internal/package-lock.json +++ b/cli/windmill-utils-internal/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-utils-internal", - "version": "1.3.6", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-utils-internal", - "version": "1.3.6", + "version": "1.4.0", "license": "Apache 2.0", "devDependencies": { "@types/node": "^24.2.0", diff --git a/cli/windmill-utils-internal/package.json b/cli/windmill-utils-internal/package.json index 56dcfd9905..a0691c56c3 100644 --- a/cli/windmill-utils-internal/package.json +++ b/cli/windmill-utils-internal/package.json @@ -1,6 +1,6 @@ { "name": "windmill-utils-internal", - "version": "1.3.8", + "version": "1.4.0", "description": "Internal utility functions for Windmill", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -10,6 +10,11 @@ "require": "./dist/cjs/index.js", "import": "./dist/esm/index.js", "types": "./dist/esm/index.d.ts" + }, + "./config": { + "require": "./dist/cjs/config/index.js", + "import": "./dist/esm/config/index.js", + "types": "./dist/esm/config/index.d.ts" } }, "scripts": { diff --git a/cli/windmill-utils-internal/src/index.ts b/cli/windmill-utils-internal/src/index.ts index 52c489da4c..d23a11e8a6 100644 --- a/cli/windmill-utils-internal/src/index.ts +++ b/cli/windmill-utils-internal/src/index.ts @@ -11,6 +11,5 @@ export * from "./inline-scripts"; export * from "./path-utils"; export * from "./parse"; -export * from "./config"; export * from "./deploy"; export { SEP, DELIMITER } from "./constants"; \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 767365375d..6fcd7f34c0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -90,7 +90,7 @@ "windmill-parser-wasm-wac": "1.668.6", "windmill-parser-wasm-yaml": "1.593.0", "windmill-sql-datatype-parser-wasm": "1.512.0", - "windmill-utils-internal": "^1.3.8", + "windmill-utils-internal": "^1.4.0", "xterm": "^5.3.0", "xterm-readline": "^1.1.2", "y-monaco": "^0.1.4", @@ -844,7 +844,6 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -856,7 +855,6 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -867,7 +865,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1357,7 +1354,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1514,7 +1510,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1531,7 +1526,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1548,7 +1542,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1565,7 +1558,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1582,7 +1574,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1599,7 +1590,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1616,7 +1606,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1633,7 +1622,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1650,7 +1638,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1667,7 +1654,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1684,7 +1670,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1701,7 +1686,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1718,7 +1702,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1735,7 +1718,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1752,7 +1734,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2058,7 +2039,6 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6834,7 +6814,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -7333,7 +7313,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7354,7 +7333,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7375,7 +7353,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7396,7 +7373,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7417,7 +7393,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7438,7 +7413,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7459,7 +7433,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7480,7 +7453,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7501,7 +7473,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7522,7 +7493,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7543,7 +7513,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12112,21 +12081,6 @@ } } }, - "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", @@ -12857,7 +12811,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -13706,9 +13660,9 @@ "integrity": "sha512-uHNL8F72/Tf96xF3hOHnPDjkEyqXw7fNjcPJiUhth9sTQkcwUIoJMOdwm8/cs+j9kKVRJ4tgNYMHEBLylazp6g==" }, "node_modules/windmill-utils-internal": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/windmill-utils-internal/-/windmill-utils-internal-1.3.8.tgz", - "integrity": "sha512-FtVEvAI2PIqPTEpowTjo5c5JkYe09Scu9zcwzJutOWMEh4aDdzOejaG7EZTac0pk+dK4JB46+nbl82hhLsL8Mw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/windmill-utils-internal/-/windmill-utils-internal-1.4.0.tgz", + "integrity": "sha512-IX5MEuHkRTDyqtCDh6Qez43faAD8Q7VcFTDriZZ831l3ZPvBZSch5D0ejeu0DLc1GE0GOj4YoPXzt1mVO8GFBA==", "license": "Apache 2.0" }, "node_modules/word-wrap": { diff --git a/frontend/package.json b/frontend/package.json index ff700a5fce..508e3813f8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -163,7 +163,7 @@ "windmill-parser-wasm-wac": "1.668.6", "windmill-parser-wasm-yaml": "1.593.0", "windmill-sql-datatype-parser-wasm": "1.512.0", - "windmill-utils-internal": "^1.3.8", + "windmill-utils-internal": "^1.4.0", "xterm": "^5.3.0", "xterm-readline": "^1.1.2", "y-monaco": "^0.1.4", From 6e5a21a9c7b5db77d325b5916a9ab8799a2eb6e7 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 5 May 2026 22:11:38 +0200 Subject: [PATCH 025/313] fix(flows): inherit flow_env in sub-flow predicates (#9042) * fix(flows): inherit flow_env in sub-flow predicates Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(flows): align flow_env lookup with get_root_job_id and tighten gate Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(flows): drop recursive CTE, root_job propagation suffices Co-Authored-By: Claude Opus 4.7 (1M context) * fix(flows): walk via flow_innermost_root_job to respect imported-flow scope Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(flows): remove flow_env API endpoint, dead code from deno_core era Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- ...5a9108b6f32490cb61b14de71102bb305f71d.json | 24 + ...86d7c4b9bdb1e8fb1f7725060990ef8984943.json | 24 - ...85242186449ff592ed457daac59b37b94aa00.json | 17 + ...3f5343e68856dfede19597813893b7e99ead1.json | 17 + backend/tests/flow_engine_parity.rs | 1046 +++++++++++++++++ backend/windmill-api/src/jobs.rs | 128 -- backend/windmill-common/src/client.rs | 20 - backend/windmill-jseval/src/lib.rs | 54 +- backend/windmill-worker/src/worker_flow.rs | 160 ++- 9 files changed, 1281 insertions(+), 209 deletions(-) create mode 100644 backend/.sqlx/query-14c2784a68f06e7349941671abe5a9108b6f32490cb61b14de71102bb305f71d.json delete mode 100644 backend/.sqlx/query-2f53576c2ad58abc24617e911e486d7c4b9bdb1e8fb1f7725060990ef8984943.json create mode 100644 backend/.sqlx/query-54d3dd91f3348c03b8b39ecb59c85242186449ff592ed457daac59b37b94aa00.json create mode 100644 backend/.sqlx/query-ea132d80fdb6525f192797fd77b3f5343e68856dfede19597813893b7e99ead1.json diff --git a/backend/.sqlx/query-14c2784a68f06e7349941671abe5a9108b6f32490cb61b14de71102bb305f71d.json b/backend/.sqlx/query-14c2784a68f06e7349941671abe5a9108b6f32490cb61b14de71102bb305f71d.json new file mode 100644 index 0000000000..d7ef4e04e0 --- /dev/null +++ b/backend/.sqlx/query-14c2784a68f06e7349941671abe5a9108b6f32490cb61b14de71102bb305f71d.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE chain(id, parent_job, flow_innermost_root_job, runnable_id, runnable_path, raw_flow, depth) AS (\n SELECT id, parent_job, flow_innermost_root_job, runnable_id, runnable_path, raw_flow, 0\n FROM v2_job\n WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id, j.parent_job, j.flow_innermost_root_job, j.runnable_id, j.runnable_path, j.raw_flow, c.depth + 1\n FROM v2_job j\n JOIN chain c\n ON j.id = COALESCE(c.flow_innermost_root_job, c.parent_job)\n WHERE j.workspace_id = $2 AND c.depth < $3\n )\n SELECT\n CASE\n WHEN flow_version.id IS NOT NULL THEN\n flow_version.value -> 'flow_env'\n ELSE\n chain.raw_flow -> 'flow_env'\n END AS \"flow_env: Json>>\"\n FROM chain\n LEFT JOIN flow_version\n ON flow_version.id = chain.runnable_id\n AND flow_version.path = chain.runnable_path\n AND flow_version.workspace_id = $2\n WHERE (CASE\n WHEN flow_version.id IS NOT NULL THEN flow_version.value -> 'flow_env'\n ELSE chain.raw_flow -> 'flow_env'\n END) IS NOT NULL\n ORDER BY chain.depth ASC\n LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "flow_env: Json>>", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Int4" + ] + }, + "nullable": [ + null + ] + }, + "hash": "14c2784a68f06e7349941671abe5a9108b6f32490cb61b14de71102bb305f71d" +} diff --git a/backend/.sqlx/query-2f53576c2ad58abc24617e911e486d7c4b9bdb1e8fb1f7725060990ef8984943.json b/backend/.sqlx/query-2f53576c2ad58abc24617e911e486d7c4b9bdb1e8fb1f7725060990ef8984943.json deleted file mode 100644 index 8c5f43ab07..0000000000 --- a/backend/.sqlx/query-2f53576c2ad58abc24617e911e486d7c4b9bdb1e8fb1f7725060990ef8984943.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n CASE\n WHEN flow_version.id IS NOT NULL THEN\n flow_version.value -> 'flow_env' -> $3\n ELSE\n root_job.raw_flow -> 'flow_env' -> $3\n END AS \"flow_env: sqlx::types::Json>\"\n FROM\n v2_job current_job\n JOIN\n v2_job root_job ON root_job.id = COALESCE(current_job.root_job, current_job.flow_innermost_root_job, current_job.parent_job, current_job.id)\n AND root_job.workspace_id = current_job.workspace_id\n LEFT JOIN\n flow_version ON flow_version.id = root_job.runnable_id\n AND flow_version.path = root_job.runnable_path\n AND flow_version.workspace_id = root_job.workspace_id\n WHERE\n current_job.id = $1 AND\n current_job.workspace_id = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "flow_env: sqlx::types::Json>", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "2f53576c2ad58abc24617e911e486d7c4b9bdb1e8fb1f7725060990ef8984943" -} diff --git a/backend/.sqlx/query-54d3dd91f3348c03b8b39ecb59c85242186449ff592ed457daac59b37b94aa00.json b/backend/.sqlx/query-54d3dd91f3348c03b8b39ecb59c85242186449ff592ed457daac59b37b94aa00.json new file mode 100644 index 0000000000..962446787e --- /dev/null +++ b/backend/.sqlx/query-54d3dd91f3348c03b8b39ecb59c85242186449ff592ed457daac59b37b94aa00.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO public.flow(workspace_id, summary, description, path, versions, schema, value, edited_by) VALUES ($1, '', '', $2, ARRAY[$3]::bigint[], '{}'::jsonb, $4, 'system')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Int8", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "54d3dd91f3348c03b8b39ecb59c85242186449ff592ed457daac59b37b94aa00" +} diff --git a/backend/.sqlx/query-ea132d80fdb6525f192797fd77b3f5343e68856dfede19597813893b7e99ead1.json b/backend/.sqlx/query-ea132d80fdb6525f192797fd77b3f5343e68856dfede19597813893b7e99ead1.json new file mode 100644 index 0000000000..593408b1d4 --- /dev/null +++ b/backend/.sqlx/query-ea132d80fdb6525f192797fd77b3f5343e68856dfede19597813893b7e99ead1.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO public.flow_version(id, workspace_id, path, schema, value, created_by) VALUES ($1, $2, $3, '{}'::jsonb, $4, 'system')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Varchar", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "ea132d80fdb6525f192797fd77b3f5343e68856dfede19597813893b7e99ead1" +} diff --git a/backend/tests/flow_engine_parity.rs b/backend/tests/flow_engine_parity.rs index 363ab4fb16..bae776e7f1 100644 --- a/backend/tests/flow_engine_parity.rs +++ b/backend/tests/flow_engine_parity.rs @@ -2097,3 +2097,1049 @@ export function main( Ok(()) } + +// ============================================================================= +// flow_env inside predicates of nested sub-flows (BranchOne / loops). +// +// Sub-flows spawned by `payload_from_modules` for branches/loops don't carry +// the parent's `flow_env` in their own FlowValue, so without explicit lookup +// the predicate evaluators receive `None` and `flow_env.X` resolves to +// `undefined` inside QuickJS. Verify `handle_flow` walks up to the nearest +// enclosing scope so predicates see the inherited env. +// ============================================================================= + +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_flow_env_skip_if_inside_branchone(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + + let mut flow_env = std::collections::HashMap::new(); + flow_env.insert( + "SKIP".to_string(), + windmill_common::worker::to_raw_value(&json!(true)), + ); + + let inner_step = { + let mut m = flow_module( + "inner", + FlowModuleValue::RawScript { + input_transforms: Default::default(), + language: ScriptLang::Deno, + content: r#" +export function main() { + return {ran: true}; +} +"# + .to_string(), + path: None, + lock: None, + tag: None, + concurrency_settings: Default::default(), + is_trigger: None, + assets: None, + }, + ); + // skip_if uses `Boolean(...)`-wrapped expression, so it falls through + // to QuickJS and exercises the local flow_env propagation path. + m.skip_if = + Some(windmill_common::flows::SkipIf { expr: "flow_env.SKIP === true".to_string() }); + m + }; + + // Branch with two modules: a marker that runs first, then `inner` which + // should be skipped via `flow_env.SKIP === true`. When skipped, `inner` + // becomes an identity job and the branch's terminal result is whatever + // `previous_result` was at that point — i.e. the branch_marker output. + let branch_marker = flow_module( + "branch_marker", + FlowModuleValue::RawScript { + input_transforms: Default::default(), + language: ScriptLang::Deno, + content: r#" +export function main() { + return {marker: "branch-marker"}; +} +"# + .to_string(), + path: None, + lock: None, + tag: None, + concurrency_settings: Default::default(), + is_trigger: None, + assets: None, + }, + ); + + let flow = FlowValue { + modules: vec![ + flow_module( + "router", + FlowModuleValue::BranchOne { + branches: vec![Branch { + summary: None, + expr: "true".to_string(), + modules: vec![branch_marker, inner_step], + modules_node: None, + skip_failure: false, + parallel: false, + }], + default: vec![], + default_node: None, + }, + ), + flow_module( + "after", + FlowModuleValue::RawScript { + input_transforms: [js_input("prev", "previous_result")].into(), + language: ScriptLang::Deno, + content: r#" +export function main(prev: any) { + return {prev}; +} +"# + .to_string(), + path: None, + lock: None, + tag: None, + concurrency_settings: Default::default(), + is_trigger: None, + assets: None, + }, + ), + ], + flow_env: Some(flow_env), + same_worker: false, + ..Default::default() + }; + + let result = + RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None }) + .run_until_complete(&db, false, server.addr.port()) + .await + .json_result() + .unwrap(); + + // With the fix, skip_if sees `flow_env.SKIP === true`, `inner` becomes an + // identity step and passes through `previous_result` (branch_marker). + // Without the fix, flow_env was None inside the sub-flow, the predicate + // returned false, and `inner` ran, leaving `{ran: true}` in `prev`. + assert_eq!( + result["prev"]["marker"], "branch-marker", + "skip_if with flow_env should skip `inner`; expected branch_marker passed through (got {result:?})" + ); + assert!( + result["prev"].get("ran").is_none(), + "`inner` ran when it should have been skipped (got {result:?})" + ); + + Ok(()) +} + +// Nested sub-flows: branch inside branch. The recursive CTE in +// `fetch_root_flow_env` must walk past more than one layer of +// `payload_from_modules`-constructed FlowValue (each of which has +// `flow_env = None`) to reach the root's flow_env. +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_flow_env_skip_if_nested_branchone(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + + let mut flow_env = std::collections::HashMap::new(); + flow_env.insert( + "SKIP".to_string(), + windmill_common::worker::to_raw_value(&json!(true)), + ); + + let leaf = { + let mut m = flow_module( + "leaf", + FlowModuleValue::RawScript { + input_transforms: Default::default(), + language: ScriptLang::Deno, + content: r#" +export function main() { + return {ran: true}; +} +"# + .to_string(), + path: None, + lock: None, + tag: None, + concurrency_settings: Default::default(), + is_trigger: None, + assets: None, + }, + ); + m.skip_if = + Some(windmill_common::flows::SkipIf { expr: "flow_env.SKIP === true".to_string() }); + m + }; + + let inner_marker = flow_module( + "inner_marker", + FlowModuleValue::RawScript { + input_transforms: Default::default(), + language: ScriptLang::Deno, + content: r#" +export function main() { + return {marker: "inner"}; +} +"# + .to_string(), + path: None, + lock: None, + tag: None, + concurrency_settings: Default::default(), + is_trigger: None, + assets: None, + }, + ); + + // Inner BranchOne: contains the marker + the leaf with skip_if. + let inner_branch = flow_module( + "inner_router", + FlowModuleValue::BranchOne { + branches: vec![Branch { + summary: None, + expr: "true".to_string(), + modules: vec![inner_marker, leaf], + modules_node: None, + skip_failure: false, + parallel: false, + }], + default: vec![], + default_node: None, + }, + ); + + // Outer BranchOne: contains the inner BranchOne. So the leaf is two + // levels deep in payload_from_modules-constructed sub-flows. + let outer = flow_module( + "outer_router", + FlowModuleValue::BranchOne { + branches: vec![Branch { + summary: None, + expr: "true".to_string(), + modules: vec![inner_branch], + modules_node: None, + skip_failure: false, + parallel: false, + }], + default: vec![], + default_node: None, + }, + ); + + let after = flow_module( + "after", + FlowModuleValue::RawScript { + input_transforms: [js_input("prev", "previous_result")].into(), + language: ScriptLang::Deno, + content: r#" +export function main(prev: any) { + return {prev}; +} +"# + .to_string(), + path: None, + lock: None, + tag: None, + concurrency_settings: Default::default(), + is_trigger: None, + assets: None, + }, + ); + + let flow = FlowValue { + modules: vec![outer, after], + flow_env: Some(flow_env), + same_worker: false, + ..Default::default() + }; + + let result = + RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None }) + .run_until_complete(&db, false, server.addr.port()) + .await + .json_result() + .unwrap(); + + // Leaf's skip_if should see flow_env.SKIP=true → leaf becomes identity → + // previous_result inside the inner branch is `inner_marker`. That bubbles + // up to the outer branch and into `after`. + assert_eq!( + result["prev"]["marker"], "inner", + "skip_if with flow_env should skip `leaf` even nested two layers deep (got {result:?})" + ); + assert!( + result["prev"].get("ran").is_none(), + "`leaf` ran when it should have been skipped two layers deep (got {result:?})" + ); + + Ok(()) +} + +// Complex input-transform expression inside a sub-flow exercises the +// QuickJS evaluation path (it doesn't match the `flow_env.X` / +// `flow_env.X.Y` regex that hits the API fast path). Without flow_env +// inheritance, QuickJS would see an empty `flow_env` and the expression +// would NaN/undefined out. +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_flow_env_complex_input_transform_in_branch(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + + let mut flow_env = std::collections::HashMap::new(); + flow_env.insert( + "LIMIT".to_string(), + windmill_common::worker::to_raw_value(&json!(7)), + ); + flow_env.insert( + "OFFSET".to_string(), + windmill_common::worker::to_raw_value(&json!(3)), + ); + + let inner = flow_module( + "compute", + FlowModuleValue::RawScript { + // Expression doesn't match the regex fast path (uses arithmetic + // and Math.min), so the worker falls through to QuickJS using + // the local flow_env. Without inheritance, this is empty. + input_transforms: [js_input( + "value", + "Math.min(flow_env.LIMIT, 10) + flow_env.OFFSET", + )] + .into(), + language: ScriptLang::Deno, + content: r#" +export function main(value: number) { + return {value}; +} +"# + .to_string(), + path: None, + lock: None, + tag: None, + concurrency_settings: Default::default(), + is_trigger: None, + assets: None, + }, + ); + + let flow = FlowValue { + modules: vec![flow_module( + "router", + FlowModuleValue::BranchOne { + branches: vec![Branch { + summary: None, + expr: "true".to_string(), + modules: vec![inner], + modules_node: None, + skip_failure: false, + parallel: false, + }], + default: vec![], + default_node: None, + }, + )], + flow_env: Some(flow_env), + same_worker: false, + ..Default::default() + }; + + let result = + RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None }) + .run_until_complete(&db, false, server.addr.port()) + .await + .json_result() + .unwrap(); + + // min(7, 10) + 3 = 10 + assert_eq!( + result["value"], 10, + "complex input transform `Math.min(flow_env.LIMIT, 10) + flow_env.OFFSET` should resolve via QuickJS with inherited flow_env (got {result:?})" + ); + + Ok(()) +} + +// Parallel for-loop iterations are pushed with `flow_innermost_root_job = +// None` (worker_flow.rs:3941), so the recursive CTE in `fetch_root_flow_env` +// must use `parent_job` to walk up. Verify a skip_if inside an iteration +// sub-flow sees the parent's flow_env. +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_flow_env_skip_if_in_parallel_forloop(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + + let mut flow_env = std::collections::HashMap::new(); + flow_env.insert( + "SKIP".to_string(), + windmill_common::worker::to_raw_value(&json!(true)), + ); + + let inner_marker = flow_module( + "marker", + FlowModuleValue::RawScript { + input_transforms: [js_input("i", "flow_input.iter.value")].into(), + language: ScriptLang::Deno, + content: r#" +export function main(i: number) { + return {marker: i}; +} +"# + .to_string(), + path: None, + lock: None, + tag: None, + concurrency_settings: Default::default(), + is_trigger: None, + assets: None, + }, + ); + + let leaf = { + let mut m = flow_module( + "leaf", + FlowModuleValue::RawScript { + input_transforms: Default::default(), + language: ScriptLang::Deno, + content: r#" +export function main() { + return {ran: true}; +} +"# + .to_string(), + path: None, + lock: None, + tag: None, + concurrency_settings: Default::default(), + is_trigger: None, + assets: None, + }, + ); + m.skip_if = + Some(windmill_common::flows::SkipIf { expr: "flow_env.SKIP === true".to_string() }); + m + }; + + let flow = FlowValue { + modules: vec![flow_module( + "loop", + FlowModuleValue::ForloopFlow { + iterator: InputTransform::Javascript { expr: "[1, 2]".to_string() }, + modules: vec![inner_marker, leaf], + modules_node: None, + skip_failures: false, + parallel: true, + parallelism: None, + squash: None, + }, + )], + flow_env: Some(flow_env), + same_worker: false, + ..Default::default() + }; + + let result = + RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None }) + .run_until_complete(&db, false, server.addr.port()) + .await + .json_result() + .unwrap(); + + // Each iteration's `leaf` is skipped (skip_if reads flow_env.SKIP=true via + // parent_job lookup since parallel iterations have flow_innermost_root_job + // = None). The skipped step passes through previous_result = marker's + // output. So iteration result = `{marker: i}`, not `{ran: true}`. + let arr = result.as_array().expect("parallel loop result is an array"); + assert_eq!(arr.len(), 2, "expected 2 iterations, got {result:?}"); + for (i, iter_result) in arr.iter().enumerate() { + assert_eq!( + iter_result["marker"], + json!(i + 1), + "iteration {i} marker mismatch (got {result:?})" + ); + assert!( + iter_result.get("ran").is_none(), + "leaf ran in iteration {i} when it should have been skipped (got {result:?})" + ); + } + + Ok(()) +} + +// Imported flows (`FlowModuleValue::Flow { path }`) load their value from +// `flow_version`. Two cases: +// (a) the imported flow defines its own flow_env → that wins, parent's is +// NOT merged (current behavior; option (i) per design discussion). +// (b) the imported flow defines no flow_env → it inherits from the parent +// via the recursive CTE. +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_flow_env_imported_flow_uses_own_env(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + + // Imported flow with its own flow_env: a single step that returns + // `flow_env.KEY`. Saved at f/system/imported_with_env. + let imported_path = "f/system/imported_with_env"; + let imported_value = json!({ + "modules": [{ + "id": "leaf", + "value": { + "type": "rawscript", + "language": "deno", + "content": "export function main(key: string) { return {key}; }", + "input_transforms": { + "key": { "type": "javascript", "expr": "flow_env.KEY" } + } + } + }], + "flow_env": { "KEY": "imported" } + }); + let imported_version_id: i64 = 9991001; + sqlx::query!( + "INSERT INTO public.flow(workspace_id, summary, description, path, versions, schema, value, edited_by) VALUES ($1, '', '', $2, ARRAY[$3]::bigint[], '{}'::jsonb, $4, 'system')", + "test-workspace", + imported_path, + imported_version_id, + imported_value.clone(), + ) + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO public.flow_version(id, workspace_id, path, schema, value, created_by) VALUES ($1, $2, $3, '{}'::jsonb, $4, 'system')", + imported_version_id, + "test-workspace", + imported_path, + imported_value.clone(), + ) + .execute(&db) + .await?; + + let mut parent_env = std::collections::HashMap::new(); + parent_env.insert( + "KEY".to_string(), + windmill_common::worker::to_raw_value(&json!("parent")), + ); + + let parent = FlowValue { + modules: vec![flow_module( + "import", + FlowModuleValue::Flow { + input_transforms: Default::default(), + path: imported_path.to_string(), + pass_flow_input_directly: None, + }, + )], + flow_env: Some(parent_env), + same_worker: false, + ..Default::default() + }; + + let result = + RunJob::from(JobPayload::RawFlow { value: parent, path: None, restarted_from: None }) + .run_until_complete(&db, false, server.addr.port()) + .await + .json_result() + .unwrap(); + + // The imported flow's `leaf` reads flow_env.KEY. The imported flow has its + // own flow_env so it wins — result should be "imported", not "parent". + assert_eq!( + result["key"], "imported", + "imported flow's own flow_env should win over parent's (got {result:?})" + ); + + Ok(()) +} + +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_flow_env_imported_flow_inherits_when_unset(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + + // Imported flow without its own flow_env. The leaf step's `skip_if` uses + // a Boolean()-wrapped expression — that always falls through to QuickJS + // (no regex/API fast path) and reads the LOCAL flow_env. Without the fix, + // local flow_env inside the imported sub-flow is None and the predicate + // returns false; with the fix, the imported flow inherits the parent's + // env via the recursive CTE. + let imported_path = "f/system/imported_no_env"; + let imported_value = json!({ + "modules": [ + { + "id": "marker", + "value": { + "type": "rawscript", + "language": "deno", + "content": "export function main() { return {marker: \"from-imported\"}; }", + "input_transforms": {} + } + }, + { + "id": "leaf", + "value": { + "type": "rawscript", + "language": "deno", + "content": "export function main() { return {ran: true}; }", + "input_transforms": {} + }, + "skip_if": { "expr": "flow_env.KEY === 'parent'" } + } + ] + }); + let imported_version_id: i64 = 9991002; + sqlx::query!( + "INSERT INTO public.flow(workspace_id, summary, description, path, versions, schema, value, edited_by) VALUES ($1, '', '', $2, ARRAY[$3]::bigint[], '{}'::jsonb, $4, 'system')", + "test-workspace", + imported_path, + imported_version_id, + imported_value.clone(), + ) + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO public.flow_version(id, workspace_id, path, schema, value, created_by) VALUES ($1, $2, $3, '{}'::jsonb, $4, 'system')", + imported_version_id, + "test-workspace", + imported_path, + imported_value.clone(), + ) + .execute(&db) + .await?; + + let mut parent_env = std::collections::HashMap::new(); + parent_env.insert( + "KEY".to_string(), + windmill_common::worker::to_raw_value(&json!("parent")), + ); + + let parent = FlowValue { + modules: vec![flow_module( + "import", + FlowModuleValue::Flow { + input_transforms: Default::default(), + path: imported_path.to_string(), + pass_flow_input_directly: None, + }, + )], + flow_env: Some(parent_env), + same_worker: false, + ..Default::default() + }; + + let result = + RunJob::from(JobPayload::RawFlow { value: parent, path: None, restarted_from: None }) + .run_until_complete(&db, false, server.addr.port()) + .await + .json_result() + .unwrap(); + + // Imported flow has no flow_env → inherits parent's via lookup. The + // leaf's skip_if (`flow_env.KEY === 'parent'`) evaluates to true → leaf + // becomes identity, passes through `previous_result` (marker's output). + // Without the fix, skip_if's QuickJS context has flow_env=None inside + // the imported sub-flow, the predicate is false, and `leaf` runs. + assert_eq!( + result["marker"], "from-imported", + "imported flow's leaf should be skipped via inherited flow_env (got {result:?})" + ); + assert!( + result.get("ran").is_none(), + "leaf ran when it should have been skipped (got {result:?})" + ); + + Ok(()) +} + +// Imported flow with its own flow_env contains a nested BranchOne whose +// inner step has a skip_if predicate. The branch sub-flow inside the +// imported flow has `root_job` pointing to the **top parent**, but its +// `flow_innermost_root_job` points to the imported flow — so the lookup +// must walk via flow_innermost_root_job to find the imported flow's scope, +// not jump straight to root_job (which would surface the parent's env and +// give the wrong answer). +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_flow_env_imported_flow_with_nested_branch(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + + // Imported flow: own flow_env={KEY: "imported"}, contains a BranchOne + // whose inner step has skip_if = "flow_env.KEY === 'imported'". The + // predicate must see the IMPORTED flow's env, not the parent's + // ({KEY: "parent"}). + let imported_path = "f/system/imported_with_nested_branch"; + let imported_value = json!({ + "modules": [{ + "id": "router", + "value": { + "type": "branchone", + "branches": [{ + "summary": null, + "expr": "true", + "modules": [ + { + "id": "marker", + "value": { + "type": "rawscript", + "language": "deno", + "content": "export function main() { return {marker: \"from-imported-branch\"}; }", + "input_transforms": {} + } + }, + { + "id": "leaf", + "value": { + "type": "rawscript", + "language": "deno", + "content": "export function main() { return {ran: true}; }", + "input_transforms": {} + }, + "skip_if": { "expr": "flow_env.KEY === 'imported'" } + } + ], + "skip_failure": false, + "parallel": false, + }], + "default": [], + } + }], + "flow_env": { "KEY": "imported" } + }); + let imported_version_id: i64 = 9991003; + sqlx::query!( + "INSERT INTO public.flow(workspace_id, summary, description, path, versions, schema, value, edited_by) VALUES ($1, '', '', $2, ARRAY[$3]::bigint[], '{}'::jsonb, $4, 'system')", + "test-workspace", + imported_path, + imported_version_id, + imported_value.clone(), + ) + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO public.flow_version(id, workspace_id, path, schema, value, created_by) VALUES ($1, $2, $3, '{}'::jsonb, $4, 'system')", + imported_version_id, + "test-workspace", + imported_path, + imported_value.clone(), + ) + .execute(&db) + .await?; + + let mut parent_env = std::collections::HashMap::new(); + parent_env.insert( + "KEY".to_string(), + windmill_common::worker::to_raw_value(&json!("parent")), + ); + + let parent = FlowValue { + modules: vec![flow_module( + "import", + FlowModuleValue::Flow { + input_transforms: Default::default(), + path: imported_path.to_string(), + pass_flow_input_directly: None, + }, + )], + flow_env: Some(parent_env), + same_worker: false, + ..Default::default() + }; + + let result = + RunJob::from(JobPayload::RawFlow { value: parent, path: None, restarted_from: None }) + .run_until_complete(&db, false, server.addr.port()) + .await + .json_result() + .unwrap(); + + // skip_if must see imported's env (KEY="imported") → predicate is true → + // leaf is skipped → branch returns marker's output. If the lookup + // shortcuts via root_job to the top parent, KEY would be "parent", + // skip_if would be false, leaf would run and return {ran: true}. + assert_eq!( + result["marker"], "from-imported-branch", + "skip_if inside imported flow's branch must see imported's flow_env, not parent's (got {result:?})" + ); + assert!( + result.get("ran").is_none(), + "leaf ran — predicate didn't see imported flow's flow_env scope (got {result:?})" + ); + + Ok(()) +} + +// stop_after_if predicate sees flow_env. Regression for the eval at line 614 +// of `update_flow_status_after_job_completion_internal` which used to pass +// `None` for flow_env unconditionally. +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_flow_env_in_stop_after_if(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + + let mut flow_env = std::collections::HashMap::new(); + flow_env.insert( + "STOP".to_string(), + windmill_common::worker::to_raw_value(&json!(true)), + ); + + let first = { + let mut m = flow_module( + "first", + FlowModuleValue::RawScript { + input_transforms: Default::default(), + language: ScriptLang::Deno, + content: r#" +export function main() { + return {stage: "first"}; +} +"# + .to_string(), + path: None, + lock: None, + tag: None, + concurrency_settings: Default::default(), + is_trigger: None, + assets: None, + }, + ); + m.stop_after_if = Some(windmill_common::flows::StopAfterIf { + expr: "flow_env.STOP === true".to_string(), + skip_if_stopped: true, + error_message: None, + }); + m + }; + + let second = flow_module( + "second", + FlowModuleValue::RawScript { + input_transforms: Default::default(), + language: ScriptLang::Deno, + content: r#" +export function main() { + return {stage: "second"}; +} +"# + .to_string(), + path: None, + lock: None, + tag: None, + concurrency_settings: Default::default(), + is_trigger: None, + assets: None, + }, + ); + + let flow = FlowValue { + modules: vec![first, second], + flow_env: Some(flow_env), + same_worker: false, + ..Default::default() + }; + + let result = + RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None }) + .run_until_complete(&db, false, server.addr.port()) + .await + .json_result() + .unwrap(); + + // With fix: stop_after_if reads flow_env.STOP=true → flow stops early + // after `first`, result is first's output. + // Without fix: stop_after_if sees flow_env=None, predicate is false, the + // flow continues to `second` whose output overrides the result. + assert_eq!( + result["stage"], "first", + "stop_after_if with flow_env should stop after `first`; got {result:?}" + ); + + Ok(()) +} + +// retry_if predicate sees flow_env. Regression for the two evaluate_retry +// call sites in `update_flow_status_after_job_completion_internal` (lines +// 1194 and 1576) which used to pass `None` for flow_env. +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_flow_env_in_retry_if(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + + let mut flow_env = std::collections::HashMap::new(); + flow_env.insert( + "SHOULD_RETRY".to_string(), + windmill_common::worker::to_raw_value(&json!(true)), + ); + + let fails = { + let mut m = flow_module( + "fails", + FlowModuleValue::RawScript { + input_transforms: Default::default(), + language: ScriptLang::Deno, + content: r#" +export function main() { + throw new Error("nope"); +} +"# + .to_string(), + path: None, + lock: None, + tag: None, + concurrency_settings: Default::default(), + is_trigger: None, + assets: None, + }, + ); + m.retry = Some(windmill_common::flows::Retry { + constant: windmill_common::flows::ConstantDelay { attempts: 2, seconds: 0 }, + exponential: Default::default(), + retry_if: Some(windmill_common::flows::RetryIf { + expr: "flow_env.SHOULD_RETRY === true".to_string(), + }), + }); + m + }; + + let flow = FlowValue { + modules: vec![fails], + flow_env: Some(flow_env), + same_worker: false, + ..Default::default() + }; + + let completed = + RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None }) + .run_until_complete(&db, false, server.addr.port()) + .await; + + // The flow always fails (script throws every attempt), but retry_if + // controls whether retries happen at all. With the fix, retry_if sees + // flow_env.SHOULD_RETRY=true and retries fire (fail_count > 0). Without + // the fix, the predicate gets `None` for flow_env, evaluates to false, + // and the flow fails on the first attempt with fail_count = 0. + let flow_status = completed + .flow_status + .as_ref() + .expect("flow should have a flow_status"); + let module_status = &flow_status["modules"][0]; + let failed_retries = module_status["failed_retries"].as_array(); + assert!( + failed_retries.is_some_and(|v| !v.is_empty()), + "retry_if with flow_env should have triggered retries; module status: {module_status:?}" + ); + + Ok(()) +} + +// stop_after_all_iters_if predicate sees flow_env. Regression for the +// signature change to `evaluate_stop_after_all_iters_if`. +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_flow_env_in_stop_after_all_iters_if(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + + let mut flow_env = std::collections::HashMap::new(); + flow_env.insert( + "STOP".to_string(), + windmill_common::worker::to_raw_value(&json!(true)), + ); + + let inner = flow_module( + "iter_step", + FlowModuleValue::RawScript { + input_transforms: [js_input("i", "flow_input.iter.value")].into(), + language: ScriptLang::Deno, + content: r#" +export function main(i: number) { + return {iter: i}; +} +"# + .to_string(), + path: None, + lock: None, + tag: None, + concurrency_settings: Default::default(), + is_trigger: None, + assets: None, + }, + ); + + let loop_module = { + let mut m = flow_module( + "loop", + FlowModuleValue::ForloopFlow { + iterator: InputTransform::Javascript { expr: "[1, 2, 3]".to_string() }, + modules: vec![inner], + modules_node: None, + skip_failures: false, + parallel: false, + parallelism: None, + squash: None, + }, + ); + m.stop_after_all_iters_if = Some(windmill_common::flows::StopAfterIf { + expr: "flow_env.STOP === true".to_string(), + skip_if_stopped: true, + error_message: None, + }); + m + }; + + let after = flow_module( + "after", + FlowModuleValue::RawScript { + input_transforms: Default::default(), + language: ScriptLang::Deno, + content: r#" +export function main() { + return {stage: "after-loop"}; +} +"# + .to_string(), + path: None, + lock: None, + tag: None, + concurrency_settings: Default::default(), + is_trigger: None, + assets: None, + }, + ); + + let flow = FlowValue { + modules: vec![loop_module, after], + flow_env: Some(flow_env), + same_worker: false, + ..Default::default() + }; + + let result = + RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None }) + .run_until_complete(&db, false, server.addr.port()) + .await + .json_result() + .unwrap(); + + // With fix: stop_after_all_iters_if reads flow_env.STOP=true after the + // loop completes → flow stops, `after` does not run, final result is + // the loop's output. + // Without fix: predicate sees flow_env=None, returns false, `after` runs + // and overrides the result. + assert!( + result.get("stage").is_none() || result["stage"] != "after-loop", + "stop_after_all_iters_if with flow_env should stop after the loop; got {result:?}" + ); + + Ok(()) +} diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 9907e35529..1ed5c7c9e4 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -343,10 +343,6 @@ pub fn workspaced_service() -> Router { "/result_by_id/{job_id}/{node_id}", get(get_result_by_id).layer(cors.clone()), ) - .route( - "/flow_env_by_flow_job_id/{flow_job_id}/{var_name}", - get(get_flow_env_by_flow_job_id).layer(cors.clone()), - ) .route("/run/dependencies", post(run_dependencies_job)) .route("/run/dependencies_async", post(run_dependencies_job_async)) .route("/run/flow_dependencies", post(run_flow_dependencies_job)) @@ -452,130 +448,6 @@ async fn get_root_job( Ok(Json(res)) } -async fn get_flow_env_by_flow_job_id( - authed: ApiAuthed, - tokened: Tokened, - Extension(db): Extension, - Path((w_id, flow_job_id, var_name)): Path<(String, Uuid, String)>, - Query(JsonPath { json_path, .. }): Query, -) -> windmill_common::error::JsonResult> { - // Fetch raw value (without json_path) to check for $var:/$res: references - let raw_value = sqlx::query_scalar!( - r#" - SELECT - CASE - WHEN flow_version.id IS NOT NULL THEN - flow_version.value -> 'flow_env' -> $3 - ELSE - root_job.raw_flow -> 'flow_env' -> $3 - END AS "flow_env: sqlx::types::Json>" - FROM - v2_job current_job - JOIN - v2_job root_job ON root_job.id = COALESCE(current_job.root_job, current_job.flow_innermost_root_job, current_job.parent_job, current_job.id) - AND root_job.workspace_id = current_job.workspace_id - LEFT JOIN - flow_version ON flow_version.id = root_job.runnable_id - AND flow_version.path = root_job.runnable_path - AND flow_version.workspace_id = root_job.workspace_id - WHERE - current_job.id = $1 AND - current_job.workspace_id = $2"#, - flow_job_id, - w_id, - var_name, - ) - .fetch_optional(&db) - .await? - .and_then(|r| r.map(|x| x.0)); - - // Resolve $var:/$res: references if present - let resolved = if let Some(raw) = raw_value { - let raw_str = raw.get(); - let db_authed = windmill_common::db::DbWithOptAuthed::::from_authed( - &authed, - db.clone(), - None, - ); - if let Some(path) = raw_str - .strip_prefix("\"$var:") - .and_then(|s| s.strip_suffix("\"")) - { - match windmill_store::variables::get_value_internal(&db_authed, &w_id, path, false) - .await - { - Ok(val) => to_raw_value(&serde_json::Value::String(val)), - Err(e) => { - tracing::warn!("Failed to resolve flow_env variable $var:{path}: {e}"); - raw - } - } - } else if let Some(path) = raw_str - .strip_prefix("\"$res:") - .and_then(|s| s.strip_suffix("\"")) - { - match windmill_store::resources::get_resource_value_interpolated_internal( - &db_authed, - &w_id, - path, - Some(flow_job_id), - Some(&tokened.token), - false, - ) - .await - { - Ok(Some(val)) => to_raw_value(&val), - Ok(None) => { - tracing::warn!( - "Failed to resolve flow_env resource $res:{path}: resource not found" - ); - raw - } - Err(e) => { - tracing::warn!("Failed to resolve flow_env resource $res:{path}: {e}"); - raw - } - } - } else { - raw - } - } else { - to_raw_value(&serde_json::Value::Null) - }; - - // Apply json_path navigation on the (possibly resolved) value - let flow_env = if let Some(ref jp) = json_path { - let mut value: serde_json::Value = - serde_json::from_str(resolved.get()).unwrap_or(serde_json::Value::Null); - for part in jp.split('.') { - value = match value { - serde_json::Value::Object(ref mut map) => { - map.remove(part).unwrap_or(serde_json::Value::Null) - } - serde_json::Value::Array(ref arr) => part - .parse::() - .ok() - .and_then(|i| arr.get(i).cloned()) - .unwrap_or(serde_json::Value::Null), - _ => serde_json::Value::Null, - }; - } - to_raw_value(&value) - } else { - resolved - }; - - log_job_view( - &db, - Some(&authed), - Some(&tokened.token), - &w_id, - &flow_job_id, - ) - .await?; - Ok(Json(flow_env)) -} - async fn compute_root_job_for_flow(db: &DB, w_id: &str, job_id: Uuid) -> error::Result { let root_job = sqlx::query_scalar!( r#"SELECT COALESCE(root_job, flow_innermost_root_job, parent_job, id) as "root_job!" FROM v2_job WHERE id = $1 AND workspace_id = $2"#, diff --git a/backend/windmill-common/src/client.rs b/backend/windmill-common/src/client.rs index 730136ac1c..68670cea31 100644 --- a/backend/windmill-common/src/client.rs +++ b/backend/windmill-common/src/client.rs @@ -134,26 +134,6 @@ impl AuthedClient { .await } - pub async fn get_flow_env_by_flow_job_id( - &self, - root_job_id: &str, - var_name: &str, - json_path: Option, - ) -> anyhow::Result { - let url = format!( - "{}/api/w/{}/jobs/flow_env_by_flow_job_id/{}/{}", - self.base_internal_url, self.workspace, root_job_id, var_name - ); - let query = query_from_json_path(json_path); - make_basic_get_request( - self, - &url, - Some(query), - Some("decoding flow env variable as json"), - ) - .await - } - pub async fn get_result_by_id( &self, flow_job_id: &str, diff --git a/backend/windmill-jseval/src/lib.rs b/backend/windmill-jseval/src/lib.rs index 7172e8cfc8..f522f95263 100644 --- a/backend/windmill-jseval/src/lib.rs +++ b/backend/windmill-jseval/src/lib.rs @@ -56,12 +56,21 @@ const END_BRACKET_PATTERN: &str = "\"]"; // ── Regex statics ───────────────────────────────────────────────────── lazy_static! { + // `results` is fetched lazily via the `__getResult` async proxy, so we + // wrap each `results.X` access with `(await ...)` to drive the proxy. + // `flow_env` used to be wrapped here too (it was an async Deno op-backed + // proxy in the deno_core era); QuickJS now exposes flow_env as a plain + // in-memory object, so no await is needed. static ref RE: Regex = Regex::new( - r#"(?m)(?P(?:results|flow_env)(?:\?)?(?:(?:\.[a-zA-Z_0-9]+)|(?:\[\".*?\"\])))"# + r#"(?m)(?Presults(?:\?)?(?:(?:\.[a-zA-Z_0-9]+)|(?:\[\".*?\"\])))"# ) .unwrap(); + // SQL fast-path: simple `results.X.Y[i]...` accesses are dispatched to + // the API endpoint to fetch a specific result without spinning the eval + // engine. flow_env is no longer dispatched here because QuickJS reads + // it directly from the in-process global set up by `eval_quickjs_inner`. static ref RE_FULL: Regex = Regex::new( - r"(?m)^(results|flow_env)(?:\?)?\.([a-zA-Z_0-9]+)(?:\[(\d+)\])?((?:\.[a-zA-Z_0-9]+)+)?$" + r"(?m)^results(?:\?)?\.([a-zA-Z_0-9]+)(?:\[(\d+)\])?((?:\.[a-zA-Z_0-9]+)+)?$" ) .unwrap(); } @@ -173,10 +182,9 @@ pub async fn handle_full_regex( by_id: &IdContext, ) -> Option>> { if let Some(captures) = RE_FULL.captures(&expr) { - let obj_name = captures.get(1).unwrap().as_str(); - let obj_key = captures.get(2).unwrap().as_str(); - let idx_o = captures.get(3).map(|y| y.as_str()); - let rest = captures.get(4).map(|y| y.as_str()); + let obj_key = captures.get(1).unwrap().as_str(); + let idx_o = captures.get(2).map(|y| y.as_str()); + let rest = captures.get(3).map(|y| y.as_str()); // Skip the SQL fast path when the expression accesses a JS runtime // property (e.g. .length) that the PostgreSQL #> operator can't resolve. @@ -193,33 +201,19 @@ pub async fn handle_full_regex( rest.map(|x| x.trim_start_matches('.').to_string()) }; - let result = if obj_name == "results" { - let res = authed_client - .get_result_by_id::>>( - &by_id.flow_job.to_string(), - obj_key, - query, - ) - .await - .ok() - .flatten(); - match res { - Some(v) => Ok(v), - None => serde_json::value::to_raw_value(&serde_json::Value::Null) - .map_err(|e| anyhow::anyhow!("Failed to serialize null: {}", e)), - } - } else if obj_name == "flow_env" { - authed_client - .get_flow_env_by_flow_job_id(&by_id.flow_job.to_string(), obj_key, query) - .await - } else { - unreachable!(); + let res = authed_client + .get_result_by_id::>>(&by_id.flow_job.to_string(), obj_key, query) + .await + .ok() + .flatten(); + let result = match res { + Some(v) => Ok(v), + None => serde_json::value::to_raw_value(&serde_json::Value::Null) + .map_err(|e| anyhow::anyhow!("Failed to serialize null: {}", e)), }; - return Some(result); } - - return None; + None } #[cfg(feature = "quickjs")] diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index c21f22045a..eaa4fb8b6d 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -333,6 +333,7 @@ async fn evaluate_stop_after_all_iters_if( stop_early_err_msg: &mut Option, nresult: &mut Option>>, args: HashMap>, + flow_env: Option<&HashMap>>, flow: uuid::Uuid, status: &FlowStatus, ) -> error::Result<()> { @@ -354,7 +355,7 @@ async fn evaluate_stop_after_all_iters_if( let stop_early_after_all_iters = compute_bool_from_expr( &stop_after_all_iters_if.expr, Marc::new(args), - None, + flow_env, iters_result.clone(), None, id_ctx.as_ref(), @@ -465,6 +466,26 @@ pub async fn update_flow_status_after_job_completion_internal( has_triggered_error_handler = false; } + // Resolve flow_env for predicate evaluations (stop_after_if, + // stop_after_all_iters_if, retry_if). Only fetch when one of these + // predicates is configured to avoid an extra DB query on the common + // path. `retry` without `retry_if` doesn't consult flow_env. + let retry_uses_flow_env = + |module: &FlowModule| module.retry.as_ref().is_some_and(|r| r.retry_if.is_some()); + let needs_flow_env = current_module.is_some_and(|m| { + m.stop_after_if.is_some() + || m.stop_after_all_iters_if.is_some() + || retry_uses_flow_env(m) + }) || flow_value + .failure_module + .as_ref() + .is_some_and(|fm| retry_uses_flow_env(fm)); + let resolved_flow_env: Option>> = if needs_flow_env { + resolve_flow_env_for_status_update(db, client, flow, w_id, flow_value).await + } else { + None + }; + let module_status = match module_step { Step::PreprocessorStep => old_status .preprocessor_module @@ -611,7 +632,7 @@ pub async fn update_flow_status_after_job_completion_internal( let bool_res = compute_bool_from_expr( &expr, Marc::new(args), - None, + resolved_flow_env.as_ref(), result.clone(), all_iters, id_ctx.as_ref(), @@ -895,6 +916,7 @@ pub async fn update_flow_status_after_job_completion_internal( &mut stop_early_err_msg, &mut nresult, args, + resolved_flow_env.as_ref(), flow, &old_status, ) @@ -1114,6 +1136,7 @@ pub async fn update_flow_status_after_job_completion_internal( &mut stop_early_err_msg, &mut nresult, args, + resolved_flow_env.as_ref(), flow, &old_status, ) @@ -1196,7 +1219,7 @@ pub async fn update_flow_status_after_job_completion_internal( &old_status.retry, result.clone(), Marc::new(args), - None, + resolved_flow_env.as_ref(), Some(client), ) .await? @@ -1578,7 +1601,7 @@ pub async fn update_flow_status_after_job_completion_internal( &old_status.retry, result.clone(), Marc::new(args), - None, + resolved_flow_env.as_ref(), Some(client), ) .await? @@ -2358,6 +2381,116 @@ async fn fetch_root_flow_id(db: &DB, flow_id: Uuid) -> Uuid { .unwrap_or(flow_id) } +// Resolve the flow_env to use for predicate evaluations in +// `update_flow_status_after_job_completion_internal`: take the current flow's +// `flow_env` if present, otherwise inherit from the root flow, then interpolate +// any `$var:`/`$res:` references via `transform_json`. +async fn resolve_flow_env_for_status_update( + db: &DB, + client: &AuthedClient, + flow_job_id: Uuid, + workspace_id: &str, + flow_value: &FlowValue, +) -> Option>> { + let env = if let Some(ref e) = flow_value.flow_env { + e.clone() + } else { + fetch_root_flow_env(db, flow_job_id, workspace_id).await? + }; + if env.is_empty() { + return Some(env); + } + let mini = match get_mini_pulled_job(db, &flow_job_id).await { + Ok(Some(j)) => j, + Ok(None) => return Some(env), + Err(e) => { + tracing::warn!("Failed to fetch flow job to resolve flow_env: {e:#}"); + return Some(env); + } + }; + match transform_json( + client, + workspace_id, + &env, + &mini, + &Connection::Sql(db.clone()), + ) + .await + { + Ok(Some(resolved)) => Some(resolved), + Ok(None) => Some(env), + Err(e) => { + tracing::warn!("Failed to resolve flow_env references in status update: {e:#}"); + Some(env) + } + } +} + +// Maximum number of ancestor jobs the flow_env walk will follow before bailing. +// Bounds runtime cost and protects against pathological data. +const MAX_FLOW_ENV_LOOKUP_DEPTH: i32 = 50; + +// Look up the nearest ancestor flow's `flow_env` for the given flow job. +// Sub-flows spawned by branches/loops via `payload_from_modules` don't carry +// `flow_env` in their own `FlowValue`, so any predicate evaluated against the +// local flow_env would see `None`. We walk the ancestor chain via +// `flow_innermost_root_job → parent_job` (one step at a time) and return the +// CLOSEST ancestor whose persisted flow definition (`flow_version.value` or +// `raw_flow`) carries `flow_env`. +// +// The walk deliberately does NOT use `root_job` (which would jump straight to +// the topmost parent) because imported flows define their own `flow_env` +// scope: a branch inside an imported flow must see the imported flow's env, +// not the top parent's. `flow_innermost_root_job` walks one scope at a time +// and resets to NULL on parallel-loop iterations and imported sub-flows, so +// recursion is required to walk past those resets to the closest scope. +async fn fetch_root_flow_env( + db: &DB, + flow_job_id: Uuid, + workspace_id: &str, +) -> Option>> { + sqlx::query_scalar!( + r#"WITH RECURSIVE chain(id, parent_job, flow_innermost_root_job, runnable_id, runnable_path, raw_flow, depth) AS ( + SELECT id, parent_job, flow_innermost_root_job, runnable_id, runnable_path, raw_flow, 0 + FROM v2_job + WHERE id = $1 AND workspace_id = $2 + UNION ALL + SELECT j.id, j.parent_job, j.flow_innermost_root_job, j.runnable_id, j.runnable_path, j.raw_flow, c.depth + 1 + FROM v2_job j + JOIN chain c + ON j.id = COALESCE(c.flow_innermost_root_job, c.parent_job) + WHERE j.workspace_id = $2 AND c.depth < $3 + ) + SELECT + CASE + WHEN flow_version.id IS NOT NULL THEN + flow_version.value -> 'flow_env' + ELSE + chain.raw_flow -> 'flow_env' + END AS "flow_env: Json>>" + FROM chain + LEFT JOIN flow_version + ON flow_version.id = chain.runnable_id + AND flow_version.path = chain.runnable_path + AND flow_version.workspace_id = $2 + WHERE (CASE + WHEN flow_version.id IS NOT NULL THEN flow_version.value -> 'flow_env' + ELSE chain.raw_flow -> 'flow_env' + END) IS NOT NULL + ORDER BY chain.depth ASC + LIMIT 1"#, + flow_job_id, + workspace_id, + MAX_FLOW_ENV_LOOKUP_DEPTH, + ) + .fetch_optional(db) + .await + .ok() + .flatten() + .flatten() + .map(|json| json.0) +} + struct FailureContext { started_at: Arc>, flow_job_id: Uuid, @@ -2497,11 +2630,24 @@ pub async fn handle_flow( ) -> anyhow::Result<()> { let flow = flow_data.value(); + // Sub-flows spawned by `payload_from_modules` for branches/loops don't + // carry the parent's `flow_env` in their own FlowValue. Fall back to the + // nearest enclosing scope's `flow_env` so predicates like `skip_if`, + // `stop_after_if`, and branch conditions see the same env as input + // transforms. + let inherited_env: Option>> = + if flow.flow_env.is_none() && flow_job.parent_job.is_some() { + fetch_root_flow_env(db, flow_job.id, &flow_job.workspace_id).await + } else { + None + }; + let env_source = flow.flow_env.as_ref().or(inherited_env.as_ref()); + // Resolve $var: and $res: references in flow_env. // We resolve into a separate variable to avoid cloning the entire FlowValue // (which includes modules, failure_module, etc.) just to replace flow_env. let resolved_env; - let flow_env = if let Some(ref env) = flow.flow_env { + let flow_env = if let Some(env) = env_source { match transform_json( client, &flow_job.workspace_id, @@ -2515,10 +2661,10 @@ pub async fn handle_flow( resolved_env = resolved; Some(&resolved_env) } - Ok(None) => flow.flow_env.as_ref(), + Ok(None) => Some(env), Err(e) => { tracing::warn!("Failed to resolve flow_env references: {e}"); - flow.flow_env.as_ref() + Some(env) } } } else { From 1bf1477cf7e534d7bf40c06e8b714212b965e6b4 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 5 May 2026 20:25:41 +0000 Subject: [PATCH 026/313] ci: run codex review on every follow-up commit --- .github/workflows/codex-pr-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml index eacb453cc3..e89a58b629 100644 --- a/.github/workflows/codex-pr-review.yml +++ b/.github/workflows/codex-pr-review.yml @@ -2,7 +2,7 @@ name: Codex Auto Review on: pull_request: - types: [ready_for_review, opened] + types: [ready_for_review, opened, synchronize] workflow_call: inputs: pr_number: From 40dbab531e5166b894f3f94b0d72b2ac456c0097 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 5 May 2026 21:06:53 +0000 Subject: [PATCH 027/313] fix(cli): resolve cross-folder relative imports during lockgen on fresh DB (#9048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): resolve cross-folder relative imports during lockgen on fresh DB On a fresh workspace, lockfile generation for scripts that imported other scripts via cross-folder relative imports (or barrel re-exporters) failed with "Failed to find relative import" because the dep job's bun build hit the server before any helper was deployed. Three independent bugs combined to produce this: 1. wmill sync push --auto-metadata regenerated locks per script without building a DoubleLinkedDependencyTree or calling uploadScripts, so temp_script_refs was never sent to dependencies_async. 2. wmill script generate-metadata (the deprecated alias) had its own old in-line implementation that bypassed the tree entirely. 3. The TypeScript WASM parser dropped re-exports (export * from, export { x } from) when called with skip_type_only=false — the path used by parse_relative_imports — so barrel files looked like leaves to the CLI's dependency tree and their sibling helpers were missing from temp_script_refs. Fix: - sync.ts: --auto-metadata mirrors generate-metadata's flow (dryRun pass to populate tree → propagateStaleness → uploadScripts → real pass with tree). - script.ts: deprecated wmill script generate-metadata now delegates to the canonical generateMetadata, which already does the tree+upload dance. - parser-ts: visit_export_all and visit_named_export had inverted skip_type_only guards; aligned with visit_import_decl's pattern. Includes 4 E2E tests reproducing each customer-hit failure path and a Rust unit test for the re-export parser fix. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: bump windmill-parser-wasm-ts to 1.695.0 Pin the parser package to the version published with the re-export fix (visit_export_all / visit_named_export skip_type_only=false) so the CLI and frontend pick it up at the next release. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(cli): restore legacy stale-check in deprecated alias, add tree to gen pass Delegating wmill script generate-metadata fully to the canonical handler broke 4 workspace_deps_filter tests that rely on the legacy hash-with-deps formula and the "No metadata to update" output string. Restore the original in-line implementation (legacy stale-check preserved), but add a DoubleLinkedDependencyTree + uploadScripts pass before the actual generation step. The customer's bug only manifests on real lockgen, not on the dry-run staleness check, so this preserves the existing test contract while still fixing cross-folder relative imports for the deprecated alias. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- backend/parsers/windmill-parser-ts/src/lib.rs | 36 +-- .../parsers/windmill-parser-ts/tests/tests.rs | 23 ++ cli/bun.lock | 4 +- cli/package.json | 2 +- .../generate-metadata/generate-metadata.ts | 2 +- cli/src/commands/script/script.ts | 40 ++- cli/src/commands/sync/sync.ts | 187 +++++++++--- .../sync_push_auto_metadata_repro.test.ts | 270 ++++++++++++++++++ frontend/package-lock.json | 8 +- frontend/package.json | 2 +- 10 files changed, 501 insertions(+), 73 deletions(-) create mode 100644 cli/test/sync_push_auto_metadata_repro.test.ts diff --git a/backend/parsers/windmill-parser-ts/src/lib.rs b/backend/parsers/windmill-parser-ts/src/lib.rs index e63b0ef680..1ae22879b6 100644 --- a/backend/parsers/windmill-parser-ts/src/lib.rs +++ b/backend/parsers/windmill-parser-ts/src/lib.rs @@ -77,32 +77,36 @@ impl Visit for ImportsFinder { } fn visit_export_all(&mut self, node: &swc_ecma_ast::ExportAll) { - if !self.skip_type_only || node.type_only { + if self.skip_type_only && node.type_only { return; } self.process_raw(node.src.raw.as_ref().map(|x| x.to_string())); } fn visit_named_export(&mut self, node: &swc_ecma_ast::NamedExport) { - if node.src.is_none() || !self.skip_type_only || node.type_only { + if node.src.is_none() { return; } - if node.specifiers.len() > 0 { - let mut is_type_only = true; - for specifier in node.specifiers.iter() { - match specifier { - swc_ecma_ast::ExportSpecifier::Named(swc_ecma_ast::ExportNamedSpecifier { - is_type_only, - .. - }) if *is_type_only => (), - _ => { - is_type_only = false; - break; + if self.skip_type_only { + if node.type_only { + return; + } + if node.specifiers.len() > 0 { + let mut is_type_only = true; + for specifier in node.specifiers.iter() { + match specifier { + swc_ecma_ast::ExportSpecifier::Named( + swc_ecma_ast::ExportNamedSpecifier { is_type_only, .. }, + ) if *is_type_only => (), + _ => { + is_type_only = false; + break; + } } } - } - if is_type_only { - return; + if is_type_only { + return; + } } } diff --git a/backend/parsers/windmill-parser-ts/tests/tests.rs b/backend/parsers/windmill-parser-ts/tests/tests.rs index 0243ccf06d..4309018fb4 100644 --- a/backend/parsers/windmill-parser-ts/tests/tests.rs +++ b/backend/parsers/windmill-parser-ts/tests/tests.rs @@ -936,4 +936,27 @@ mod tests { let result = parse_relative_imports(code, "f/one/two/three/script").unwrap(); assert_eq!(result, vec!["f/b", "f/one/a"]); } + + #[test] + fn test_relative_imports_includes_re_exports() { + // Barrel re-exports must be captured as relative imports — without + // them, importers reaching helpers via a barrel file lose the edge in + // the dependency tree and the dep job 404s on the sibling fetches. + let code = r#" + export * from "./types.ts"; + export { WorkflowError } from "./WorkflowError.ts"; + export * as factory from "./errorFactory.ts"; + export type { ErrorKind } from "./types-only.ts"; + "#; + let result = parse_relative_imports(code, "f/lib/errors/index").unwrap(); + assert_eq!( + result, + vec![ + "f/lib/errors/WorkflowError", + "f/lib/errors/errorFactory", + "f/lib/errors/types", + "f/lib/errors/types-only", + ] + ); + } } diff --git a/cli/bun.lock b/cli/bun.lock index f4cb387593..de02781822 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -28,7 +28,7 @@ "windmill-parser-wasm-regex": "1.692.0", "windmill-parser-wasm-ruby": "1.526.1", "windmill-parser-wasm-rust": "1.647.1", - "windmill-parser-wasm-ts": "1.693.1", + "windmill-parser-wasm-ts": "1.695.0", "windmill-parser-wasm-yaml": "1.593.0", "windmill-yaml-validator": "1.1.1", "ws": "8.18.0", @@ -305,7 +305,7 @@ "windmill-parser-wasm-rust": ["windmill-parser-wasm-rust@1.647.1", "", {}, "sha512-9yGLYZX2Hn9TdTqGY/5Fp50ftzgUsrfBkSK9vJkKJd5Amyg+yXLBGzd8pz6Org+4uxMenz/16wpsgijvo6uhhQ=="], - "windmill-parser-wasm-ts": ["windmill-parser-wasm-ts@1.693.1", "", {}, "sha512-xrPgVWwQbOWJKiz68wBDNMrKVOtCY/utyhzSx0kFYIWm/QH/6L8k6LLjo4DOn+PnlBNRXc7qum0sDyB8IuuJYQ=="], + "windmill-parser-wasm-ts": ["windmill-parser-wasm-ts@1.695.0", "", {}, "sha512-9EFxeRZWmfb7EyhSlcG7dzTTKETPRYAvpRlxxLkhhtI5I219wFgI7kwrMpz4stXHJj/aqBknVv66NQHRstSJmw=="], "windmill-parser-wasm-yaml": ["windmill-parser-wasm-yaml@1.593.0", "", {}, "sha512-Gyx4aR2jsJYuDrD3mCNTmz7LWOQQXPw5yKNCC1xRgUOPfjsD/tINAFfsBLwVOSmlQQcFZO+wHm4KtDtXOcnGVw=="], diff --git a/cli/package.json b/cli/package.json index 08c3d0c693..5afe6f04bb 100644 --- a/cli/package.json +++ b/cli/package.json @@ -37,7 +37,7 @@ "windmill-parser-wasm-regex": "1.692.0", "windmill-parser-wasm-ruby": "1.526.1", "windmill-parser-wasm-rust": "1.647.1", - "windmill-parser-wasm-ts": "1.693.1", + "windmill-parser-wasm-ts": "1.695.0", "windmill-parser-wasm-yaml": "1.593.0", "windmill-yaml-validator": "1.1.1", "ws": "8.18.0", diff --git a/cli/src/commands/generate-metadata/generate-metadata.ts b/cli/src/commands/generate-metadata/generate-metadata.ts index e840397230..226220592e 100644 --- a/cli/src/commands/generate-metadata/generate-metadata.ts +++ b/cli/src/commands/generate-metadata/generate-metadata.ts @@ -263,7 +263,7 @@ export async function rehashOnly( return counts; } -async function generateMetadata( +export async function generateMetadata( opts: GlobalOptions & { yes?: boolean; lockOnly?: boolean; diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index db2b9b3fef..0635cd42ea 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -1380,7 +1380,42 @@ export async function generateMetadata( log.info(colors.green.bold("No metadata to update")); return; } - // TODO: test this + + // Build a DoubleLinkedDependencyTree and upload mismatched scripts to + // raw_script_temp before the actual generation pass. Without this, + // dep jobs for scripts that import other not-yet-deployed scripts via + // relative paths would 404 on the import target (the very bug this + // alias was introducing on fresh-DB pushes). + const { DoubleLinkedDependencyTree, uploadScripts } = await import( + "../../utils/dependency_tree.ts" + ); + const tree = new DoubleLinkedDependencyTree(); + tree.setWorkspaceDeps(rawWorkspaceDependencies); + for (const e of Object.keys(elems)) { + await generateScriptMetadataInternal( + e, + workspace, + opts, + true, // dryRun: populate tree + true, + rawWorkspaceDependencies, + codebases, + false, + tree, + ); + } + tree.propagateStaleness(); + try { + await uploadScripts(tree, workspace); + } catch (e) { + log.warn( + colors.yellow( + `Failed to upload scripts to temp storage (backend may be too old): ${e}. ` + + `Locks will be generated using deployed script versions only — locally modified ` + + `relative imports may not be reflected.`, + ), + ); + } for (const e of Object.keys(elems)) { await generateScriptMetadataInternal( e, @@ -1390,7 +1425,8 @@ export async function generateMetadata( true, rawWorkspaceDependencies, codebases, - false + false, + tree, ); } } diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index a7dd91b5fa..0cadc7c083 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -79,6 +79,7 @@ import { MalformedLockfileError, workspaceDependenciesPathToLanguageAndFilename, } from "../../utils/metadata.ts"; +import { DoubleLinkedDependencyTree, uploadScripts } from "../../utils/dependency_tree.ts"; import { OpenFlow, NativeServiceName, ScriptModule } from "../../../gen/types.gen.ts"; import { pushResource } from "../resource/resource.ts"; import { @@ -2990,21 +2991,159 @@ export async function push( const staleFlows: string[] = []; const staleApps: string[] = []; + // Auto-regenerate uses a DoubleLinkedDependencyTree so the dep job can + // resolve cross-folder relative imports against not-yet-deployed scripts via + // raw_script_temp + temp_script_refs. Without this the importer's lockgen + // 404s on its sibling/parent imports because nothing has been pushed yet. + const tree = autoRegenerate ? new DoubleLinkedDependencyTree() : undefined; + if (tree) tree.setWorkspaceDeps(rawWorkspaceDependencies); + + // Pass 1: populate the tree (autoRegenerate) or run the legacy stale-check + // (no autoRegenerate, just collect warnings). for (const change of tracker.scripts) { const stale = await generateScriptMetadataInternal( change, workspace, opts, - !autoRegenerate, // dryRun=false when --auto is set + true, // dryRun: pass 1 only populates the tree / detects staleness true, rawWorkspaceDependencies, codebases, false, + tree, ); - if (stale) { + if (!autoRegenerate && stale) { staleScripts.push(stale); } } + for (const change of tracker.flows) { + const stale = await generateFlowLockInternal( + change, + true, + workspace, + opts, + false, + true, + tree, + ); + if (!autoRegenerate && stale) { + staleFlows.push(stale as string); + } + } + for (const change of tracker.apps) { + const stale = await generateAppLocksInternal( + change, + false, + true, + workspace, + opts, + true, + true, + tree, + ); + if (!autoRegenerate && stale) { + staleApps.push(stale as string); + } + } + for (const change of tracker.rawApps) { + const stale = await generateAppLocksInternal( + change, + true, + true, + workspace, + opts, + true, + true, + tree, + ); + if (!autoRegenerate && stale) { + staleApps.push(stale as string); + } + } + + if (autoRegenerate && tree) { + // Propagate staleness through imports + upload script content to + // raw_script_temp so the dep job can resolve cross-folder relative imports + // via temp_script_refs (instead of hitting 404s for not-yet-deployed + // scripts and recording lock_error_logs). + tree.propagateStaleness(); + try { + await uploadScripts(tree, workspace); + } catch (e) { + log.warn( + colors.yellow( + `Failed to upload scripts to temp storage (backend may be too old): ${e}. ` + + `Locks will be generated using deployed script versions only — locally modified ` + + `relative imports may not be reflected.`, + ), + ); + } + + // Pass 2: actually generate metadata/locks. Threading `tree` makes + // generateScriptMetadataInternal include temp_script_refs in the + // dependencies_async request so the dep job resolves relative imports + // against raw_script_temp. + for (const change of tracker.scripts) { + const generated = await generateScriptMetadataInternal( + change, + workspace, + opts, + false, + true, + rawWorkspaceDependencies, + codebases, + false, + tree, + ); + if (generated) { + staleScripts.push(generated); + } + } + for (const change of tracker.flows) { + const generated = await generateFlowLockInternal( + change, + false, + workspace, + opts, + false, + true, + tree, + ); + if (generated) { + staleFlows.push(generated as string); + } + } + for (const change of tracker.apps) { + const generated = await generateAppLocksInternal( + change, + false, + false, + workspace, + opts, + true, + true, + tree, + ); + if (generated) { + staleApps.push(generated as string); + } + } + for (const change of tracker.rawApps) { + const generated = await generateAppLocksInternal( + change, + true, + false, + workspace, + opts, + true, + true, + tree, + ); + if (generated) { + staleApps.push(generated as string); + } + } + } if (staleScripts.length > 0) { log.info(""); @@ -3026,20 +3165,6 @@ export async function push( log.info(""); } - for (const change of tracker.flows) { - const stale = await generateFlowLockInternal( - change, - !autoRegenerate, // dryRun=false when --auto is set - workspace, - opts, - false, - true, - ); - if (stale) { - staleFlows.push(stale as string); - } - } - if (staleFlows.length > 0) { if (autoRegenerate) { log.info("Auto-regenerated locks for stale flows:"); @@ -3058,36 +3183,6 @@ export async function push( log.info(""); } - for (const change of tracker.apps) { - const stale = await generateAppLocksInternal( - change, - false, - !autoRegenerate, - workspace, - opts, - true, - true, - ); - if (stale) { - staleApps.push(stale as string); - } - } - - for (const change of tracker.rawApps) { - const stale = await generateAppLocksInternal( - change, - true, - !autoRegenerate, - workspace, - opts, - true, - true, - ); - if (stale) { - staleApps.push(stale as string); - } - } - if (staleApps.length > 0) { if (autoRegenerate) { log.info("Auto-regenerated locks for stale apps:"); diff --git a/cli/test/sync_push_auto_metadata_repro.test.ts b/cli/test/sync_push_auto_metadata_repro.test.ts new file mode 100644 index 0000000000..e9ea167ffc --- /dev/null +++ b/cli/test/sync_push_auto_metadata_repro.test.ts @@ -0,0 +1,270 @@ +/** + * Reproduction for `sync push --auto-metadata` cross-folder relative-import bug. + * + * `--auto-metadata` regenerates lockfiles client-side before pushing, but on a + * fresh workspace none of the imported scripts are deployed yet. The fix must + * route lockgen through `DoubleLinkedDependencyTree` + `uploadScripts` so the + * dep job can resolve relative imports via `temp_script_refs`. + * + * Without the fix, `wmill sync push --auto-metadata --yes` aborts with + * "Failed to find relative import" / "Non-zero exit status for bun build". + */ + +import { expect, test } from "bun:test"; +import { writeFile, readFile } from "node:fs/promises"; +import { withTestBackend } from "./test_backend.ts"; +import { createLocalScript } from "./test_fixtures.ts"; + +const wmillYaml = `defaultTs: bun +includes: ["**"] +excludes: [] +`; + +// The importer path (f/aaa/...) sorts before the imported path (f/bbb/...) +// alphabetically. The CLI sorts changes by path within the script bucket, so +// without the fix the importer is processed first and its lockgen tries to +// fetch a not-yet-uploaded helper from the server. +// +// One `../` from f/aaa/consumer.ts steps out of f/aaa/ to f/, then `bbb/helper.ts` +// resolves to f/bbb/helper.ts. +const consumerScript = `import { helper } from "../bbb/helper.ts"; +export async function main() { return helper(); } +`; + +const helperScript = `export function helper() { return "ok"; } +`; + +test( + "sync push --auto-metadata succeeds for cross-folder relative imports on a fresh workspace", + { timeout: 120000 }, + async () => { + await withTestBackend(async (backend, tempDir) => { + await writeFile(`${tempDir}/wmill.yaml`, wmillYaml); + + // Cross-folder relative import where the importer's path comes + // alphabetically before the import target. + await createLocalScript(tempDir, "f/aaa", "consumer", "bun", consumerScript); + await createLocalScript(tempDir, "f/bbb", "helper", "bun", helperScript); + + const result = await backend.runCLICommand( + ["sync", "push", "--yes", "--auto-metadata"], + tempDir, + ); + + if (result.code !== 0) { + console.log("STDOUT:", result.stdout); + console.log("STDERR:", result.stderr); + } + + // The exit code must be zero — `--auto-metadata` should not abort on a + // fresh workspace just because the importer is alphabetically first. + expect(result.code).toBe(0); + + const combined = result.stdout + result.stderr; + expect(combined).not.toContain("Failed to find relative import"); + expect(combined).not.toContain("Failed to generate lockfile"); + + // Consumer's lockfile should exist and be non-empty (i.e. lockgen + // actually produced a valid lock, not a sentinel/error string). + const consumerLock = await readFile( + `${tempDir}/f/aaa/consumer.script.lock`, + "utf-8", + ).catch(() => ""); + expect(consumerLock.length).toBeGreaterThan(0); + }); + }, +); + +// Helper: build a multi-folder topology mimicking the customer's failure +// shape. Importers (analytics, webhooks) reach helpers in f/lib via deep +// cross-folder relative imports. +async function setupCustomerLikeTopology(tempDir: string) { + await writeFile(`${tempDir}/wmill.yaml`, wmillYaml); + await createLocalScript( + tempDir, + "f/lib", + "log_event", + "bun", + `export function logEvent(msg: string) { return msg; }\n`, + ); + await createLocalScript( + tempDir, + "f/lib", + "errors", + "bun", + `export class AppError extends Error {}\n`, + ); + await createLocalScript( + tempDir, + "f/integrations/snowflake", + "client", + "bun", + `export function client() { return "snowflake"; }\n`, + ); + await createLocalScript( + tempDir, + "f/analytics/claims_operations", + "bulk", + "bun", + `import { client } from "../../integrations/snowflake/client.ts"; +import { AppError } from "../../lib/errors.ts"; +export async function main() { try { return client(); } catch (e) { throw new AppError(); } } +`, + ); + await createLocalScript( + tempDir, + "f/webhooks/stripe", + "handle_webhook", + "bun", + `import { logEvent } from "../../lib/log_event.ts"; +export async function main() { return logEvent("ok"); } +`, + ); +} + +test( + "generate-metadata succeeds across many folders with deep cross-folder relative imports", + { timeout: 180000 }, + async () => { + await withTestBackend(async (backend, tempDir) => { + await setupCustomerLikeTopology(tempDir); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + ); + + if (result.code !== 0) { + console.log("STDOUT:", result.stdout); + console.log("STDERR:", result.stderr); + } + + expect(result.code).toBe(0); + + const combined = result.stdout + result.stderr; + expect(combined).not.toContain("Failed to find relative import"); + expect(combined).not.toContain("Failed to generate lockfile"); + + const bulkLock = await readFile( + `${tempDir}/f/analytics/claims_operations/bulk.script.lock`, + "utf-8", + ).catch(() => ""); + const webhookLock = await readFile( + `${tempDir}/f/webhooks/stripe/handle_webhook.script.lock`, + "utf-8", + ).catch(() => ""); + expect(bulkLock.length).toBeGreaterThan(0); + expect(webhookLock.length).toBeGreaterThan(0); + }); + }, +); + +// `wmill script generate-metadata` is a deprecated alias defined in +// commands/script/script.ts. Its action handler used to be a separate +// implementation that didn't go through DoubleLinkedDependencyTree + +// uploadScripts, so on a fresh DB it hit the same out-of-order failure as +// `sync push --auto-metadata`. The fix delegates the alias to the canonical +// generateMetadata implementation. +// Customer scenario: a barrel file (f/lib/errors/index.ts) re-exports from +// siblings (./types.ts, ./WorkflowError.ts, ...). An importer in a different +// folder imports from the barrel. On a fresh DB, the dep job for the importer +// fetches index.ts via raw_unpinned + temp_script_hash, but bun's resolver +// then has to resolve the barrel's *sibling* imports — and those need to be +// in TEMP_SCRIPT_REFS too. +test( + "generate-metadata succeeds when importer reaches helpers via a barrel re-exporter", + { timeout: 180000 }, + async () => { + await withTestBackend(async (backend, tempDir) => { + await writeFile(`${tempDir}/wmill.yaml`, wmillYaml); + + await createLocalScript( + tempDir, + "f/lib/errors", + "types", + "bun", + `export type ErrorKind = "fatal" | "warn";\n` + + `export function _typesAnchor() { return null as unknown; }\n`, + ); + await createLocalScript( + tempDir, + "f/lib/errors", + "WorkflowError", + "bun", + `export class WorkflowError extends Error { kind = "fatal" as const; }\n` + + `export function _wfeAnchor() { return new WorkflowError(); }\n`, + ); + await createLocalScript( + tempDir, + "f/lib/errors", + "index", + "bun", + `export * from "./types.ts";\n` + + `export * from "./WorkflowError.ts";\n` + + `export function main() { return "barrel"; }\n`, + ); + await createLocalScript( + tempDir, + "f/analytics/claims_operations", + "bulk", + "bun", + `import { WorkflowError } from "../../lib/errors/index.ts"; +export async function main() { return new WorkflowError().message; } +`, + ); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + ); + + if (result.code !== 0) { + console.log("STDOUT:", result.stdout); + console.log("STDERR:", result.stderr); + } + + expect(result.code).toBe(0); + + const combined = result.stdout + result.stderr; + expect(combined).not.toContain("Failed to find relative import"); + expect(combined).not.toContain("Failed to generate lockfile"); + }); + }, +); + +test( + "deprecated `wmill script generate-metadata` succeeds for cross-folder imports on a fresh workspace", + { timeout: 180000 }, + async () => { + await withTestBackend(async (backend, tempDir) => { + await setupCustomerLikeTopology(tempDir); + + const result = await backend.runCLICommand( + ["script", "generate-metadata", "--yes"], + tempDir, + ); + + if (result.code !== 0) { + console.log("STDOUT:", result.stdout); + console.log("STDERR:", result.stderr); + } + + expect(result.code).toBe(0); + + const combined = result.stdout + result.stderr; + expect(combined).not.toContain("Failed to find relative import"); + expect(combined).not.toContain("Failed to generate lockfile"); + + const bulkLock = await readFile( + `${tempDir}/f/analytics/claims_operations/bulk.script.lock`, + "utf-8", + ).catch(() => ""); + const webhookLock = await readFile( + `${tempDir}/f/webhooks/stripe/handle_webhook.script.lock`, + "utf-8", + ).catch(() => ""); + expect(bulkLock.length).toBeGreaterThan(0); + expect(webhookLock.length).toBeGreaterThan(0); + }); + }, +); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6fcd7f34c0..76002aba4a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -86,7 +86,7 @@ "windmill-parser-wasm-regex": "1.692.0", "windmill-parser-wasm-ruby": "1.526.1", "windmill-parser-wasm-rust": "1.647.1", - "windmill-parser-wasm-ts": "1.693.1", + "windmill-parser-wasm-ts": "1.695.0", "windmill-parser-wasm-wac": "1.668.6", "windmill-parser-wasm-yaml": "1.593.0", "windmill-sql-datatype-parser-wasm": "1.512.0", @@ -13640,9 +13640,9 @@ "integrity": "sha512-9yGLYZX2Hn9TdTqGY/5Fp50ftzgUsrfBkSK9vJkKJd5Amyg+yXLBGzd8pz6Org+4uxMenz/16wpsgijvo6uhhQ==" }, "node_modules/windmill-parser-wasm-ts": { - "version": "1.693.1", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.693.1.tgz", - "integrity": "sha512-xrPgVWwQbOWJKiz68wBDNMrKVOtCY/utyhzSx0kFYIWm/QH/6L8k6LLjo4DOn+PnlBNRXc7qum0sDyB8IuuJYQ==" + "version": "1.695.0", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.695.0.tgz", + "integrity": "sha512-9EFxeRZWmfb7EyhSlcG7dzTTKETPRYAvpRlxxLkhhtI5I219wFgI7kwrMpz4stXHJj/aqBknVv66NQHRstSJmw==" }, "node_modules/windmill-parser-wasm-wac": { "version": "1.668.6", diff --git a/frontend/package.json b/frontend/package.json index 508e3813f8..98ff10ae9d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -159,7 +159,7 @@ "windmill-parser-wasm-regex": "1.692.0", "windmill-parser-wasm-ruby": "1.526.1", "windmill-parser-wasm-rust": "1.647.1", - "windmill-parser-wasm-ts": "1.693.1", + "windmill-parser-wasm-ts": "1.695.0", "windmill-parser-wasm-wac": "1.668.6", "windmill-parser-wasm-yaml": "1.593.0", "windmill-sql-datatype-parser-wasm": "1.512.0", From c1e52eab09794746bea7dc9adb94552641f87d5b Mon Sep 17 00:00:00 2001 From: Henri Courdent <122811744+hcourdent@users.noreply.github.com> Date: Tue, 5 May 2026 23:12:00 +0200 Subject: [PATCH 028/313] fix: navigate home arrows (#9024) * Navigate with arrows * Jumps to other side item + load 30 more * No workspace selector * Recommendations Claude check * Navigation horizontal * Same --------- Co-authored-by: Ruben Fiszel --- .../lib/components/common/table/AppRow.svelte | 5 +- .../components/common/table/FlowRow.svelte | 5 +- .../components/common/table/RawAppRow.svelte | 5 +- .../lib/components/common/table/Row.svelte | 15 +- .../components/common/table/ScriptRow.svelte | 5 +- frontend/src/lib/components/home/Item.svelte | 13 +- .../src/lib/components/home/ItemsList.svelte | 254 +++++++++++++++++- 7 files changed, 292 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index 1034eeac81..1c0c5b08d1 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -46,6 +46,7 @@ depth?: number menuOpen?: boolean showEditButton?: boolean + keyboardSelected?: boolean } let { @@ -57,7 +58,8 @@ deleteConfirmedCallback = $bindable(), depth = 0, menuOpen = $bindable(false), - showEditButton = $bindable(true) + showEditButton = $bindable(true), + keyboardSelected = false }: Props = $props() const dispatch = createEventDispatcher() @@ -86,6 +88,7 @@ workspaceId={app.workspace_id ?? $workspaceStore ?? ''} canFavorite={!app.draft_only} {depth} + {keyboardSelected} > {#snippet badges()} {#if app.execution_mode == 'anonymous'} diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index ef0ff76215..14ee1a3287 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -49,6 +49,7 @@ depth?: number menuOpen?: boolean showEditButton?: boolean + keyboardSelected?: boolean } let { @@ -61,7 +62,8 @@ errorHandlerMuted, depth = 0, menuOpen = $bindable(false), - showEditButton = $bindable(true) + showEditButton = $bindable(true), + keyboardSelected = false }: Props = $props() const dispatch = createEventDispatcher() @@ -112,6 +114,7 @@ {errorHandlerMuted} canFavorite={!flow.draft_only} {depth} + {keyboardSelected} > {#snippet badges()} {#if flow.archived} diff --git a/frontend/src/lib/components/common/table/RawAppRow.svelte b/frontend/src/lib/components/common/table/RawAppRow.svelte index f163630747..e759a1d9fe 100644 --- a/frontend/src/lib/components/common/table/RawAppRow.svelte +++ b/frontend/src/lib/components/common/table/RawAppRow.svelte @@ -18,6 +18,7 @@ deploymentDrawer: DeployWorkspaceDrawer depth?: number menuOpen?: boolean + keyboardSelected?: boolean } let { @@ -26,7 +27,8 @@ shareModal, deploymentDrawer, depth = 0, - menuOpen = $bindable(false) + menuOpen = $bindable(false), + keyboardSelected = false }: Props = $props() @@ -39,6 +41,7 @@ workspaceId={app.workspace_id ?? $workspaceStore ?? ''} canFavorite={true} {depth} + {keyboardSelected} > {#snippet badges()} diff --git a/frontend/src/lib/components/common/table/Row.svelte b/frontend/src/lib/components/common/table/Row.svelte index f3d44965e4..58e3ed9a3f 100644 --- a/frontend/src/lib/components/common/table/Row.svelte +++ b/frontend/src/lib/components/common/table/Row.svelte @@ -10,6 +10,7 @@ interface Props { marked: string | undefined selected?: boolean + keyboardSelected?: boolean disabled?: boolean canFavorite?: boolean isSelectable?: boolean @@ -47,6 +48,7 @@ let { marked, selected = false, + keyboardSelected = false, disabled = false, canFavorite = true, isSelectable = false, @@ -73,6 +75,13 @@ : untrack(() => path) ?.split('/') ?.slice(-1)?.[0]) ?? '' + + let rowEl: HTMLDivElement | undefined = $state() + $effect(() => { + if (keyboardSelected) { + rowEl?.scrollIntoView({ block: 'nearest' }) + } + }) {#if href} @@ -88,11 +97,12 @@ > {/if}
0 ? '!rounded-none' : '', disabled ? 'opacity-25' : 'hover:bg-surface-hover', - selected ? 'bg-surface-accent-selected' : '' + selected ? 'bg-surface-accent-selected' : keyboardSelected ? 'bg-gray-200 dark:bg-gray-700' : '' )} style={depth > 0 ? `padding-left: ${depth * 32}px;` : ''} > @@ -105,6 +115,7 @@ {#if href} {@render rowContent()} @@ -131,7 +142,7 @@
{/if} -
+
{@render actions?.()}
diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index 91f8f2d5d0..3d0aec796b 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -62,6 +62,7 @@ depth?: number menuOpen?: boolean showEditButton?: boolean + keyboardSelected?: boolean } let { @@ -75,7 +76,8 @@ showCode, depth = 0, menuOpen = $bindable(false), - showEditButton = $bindable(true) + showEditButton = $bindable(true), + keyboardSelected = false }: Props = $props() const dispatch = createEventDispatcher() @@ -130,6 +132,7 @@ workspaceId={$workspaceStore ?? ''} canFavorite={!script.draft_only} {depth} + {keyboardSelected} > {#snippet badges()} {#if script.lock_error_logs} diff --git a/frontend/src/lib/components/home/Item.svelte b/frontend/src/lib/components/home/Item.svelte index 2e06e66417..6621e60fef 100644 --- a/frontend/src/lib/components/home/Item.svelte +++ b/frontend/src/lib/components/home/Item.svelte @@ -24,9 +24,16 @@ depth?: number showCode: (path: string, summary: string) => void showEditButton?: boolean + keyboardSelected?: boolean } - let { item, depth = 0, showCode, showEditButton = true }: Props = $props() + let { + item, + depth = 0, + showCode, + showEditButton = true, + keyboardSelected = false + }: Props = $props() {#if item.type == 'script'} @@ -46,6 +53,7 @@ bind:menuOpen {showCode} {showEditButton} + {keyboardSelected} /> {:else if item.type == 'flow'} {:else if item.type == 'app'} {:else if item.type == 'raw_app'} {/if} diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index f8bb688636..0248ceda1d 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -40,7 +40,7 @@ import Item from './Item.svelte' import TreeViewRoot from './TreeViewRoot.svelte' import Popover from '$lib/components/meltComponents/Popover.svelte' - import { getContext, untrack } from 'svelte' + import { getContext, tick, untrack } from 'svelte' import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte' import TextInput from '../text_input/TextInput.svelte' interface Props { @@ -326,9 +326,252 @@ ) ) let items = $derived(filter !== '' ? filteredItems : preFilteredItems) + let displayedItems = $derived((items ?? []).slice(0, nbDisplayed)) $effect(() => { items && resetScroll() }) + + let selectedIndex: number = $state(-1) + let hasMore = $derived(items != undefined && items.length > nbDisplayed) + let loadMoreIndex = $derived(displayedItems.length) + let loadMoreEl: HTMLButtonElement | undefined = $state() + let pendingAutoSelect = $state(true) + let firstWorkspaceRun = true + $effect(() => { + $workspaceStore + pendingAutoSelect = true + if (firstWorkspaceRun) { + firstWorkspaceRun = false + return + } + // On workspace switch, melt-ui restores focus to the workspace-picker trigger + // button asynchronously after the menu closes. Without overriding it, pressing + // an arrow key would re-open / re-highlight the workspace picker instead of + // moving the items-list selection. Run several times to win the focus race. + const focusSearch = () => { + const el = document.getElementById('home-search-input') as HTMLInputElement | null + el?.focus() + } + focusSearch() + const raf1 = requestAnimationFrame(() => { + focusSearch() + requestAnimationFrame(focusSearch) + }) + const timeoutId = setTimeout(focusSearch, 100) + return () => { + cancelAnimationFrame(raf1) + clearTimeout(timeoutId) + } + }) + $effect(() => { + filter + itemKind + ownerFilter + labelFilter + // Skip while pendingAutoSelect is true (initial load / workspace switch); + // the auto-select effect below will set the index once items appear. + if (!pendingAutoSelect) { + selectedIndex = -1 + } + }) + $effect(() => { + if (pendingAutoSelect && displayedItems.length > 0) { + selectedIndex = 0 + pendingAutoSelect = false + } + }) + $effect(() => { + const max = hasMore ? displayedItems.length : displayedItems.length - 1 + if (selectedIndex > max) { + selectedIndex = max + } + }) + $effect(() => { + if (hasMore && selectedIndex === loadMoreIndex) { + loadMoreEl?.scrollIntoView({ block: 'nearest' }) + } + }) + // Capture-phase listener so we run before melt-ui's button keydown handlers + // (e.g. ArrowDown on the dropdown trigger would otherwise open the menu). + $effect(() => { + window.addEventListener('keydown', handleGlobalKeydown, true) + return () => window.removeEventListener('keydown', handleGlobalKeydown, true) + }) + + function loadMoreAndPreselectFirstNew() { + const previousNbDisplayed = nbDisplayed + nbDisplayed += 30 + selectedIndex = previousNbDisplayed + } + + function getSelectedRowActionButtons(): HTMLElement[] { + const anchor = document.querySelector('a[data-row-keyboard-selected="true"]') + const actions = anchor?.parentElement?.querySelector('[data-row-actions]') + return actions ? Array.from(actions.querySelectorAll('button, a[href]')) : [] + } + + function handleGlobalKeydown(e: KeyboardEvent) { + if (treeView) return + const target = e.target as HTMLElement | null + + // When focus is inside a row's action buttons, handle arrow keys ourselves: + // - Left/Right cycle between buttons (Left from the first returns to search). + // - Up/Down move to the same-position button on the previous/next row. + // All other keys pass through so Enter/Space activate the focused button normally. + // This must run BEFORE the skipSelector check, since the dropdown ellipsis + // trigger carries [data-menu] (which would otherwise filter the event out). + // Up/Down also need stopImmediatePropagation so melt-ui's dropdown trigger + // doesn't open the menu (its default ArrowDown behavior). + const actionsContainer = target?.closest('[data-row-actions]') + if (actionsContainer) { + if ( + e.key !== 'ArrowRight' && + e.key !== 'ArrowLeft' && + e.key !== 'ArrowUp' && + e.key !== 'ArrowDown' + ) + return + const buttons = Array.from(actionsContainer.querySelectorAll('button, a[href]')) + const currentIdx = buttons.indexOf(target as HTMLElement) + if (currentIdx < 0) return + if (e.key === 'ArrowRight') { + if (currentIdx < buttons.length - 1) { + e.preventDefault() + buttons[currentIdx + 1].focus() + } + } else if (e.key === 'ArrowLeft') { + e.preventDefault() + if (currentIdx > 0) { + buttons[currentIdx - 1].focus() + } else { + ;(document.getElementById('home-search-input') as HTMLInputElement | null)?.focus() + } + } else { + // ArrowUp / ArrowDown: move to same-position button on prev/next row. + e.preventDefault() + e.stopImmediatePropagation() + if (selectedIndex < 0 || selectedIndex >= displayedItems.length) return + const newIndex = + e.key === 'ArrowDown' + ? Math.min(selectedIndex + 1, displayedItems.length - 1) + : Math.max(selectedIndex - 1, 0) + if (newIndex === selectedIndex) return + selectedIndex = newIndex + tick().then(() => { + const newButtons = getSelectedRowActionButtons() + if (newButtons.length === 0) return + const targetIdx = Math.min(currentIdx, newButtons.length - 1) + newButtons[targetIdx]?.focus() + }) + } + return + } + + // Inside an open dropdown menu: ArrowUp on first item / ArrowDown on last item + // closes the menu (so users can leave with arrows instead of needing Escape). + // Other arrow keys fall through to melt-ui's default cycle. + const menuItem = target?.closest('[role="menuitem"]') + if (menuItem) { + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + const menu = menuItem.closest('[role="menu"]') + if (menu) { + const items = Array.from(menu.querySelectorAll('[role="menuitem"]')) + const idx = items.indexOf(menuItem) + const isFirst = idx === 0 + const isLast = idx === items.length - 1 + if ((e.key === 'ArrowUp' && isFirst) || (e.key === 'ArrowDown' && isLast)) { + e.preventDefault() + e.stopImmediatePropagation() + menuItem.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + ) + } + } + } + return + } + + const skipSelector = + '[role="menu"], [role="menuitem"], [role="dialog"], [role="listbox"], [role="combobox"], [aria-expanded="true"], [data-menu]' + if (target) { + const tag = target.tagName + const isEditable = + tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable + const isOurSearch = target.id === 'home-search-input' + if (isEditable && !isOurSearch) return + if (target.closest(skipSelector)) return + } + const active = document.activeElement as HTMLElement | null + if (active?.closest(skipSelector)) return + + // ArrowRight from search input / body → focus first action button of selected row. + // Guard: if cursor is in the middle of typed search text, let the cursor move. + if (e.key === 'ArrowRight') { + if (target?.id === 'home-search-input') { + const inp = target as HTMLInputElement + if (inp.value.length > 0 && inp.selectionEnd !== inp.value.length) return + } + if (selectedIndex < 0 || selectedIndex >= displayedItems.length) return + const buttons = getSelectedRowActionButtons() + if (buttons.length > 0) { + e.preventDefault() + buttons[0].focus() + } + return + } + // ArrowLeft from search input with cursor at start: no-op (let default handle). + if (e.key === 'ArrowLeft') { + if (target?.id === 'home-search-input') { + const inp = target as HTMLInputElement + if (inp.value.length > 0 && inp.selectionStart !== 0) return + } + return + } + + if (e.key === 'ArrowDown') { + if (displayedItems.length === 0) return + e.preventDefault() + if (selectedIndex === -1) { + selectedIndex = 0 + } else if (selectedIndex === loadMoreIndex && hasMore) { + selectedIndex = 0 + } else if (selectedIndex === displayedItems.length - 1) { + selectedIndex = hasMore ? loadMoreIndex : 0 + } else { + selectedIndex = selectedIndex + 1 + } + } else if (e.key === 'ArrowUp') { + if (displayedItems.length === 0) return + e.preventDefault() + if (selectedIndex === -1) { + selectedIndex = displayedItems.length - 1 + } else if (selectedIndex === loadMoreIndex && hasMore) { + selectedIndex = displayedItems.length - 1 + } else if (selectedIndex === 0) { + selectedIndex = hasMore ? loadMoreIndex : displayedItems.length - 1 + } else { + selectedIndex = selectedIndex - 1 + } + } else if (e.key === 'Enter') { + if (selectedIndex === loadMoreIndex && hasMore) { + e.preventDefault() + loadMoreAndPreselectFirstNew() + } else if (selectedIndex >= 0 && selectedIndex < displayedItems.length) { + const anchor = document.querySelector( + 'a[data-row-keyboard-selected="true"]' + ) + if (anchor) { + e.preventDefault() + anchor.click() + } + } + } else if (e.key === 'Escape') { + if (selectedIndex !== -1) { + e.preventDefault() + selectedIndex = -1 + } + } + } $effect(() => { storeLocalSetting(TREE_VIEW_SETTING_NAME, treeView ? 'true' : undefined) }) @@ -572,7 +815,7 @@ /> {:else}
- {#each (items ?? []).slice(0, nbDisplayed) as item (item.type + '/' + item.path + (item.hash ? '/' + item.hash : ''))} + {#each displayedItems as item, i (item.type + '/' + item.path + (item.hash ? '/' + item.hash : ''))} loadScripts(includeWithoutMain)} @@ -587,6 +830,7 @@ }} {showCode} showEditButton={showEditButtons} + keyboardSelected={selectedIndex === i} /> {/each}
@@ -594,7 +838,11 @@ {nbDisplayed} items out of {items.length} From f07f19ebe7530934eb20e89a35ac31756d7d4fa9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 5 May 2026 21:20:40 +0000 Subject: [PATCH 029/313] chore(main): release 1.696.0 (#9040) * chore(main): release 1.696.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 19 ++ backend/Cargo.lock | 208 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 ++-- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 54 ++++- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 206 insertions(+), 151 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f12091b720..7048785010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [1.696.0](https://github.com/windmill-labs/windmill/compare/v1.695.0...v1.696.0) (2026-05-05) + + +### Features + +* add ai chat resource action buttons ([#9016](https://github.com/windmill-labs/windmill/issues/9016)) ([502a029](https://github.com/windmill-labs/windmill/commit/502a02998685308e82fab95bae7d3c14efd77d6a)) +* add wac ai context for frontend chat ([#9021](https://github.com/windmill-labs/windmill/issues/9021)) ([0d0557f](https://github.com/windmill-labs/windmill/commit/0d0557fc9dc5addee887911ddfc0fd08a09bc92e)) +* **cli:** add --as-superadmin flag to workspace list-remote ([#9043](https://github.com/windmill-labs/windmill/issues/9043)) ([66c9063](https://github.com/windmill-labs/windmill/commit/66c90639191a77eb4f19da092167384565edb9b3)) + + +### Bug Fixes + +* **cli:** resolve cross-folder relative imports during lockgen on fresh DB ([#9048](https://github.com/windmill-labs/windmill/issues/9048)) ([40dbab5](https://github.com/windmill-labs/windmill/commit/40dbab531e5166b894f3f94b0d72b2ac456c0097)) +* **flows:** inherit flow_env in sub-flow predicates ([#9042](https://github.com/windmill-labs/windmill/issues/9042)) ([6e5a21a](https://github.com/windmill-labs/windmill/commit/6e5a21a9c7b5db77d325b5916a9ab8799a2eb6e7)) +* navigate home arrows ([#9024](https://github.com/windmill-labs/windmill/issues/9024)) ([c1e52ea](https://github.com/windmill-labs/windmill/commit/c1e52eab09794746bea7dc9adb94552641f87d5b)) +* open job detail header path links in a new tab ([#9039](https://github.com/windmill-labs/windmill/issues/9039)) ([fe68c06](https://github.com/windmill-labs/windmill/commit/fe68c066004d860088e09be32e7ff2e7438f78c4)) +* **rust-client:** re-export models module from wmill crate ([#9038](https://github.com/windmill-labs/windmill/issues/9038)) ([ca6efbf](https://github.com/windmill-labs/windmill/commit/ca6efbff74e7d7e85174b1d8394af79eda7d6535)) +* **windmill-utils-internal:** move config to subpath export ([#9045](https://github.com/windmill-labs/windmill/issues/9045)) ([b86f896](https://github.com/windmill-labs/windmill/commit/b86f8960fcd8a66bc6849638178ef45ac49e06f1)) + ## [1.695.0](https://github.com/windmill-labs/windmill/compare/v1.694.0...v1.695.0) (2026-05-04) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index ddcb9848f9..c188373029 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1221,7 +1221,7 @@ dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", "h2 0.3.27", - "h2 0.4.13", + "h2 0.4.14", "http 0.2.12", "http 1.4.0", "http-body 0.4.6", @@ -4001,7 +4001,7 @@ dependencies = [ "deno_tls", "dyn-clone", "error_reporter", - "h2 0.4.13", + "h2 0.4.14", "hickory-resolver", "http 1.4.0", "http-body-util", @@ -4282,7 +4282,7 @@ dependencies = [ "elliptic-curve", "errno", "faster-hex", - "h2 0.4.13", + "h2 0.4.14", "hkdf", "http 1.4.0", "http-body-util", @@ -4738,7 +4738,7 @@ dependencies = [ "deno_permissions", "deno_tls", "fastwebsockets", - "h2 0.4.13", + "h2 0.4.14", "http 1.4.0", "http-body-util", "hyper 1.9.0", @@ -6558,9 +6558,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -7041,7 +7041,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.13", + "h2 0.4.14", "http 1.4.0", "http-body 1.0.1", "httparse", @@ -7539,16 +7539,6 @@ dependencies = [ "serde", ] -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is-macro" version = "0.3.7" @@ -8168,7 +8158,7 @@ dependencies = [ "bitflags 2.9.4", "libc", "plain", - "redox_syscall 0.7.4", + "redox_syscall 0.7.5", ] [[package]] @@ -10259,18 +10249,18 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.11" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.11" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" dependencies = [ "proc-macro2", "quote", @@ -11162,9 +11152,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ "bitflags 2.9.4", ] @@ -11282,7 +11272,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2 0.4.13", + "h2 0.4.14", "http 1.4.0", "http-body 1.0.1", "http-body-util", @@ -11330,7 +11320,7 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2 0.4.13", + "h2 0.4.14", "http 1.4.0", "http-body 1.0.1", "http-body-util", @@ -14721,7 +14711,7 @@ dependencies = [ "base64 0.22.1", "bytes", "flate2", - "h2 0.4.13", + "h2 0.4.14", "http 1.4.0", "http-body 1.0.1", "http-body-util", @@ -14753,7 +14743,7 @@ dependencies = [ "axum 0.8.4", "base64 0.22.1", "bytes", - "h2 0.4.13", + "h2 0.4.14", "http 1.4.0", "http-body 1.0.1", "http-body-util", @@ -14831,9 +14821,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "a28f0d049ccfaa566e14e9663d304d8577427b368cb4710a20528690287a738b" dependencies = [ "async-compression", "base64 0.22.1", @@ -14844,7 +14834,6 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", - "iri-string", "mime", "pin-project-lite", "tokio", @@ -14853,6 +14842,7 @@ dependencies = [ "tower-layer", "tower-service", "tracing", + "url", ] [[package]] @@ -16019,7 +16009,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-nats", @@ -16100,7 +16090,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.695.0" +version = "1.696.0" dependencies = [ "async-trait", "aws-config", @@ -16124,7 +16114,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.695.0" +version = "1.696.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16137,7 +16127,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "argon2", @@ -16280,7 +16270,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.695.0" +version = "1.696.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16303,7 +16293,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.695.0" +version = "1.696.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16316,7 +16306,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16342,7 +16332,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.695.0" +version = "1.696.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16352,7 +16342,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.695.0" +version = "1.696.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16369,7 +16359,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.695.0" +version = "1.696.0" dependencies = [ "axum 0.8.4", "base64 0.22.1", @@ -16391,7 +16381,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16414,7 +16404,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.695.0" +version = "1.696.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16430,7 +16420,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.695.0" +version = "1.696.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16451,7 +16441,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.695.0" +version = "1.696.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16472,7 +16462,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.695.0" +version = "1.696.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16486,7 +16476,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-nats", @@ -16518,7 +16508,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16543,7 +16533,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.695.0" +version = "1.696.0" dependencies = [ "axum 0.8.4", "flate2", @@ -16561,7 +16551,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16583,7 +16573,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.695.0" +version = "1.696.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16603,7 +16593,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.695.0" +version = "1.696.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16633,7 +16623,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16661,7 +16651,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.695.0" +version = "1.696.0" dependencies = [ "lazy_static", "serde", @@ -16673,7 +16663,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.695.0" +version = "1.696.0" dependencies = [ "argon2", "axum 0.8.4", @@ -16698,7 +16688,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.695.0" +version = "1.696.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16712,7 +16702,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.695.0" +version = "1.696.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16745,7 +16735,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.695.0" +version = "1.696.0" dependencies = [ "chrono", "lazy_static", @@ -16759,7 +16749,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16778,7 +16768,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.695.0" +version = "1.696.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -16879,7 +16869,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.695.0" +version = "1.696.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16898,7 +16888,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.695.0" +version = "1.696.0" dependencies = [ "regex", "serde", @@ -16913,7 +16903,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16937,7 +16927,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "futures", @@ -16954,7 +16944,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.695.0" +version = "1.696.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16970,7 +16960,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-trait", @@ -16991,7 +16981,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-trait", @@ -17022,7 +17012,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "arc-swap", @@ -17047,7 +17037,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-stream", @@ -17081,7 +17071,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "futures", @@ -17099,7 +17089,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.695.0" +version = "1.696.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -17108,7 +17098,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "lazy_static", @@ -17120,7 +17110,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "serde_json", @@ -17132,7 +17122,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "gosyn", @@ -17144,7 +17134,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "lazy_static", @@ -17156,7 +17146,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "serde_json", @@ -17168,7 +17158,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "nu-parser", @@ -17179,7 +17169,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -17190,7 +17180,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -17202,7 +17192,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17213,7 +17203,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-recursion", @@ -17235,7 +17225,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "serde_json", @@ -17247,7 +17237,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "lazy_static", @@ -17261,7 +17251,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -17278,7 +17268,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "lazy_static", @@ -17291,7 +17281,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "serde", @@ -17303,7 +17293,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "lazy_static", @@ -17321,7 +17311,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -17337,7 +17327,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17353,7 +17343,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "serde", @@ -17364,7 +17354,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-recursion", @@ -17401,7 +17391,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "const_format", @@ -17439,7 +17429,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.695.0" +version = "1.696.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17450,7 +17440,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-recursion", @@ -17480,7 +17470,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-trait", @@ -17504,7 +17494,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-trait", @@ -17537,7 +17527,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-trait", @@ -17570,7 +17560,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-trait", @@ -17590,7 +17580,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-trait", @@ -17624,7 +17614,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-trait", @@ -17660,7 +17650,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-trait", @@ -17683,7 +17673,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-trait", @@ -17707,7 +17697,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-nats", @@ -17731,7 +17721,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-trait", @@ -17766,7 +17756,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-trait", @@ -17794,7 +17784,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-trait", @@ -17817,7 +17807,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17836,7 +17826,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-once-cell", @@ -17948,7 +17938,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.695.0" +version = "1.696.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index fd6f00e68b..6792679886 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.695.0" +version = "1.696.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.695.0" +version = "1.696.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 6687a77c03..45d0fe8fff 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.695.0" +version = "1.696.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.695.0" +version = "1.696.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.695.0" +version = "1.696.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.695.0" +version = "1.696.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index a77fcc6436..47adc66cb5 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.695.0" +version = "1.696.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index f44b5fac5e..644e01cbb9 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.695.0 + version: 1.696.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 6707029f16..73e51d884d 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.695.0"; +export const VERSION = "v1.696.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index fb030e3347..aaa87937b6 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -78,7 +78,7 @@ export { token, }; -export const VERSION = "1.695.0"; +export const VERSION = "1.696.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 76002aba4a..53a178dfca 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.695.0", + "version": "1.696.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.695.0", + "version": "1.696.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -844,6 +844,7 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -855,6 +856,7 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -865,6 +867,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1354,6 +1357,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1510,6 +1514,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1526,6 +1531,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1542,6 +1548,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1558,6 +1565,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1574,6 +1582,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1590,6 +1599,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1606,6 +1616,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1622,6 +1633,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1638,6 +1650,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1654,6 +1667,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1670,6 +1684,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1686,6 +1701,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1702,6 +1718,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1718,6 +1735,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1734,6 +1752,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2039,6 +2058,7 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6814,7 +6834,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -7313,6 +7333,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7333,6 +7354,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7353,6 +7375,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7373,6 +7396,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7393,6 +7417,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7413,6 +7438,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7433,6 +7459,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7453,6 +7480,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7473,6 +7501,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7493,6 +7522,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7513,6 +7543,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12081,6 +12112,21 @@ } } }, + "node_modules/svelte-check/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", @@ -12811,7 +12857,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/frontend/package.json b/frontend/package.json index 98ff10ae9d..5bf6e54088 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.695.0", + "version": "1.696.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 9b60f26f0a..e6064a7d20 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.695.0" +wmill = ">=1.696.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 910df51d99..8f8819e511 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.695.0 + version: 1.696.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 9811b57cf3..918e4afa5f 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.695.0' + ModuleVersion = '1.696.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 3ff1345b0e..8c2d924858 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.695.0" +version = "1.696.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/typescript-client/jsr.json b/typescript-client/jsr.json index bcbb5fbc5a..21a0d9396d 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.695.0", + "version": "1.696.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 75b89c0ba2..8e170e0daa 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.695.0", + "version": "1.696.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index af443c10ea..22f9e1c0a1 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.695.0 +1.696.0 From f4553e8e7919b115a4239a62ee4587347cc82bb8 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 5 May 2026 21:49:56 +0000 Subject: [PATCH 030/313] fix(workspaces): validate fork id as a git branch name component (#9049) --- .../windmill-api-workspaces/src/workspaces.rs | 16 +-- backend/windmill-common/src/workspaces.rs | 109 ++++++++++++++++++ 2 files changed, 115 insertions(+), 10 deletions(-) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 9ca9a0dd84..a4173fc612 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -40,9 +40,9 @@ use windmill_common::workspaces::GitRepositorySettings; #[cfg(feature = "enterprise")] use windmill_common::workspaces::WorkspaceDeploymentUISettings; use windmill_common::workspaces::{ - check_user_against_rule, get_datatable_resource_from_db_unchecked, DataTable, - DataTableCatalogResourceType, DataTableForkBehavior, ProtectionRuleKind, ProtectionRules, - ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings, WM_FORK_PREFIX, + check_user_against_rule, get_datatable_resource_from_db_unchecked, validate_fork_workspace_id, + DataTable, DataTableCatalogResourceType, DataTableForkBehavior, ProtectionRuleKind, + ProtectionRules, ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings, }; use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType}; use windmill_common::PgDatabase; @@ -4776,6 +4776,8 @@ async fn create_workspace_fork_branch( return Err(Error::PermissionDenied(msg)); } + validate_fork_workspace_id(&nw.id)?; + Ok(Json( handle_fork_branch_creation(&authed.email, &authed.username, &db, &w_id, &nw.id).await?, )) @@ -4935,13 +4937,7 @@ async fn create_workspace_fork( let mut tx: Transaction<'_, Postgres> = db.begin().await?; - // Generate unique forked workspace ID with wm-fork prefix - if !nw.id.starts_with(WM_FORK_PREFIX) { - return Err(Error::BadRequest(format!( - "The id `{}` is invalid for a forked workspace. It should be prefixed by {}", - nw.id, WM_FORK_PREFIX - ))); - } + validate_fork_workspace_id(&nw.id)?; let forked_id = nw.id; diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 77e69a3340..74ea9f9c9e 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -163,6 +163,65 @@ pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28217/sync-script-to-git-repo /// fork of another workspace. pub const WM_FORK_PREFIX: &str = "wm-fork-"; +/// Validate that a fork workspace id is safe to interpolate into a git branch name. +/// +/// The id is appended verbatim to a branch like `wm-fork//`, +/// so it must satisfy `git check-ref-format` rules. We validate synchronously at the API +/// layer because the actual branch creation runs in a deferred git-sync worker job — without +/// this check, the API returns 200 and the failure only surfaces later in the worker. +pub fn validate_fork_workspace_id(id: &str) -> error::Result<()> { + if !id.starts_with(WM_FORK_PREFIX) { + return Err(Error::BadRequest(format!( + "The id `{}` is invalid for a forked workspace. It should be prefixed by {}", + id, WM_FORK_PREFIX + ))); + } + + let reject = |reason: &str| { + Err::<(), _>(Error::BadRequest(format!( + "Fork workspace id `{}` is invalid: {} (must be a valid git branch name component)", + id, reason + ))) + }; + + if id.ends_with('.') { + return reject("cannot end with '.'"); + } + if id.ends_with(".lock") { + return reject("cannot end with '.lock'"); + } + if id.contains("..") { + return reject("cannot contain '..'"); + } + if id.contains("@{") { + return reject("cannot contain '@{'"); + } + if id.contains("//") { + return reject("cannot contain '//'"); + } + for ch in id.chars() { + match ch { + ':' | '~' | '^' | '?' | '*' | '[' | '\\' | ' ' => { + return reject(&format!("contains forbidden character '{}'", ch)); + } + c if c.is_ascii_control() || c == '\u{7f}' => { + return reject("contains a control character"); + } + _ => {} + } + } + // Each slash-separated component cannot start with '.' or end with '.lock'. + for component in id.split('/') { + if component.starts_with('.') { + return reject("a path component cannot start with '.'"); + } + if component.ends_with(".lock") { + return reject("a path component cannot end with '.lock'"); + } + } + Ok(()) +} + #[derive(Serialize, Deserialize, Debug)] pub struct GitRepositorySettings { #[serde(skip_serializing_if = "Option::is_none")] @@ -666,3 +725,53 @@ async fn transform_json_unchecked( Ok(value) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_fork_workspace_id_accepts_valid() { + validate_fork_workspace_id("wm-fork-test-allow").unwrap(); + validate_fork_workspace_id("wm-fork-my_workspace.42").unwrap(); + validate_fork_workspace_id("wm-fork-a").unwrap(); + } + + #[test] + fn test_validate_fork_workspace_id_rejects_missing_prefix() { + assert!(validate_fork_workspace_id("not-a-fork").is_err()); + assert!(validate_fork_workspace_id("wm-fork").is_err()); + } + + #[test] + fn test_validate_fork_workspace_id_rejects_git_unsafe_chars() { + for bad in [ + "wm-fork-test:allow", + "wm-fork-test allow", + "wm-fork-test~allow", + "wm-fork-test^allow", + "wm-fork-test?allow", + "wm-fork-test*allow", + "wm-fork-test[allow", + "wm-fork-test\\allow", + "wm-fork-test\nallow", + ] { + assert!( + validate_fork_workspace_id(bad).is_err(), + "expected `{}` to be rejected", + bad + ); + } + } + + #[test] + fn test_validate_fork_workspace_id_rejects_git_unsafe_sequences() { + assert!(validate_fork_workspace_id("wm-fork-foo..bar").is_err()); + assert!(validate_fork_workspace_id("wm-fork-foo@{bar").is_err()); + assert!(validate_fork_workspace_id("wm-fork-foo//bar").is_err()); + assert!(validate_fork_workspace_id("wm-fork-foo.").is_err()); + assert!(validate_fork_workspace_id("wm-fork-foo.lock").is_err()); + assert!(validate_fork_workspace_id("wm-fork-foo/.bar").is_err()); + assert!(validate_fork_workspace_id("wm-fork-foo/bar.lock").is_err()); + } +} From eebaab9c87f975b70049e118a08665fc21653b13 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 6 May 2026 10:00:46 +0000 Subject: [PATCH 031/313] fix(bun): propagate non-zero exit from generate_bun_bundle on no-DB path (#9051) --- backend/src/main.rs | 2 - backend/tests/bun_jobs.rs | 215 ++++++++++++++++++ backend/windmill-worker/loader_builder.bun.js | 8 +- backend/windmill-worker/src/bun_executor.rs | 65 ++++-- backend/windmill-worker/src/lib.rs | 7 +- 5 files changed, 278 insertions(+), 19 deletions(-) diff --git a/backend/src/main.rs b/backend/src/main.rs index ce09c7f25f..3f3a9b0e29 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -305,8 +305,6 @@ async fn cache_hub_scripts(file_path: Option) -> anyhow::Result<()> { ) .await?; - let _ = windmill_common::worker::write_file(&job_dir, "main.js", &res.content)?; - if let Err(e) = windmill_worker::prebundle_bun_script( &res.content, &lock, diff --git a/backend/tests/bun_jobs.rs b/backend/tests/bun_jobs.rs index a791060a2c..fa15866bca 100644 --- a/backend/tests/bun_jobs.rs +++ b/backend/tests/bun_jobs.rs @@ -956,6 +956,221 @@ export function main() { Ok(()) } +// ============================================================================ +// Bundle Wrapper Safety Tests +// ============================================================================ + +/// Regression test for the "TS source ends up in the bun bundle cache" bug. +/// +/// The wrapper-side hardening: `node_builder.ts` discarded `Bun.build`'s +/// return value, so any silent-failure mode (`success: false` without +/// throwing — `throw: false`, or a future Bun where defaults change) made +/// the wrapper exit 0 even though no `main.js` was written. Pair that with +/// a pre-existing `main.js` containing raw TypeScript and `save_cache` +/// happily copied that TS into the bundle cache; the worker later choked +/// on `type GpgKey = {`. +/// +/// This test patches `node_builder.ts` to force the silent-failure shape +/// and asserts that our wrapper now refuses to silently succeed — bun must +/// exit non-zero so prebundling fails loudly instead of writing TypeScript +/// into the bundle cache. +#[test] +fn test_bun_bundle_wrapper_catches_silent_failure() { + use std::process::Command; + use windmill_worker::{build_loader, LoaderMode, BUN_PATH}; + + let temp_dir = tempfile::tempdir().unwrap(); + let dir = temp_dir.path(); + let dir_str = dir.to_str().unwrap(); + + // Script imports a package that won't exist in node_modules. + std::fs::write( + dir.join("main.ts"), + r#" +import x from "definitely-not-a-real-pkg-windmill-test"; +export function main() { return x; } +"#, + ) + .unwrap(); + + // Generate the real node_builder.ts via the production code path. + tokio::runtime::Runtime::new() + .unwrap() + .block_on(build_loader( + dir_str, + "http://localhost:8000", + "test_token", + "test-workspace", + "f/test/script", + LoaderMode::BunBundle, + &None, + )) + .expect("build_loader failed"); + + // Force the silent-failure shape by injecting `throw: false`. The + // wrapper's pre-fix `try/catch` would have swallowed this; the fixed + // wrapper inspects `result.success` and `result.outputs` and exits 1. + let path = dir.join("node_builder.ts"); + let original = std::fs::read_to_string(&path).unwrap(); + let patched = original.replace( + "external: [\"electron\"],", + "external: [\"electron\"], throw: false,", + ); + assert_ne!( + original, patched, + "expected to find Bun.build options block to patch; node_builder.ts template changed?" + ); + std::fs::write(&path, patched).unwrap(); + + // Pre-seed main.js with raw TypeScript (mimics the historical + // pre-write that originally seeded the bug). + std::fs::write( + dir.join("main.js"), + "type GpgKey = { email: string };\nexport const main = (): GpgKey => ({ email: \"\" });\n", + ) + .unwrap(); + + let output = Command::new(BUN_PATH.as_str()) + .args(["run", path.to_str().unwrap()]) + .current_dir(dir) + .output() + .expect("Failed to run bun"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !output.status.success(), + "node_builder.ts must exit non-zero when Bun.build silently fails to write a bundle.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!( + stdout.contains("Failed to build node bundle"), + "expected diagnostic in stdout, got:\n{stdout}" + ); +} + +/// Regression test for the actual root cause of the "TS source in bundle +/// cache" bug: `generate_bun_bundle` was awaiting `child_process.wait()` +/// without checking the exit code on the no-DB path (used by Docker-build +/// `windmill cache hubPaths.json`). bun would exit 1 after Bun.build threw, +/// `wait().await?` propagated only IO errors, and `generate_bun_bundle` +/// returned `Ok(())`. `save_cache` then copied a stale `main.js` (raw TS +/// source) straight into the bundle cache. +/// +/// This test runs `generate_bun_bundle` with `db: None` against a `node_builder.ts` +/// that calls `process.exit(1)`, and asserts the function now returns an error. +#[test] +fn test_generate_bun_bundle_propagates_exit_status() { + use windmill_worker::{generate_bun_bundle, get_common_bun_proc_envs}; + + let temp_dir = tempfile::tempdir().unwrap(); + let dir = temp_dir.path(); + let dir_str = dir.to_str().unwrap(); + + // node_builder.ts that exits 1, mimicking what bun does when Bun.build throws. + std::fs::write( + dir.join("node_builder.ts"), + "console.log('simulated bun build failure');\nprocess.exit(1);\n", + ) + .unwrap(); + + let runtime = tokio::runtime::Runtime::new().unwrap(); + let envs = runtime.block_on(get_common_bun_proc_envs(None)); + + let result = runtime.block_on(generate_bun_bundle( + dir_str, + "test-workspace", + &uuid::Uuid::new_v4(), + "test-worker", + None, // db: None — this is the cache_hub_scripts path that had the bug + None, + &mut 0, + &mut None, + &envs, + &mut None, + )); + + assert!( + result.is_err(), + "generate_bun_bundle must surface bun's non-zero exit on the no-DB path. \ + If it returns Ok(()) when bun exited 1, save_cache will silently cache stale main.js content." + ); + let err_msg = format!("{:?}", result.unwrap_err()); + assert!( + err_msg.contains("non-zero status"), + "expected exit-status error, got: {err_msg}" + ); +} + +/// Regression test for the install_bun_lockfile no-DB path: same code shape as +/// `generate_bun_bundle` (site 3 of the original bug) — `wait().await?` ignored +/// non-zero bun exits. A `bun install` failure (e.g. malformed package.json) +/// must now surface as an error so callers don't proceed with a half-installed +/// node_modules. +#[test] +fn test_install_bun_lockfile_propagates_exit_status() { + use windmill_worker::{get_common_bun_proc_envs, install_bun_lockfile}; + let temp_dir = tempfile::tempdir().unwrap(); + let dir = temp_dir.path(); + let dir_str = dir.to_str().unwrap(); + // Malformed package.json -> bun install fails with exit 1. + std::fs::write(dir.join("package.json"), "this is not valid json").unwrap(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let envs = runtime.block_on(get_common_bun_proc_envs(None)); + let result = runtime.block_on(install_bun_lockfile( + &mut 0, + &mut None, + &uuid::Uuid::new_v4(), + "test-workspace", + None, // db: None — no-DB path that had the bug + dir_str, + "test-worker", + envs, + false, // npm_mode + &mut None, + true, // quiet + )); + assert!( + result.is_err(), + "install_bun_lockfile must surface bun's non-zero exit on the no-DB path" + ); + let err_msg = format!("{:?}", result.unwrap_err()); + assert!( + err_msg.contains("non-zero status"), + "expected exit-status error, got: {err_msg}" + ); +} + +/// Regression test for the post-bundle existence check in `prebundle_bun_script` +/// and `handle_bun_job`. Both call sites guard against the case where +/// `generate_bun_bundle` returns `Ok(())` but `main.js` was never written — +/// the upstream wait-status fix is the primary defense, this is the catch-all +/// for any other silent-failure mode (Bun output-naming change, custom plugin +/// swallowing the build, etc.). Without this check, `save_cache` would +/// happily copy whatever's at the bundle path (often raw TypeScript that some +/// other code path left there). +#[test] +fn test_ensure_bundle_output_exists_rejects_missing_file() { + use windmill_worker::ensure_bundle_output_exists; + let temp_dir = tempfile::tempdir().unwrap(); + let dir = temp_dir.path(); + let missing = dir.join("main.js").to_str().unwrap().to_string(); + + let result = ensure_bundle_output_exists(&missing); + assert!( + result.is_err(), + "ensure_bundle_output_exists must reject when the bundle file is missing" + ); + let err_msg = format!("{:?}", result.unwrap_err()); + assert!( + err_msg.contains("bun bundle output missing"), + "expected 'bun bundle output missing' in error, got: {err_msg}" + ); + + // Sanity: when the file does exist, it returns Ok. + std::fs::write(&missing, "// @bun\n").unwrap(); + assert!(ensure_bundle_output_exists(&missing).is_ok()); +} + // ============================================================================ // Dedicated Worker Protocol Tests // ============================================================================ diff --git a/backend/windmill-worker/loader_builder.bun.js b/backend/windmill-worker/loader_builder.bun.js index 5ca1b22ab2..d2206b398b 100644 --- a/backend/windmill-worker/loader_builder.bun.js +++ b/backend/windmill-worker/loader_builder.bun.js @@ -1,5 +1,6 @@ +let buildResult; try { - await Bun.build({ + buildResult = await Bun.build({ entrypoints: ["./main.ts"], outdir: "./out", plugins: [p], @@ -17,6 +18,11 @@ try { console.log(err); process.exit(1); } +if (!buildResult?.success || !(buildResult.outputs?.length > 0)) { + for (const log of buildResult?.logs ?? []) console.log(log); + console.log("Failed to build bundle: success=" + buildResult?.success + ", outputs=" + (buildResult?.outputs?.length ?? 0)); + process.exit(1); +} const fs = require("fs/promises"); diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index be842e28b4..82d59d44b1 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -471,7 +471,12 @@ pub async fn gen_bun_lockfile( } result?; } else { - Box::into_pin(child_process.wait()).await?; + let status = Box::into_pin(child_process.wait()).await?; + if !status.success() { + return Err(error::Error::ExecutionErr(format!( + "bun build exited with non-zero status: {status:?}" + ))); + } } let new_package_json = read_file_content(&format!("{job_dir}/package.json")).await?; @@ -777,7 +782,12 @@ pub async fn install_bun_lockfile( } result?; } else { - Box::into_pin(child_process.wait()).await?; + let status = Box::into_pin(child_process.wait()).await?; + if !status.success() { + return Err(error::Error::ExecutionErr(format!( + "bun install exited with non-zero status: {status:?}" + ))); + } } if has_file { @@ -838,8 +848,9 @@ try {{ }} catch (e) {{ }} +let result; try {{ - await Bun.build({{ + result = await Bun.build({{ entrypoints: ["{job_dir_js}/wrapper.mjs"], outdir: "./", target: "node", @@ -852,6 +863,11 @@ try {{ console.log("Failed to build node bundle"); process.exit(1); }} +if (!result?.success || !(result.outputs?.length > 0)) {{ + for (const log of result?.logs ?? []) console.log(log); + console.log("Failed to build node bundle: success=" + result?.success + ", outputs=" + (result?.outputs?.length ?? 0)); + process.exit(1); +}} "# ), )?; @@ -880,8 +896,9 @@ plugin(p) r#" {loader} +let result; try {{ - await Bun.build({{ + result = await Bun.build({{ entrypoints: ["{job_dir_js}/main.ts"], outdir: "./", target: "{}", @@ -898,6 +915,11 @@ try {{ console.log("Failed to build node bundle"); process.exit(1); }} +if (!result?.success || !(result.outputs?.length > 0)) {{ + for (const log of result?.logs ?? []) console.log(log); + console.log("Failed to build node bundle: success=" + result?.success + ", outputs=" + (result?.outputs?.length ?? 0)); + process.exit(1); +}} "#, if mode == LoaderMode::BunBundle { "bun" @@ -1008,7 +1030,12 @@ pub async fn generate_bun_bundle( ) .await?; } else { - Box::into_pin(child_process.wait()).await?; + let status = Box::into_pin(child_process.wait()).await?; + if !status.success() { + return Err(error::Error::ExecutionErr(format!( + "bun build exited with non-zero status: {status:?}" + ))); + } } Ok(()) } @@ -1137,6 +1164,9 @@ pub async fn prebundle_bun_script( content = format!("export {{ WorkflowCtx, StepSuspend, setWorkflowCtx }} from \"windmill-client\";\n{content}"); } write_file(job_dir, "main.ts", &content)?; + // Remove any stale main.js so we never confuse a leftover (e.g. unbundled TS source + // a caller dropped at this path) with a fresh Bun bundle output. + let _ = std::fs::remove_file(&origin); build_loader( job_dir, base_internal_url, @@ -1170,11 +1200,25 @@ pub async fn prebundle_bun_script( ) .await?; + ensure_bundle_output_exists(&origin)?; + save_cache(&local_path, &remote_path, &origin, false).await?; Ok(()) } +/// Refuse to cache a bundle if `Bun.build` finished without producing the +/// expected output file. Belt-and-suspenders for any silent-failure mode the +/// upstream wait-status / `result.success` checks don't already trip on. +pub fn ensure_bundle_output_exists(bundle_path: &str) -> Result<()> { + if !std::path::Path::new(bundle_path).exists() { + return Err(error::Error::ExecutionErr(format!( + "bun bundle output missing at {bundle_path} after Bun.build — refusing to cache" + ))); + } + Ok(()) +} + pub const BUN_BUNDLE_OBJECT_STORE_PREFIX: &str = "bun_bundle/"; async fn get_script_import_updated_at(db: &DB, w_id: &str, script_path: &str) -> Result { @@ -1902,15 +1946,10 @@ try {{ &mut Some(occupancy_metrics), ) .await?; + let bundle_path = format!("{job_dir}/main.js"); + ensure_bundle_output_exists(&bundle_path)?; if !local_path.is_empty() { - match save_cache( - &local_path, - &remote_path, - &format!("{job_dir}/main.js"), - false, - ) - .await - { + match save_cache(&local_path, &remote_path, &bundle_path, false).await { Err(e) => { let em = format!("could not save {local_path} to bundle cache: {e:?}"); tracing::error!(em) diff --git a/backend/windmill-worker/src/lib.rs b/backend/windmill-worker/src/lib.rs index 2634824d5c..0d5df81a83 100644 --- a/backend/windmill-worker/src/lib.rs +++ b/backend/windmill-worker/src/lib.rs @@ -90,9 +90,10 @@ pub use otel_tracing_proxy_ee::{load_internal_otel_exporter, DENO_OTEL_INITIALIZ pub use worker::*; pub use bun_executor::{ - build_loader, compute_bundle_local_and_remote_path, get_common_bun_proc_envs, - install_bun_lockfile, prebundle_bun_script, prepare_job_dir, LoaderMode, - BUN_DEDICATED_WORKER_ARGS, RELATIVE_BUN_BUILDER, RELATIVE_BUN_LOADER, + build_loader, compute_bundle_local_and_remote_path, ensure_bundle_output_exists, + generate_bun_bundle, get_common_bun_proc_envs, install_bun_lockfile, prebundle_bun_script, + prepare_job_dir, LoaderMode, BUN_DEDICATED_WORKER_ARGS, RELATIVE_BUN_BUILDER, + RELATIVE_BUN_LOADER, }; #[cfg(any(feature = "private", test))] pub use bun_executor::{ From 73358c29a49e8505748e0aee551164d31b64d031 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Wed, 6 May 2026 12:25:50 +0200 Subject: [PATCH 032/313] fix: autofocus searchbar and open dropdown on typing (#9052) * Autofocus searchbar and open dropdown on typing * nit always call onKeyDown --- frontend/src/lib/components/FilterSearchbar.svelte | 6 +++++- frontend/src/lib/components/RunsPage.svelte | 1 + frontend/src/lib/components/TaggedTextInput.svelte | 7 +++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/FilterSearchbar.svelte b/frontend/src/lib/components/FilterSearchbar.svelte index 661b84cc87..537e76ceff 100644 --- a/frontend/src/lib/components/FilterSearchbar.svelte +++ b/frontend/src/lib/components/FilterSearchbar.svelte @@ -263,6 +263,7 @@ presets?: { name: string; value: string }[] class?: string placeholder?: string + autofocus?: boolean } type SchemaT = FilterSchemaRec // TODO: Generic @@ -271,7 +272,8 @@ value: valueInput = $bindable(), presets: _presets = [], class: className, - placeholder = 'Filter...' + placeholder = 'Filter...', + autofocus }: Props = $props() let _value = new DebouncedTempValue( @@ -604,6 +606,8 @@ inputSizeClasses.md )} {placeholder} + onKeyDown={() => (open = true)} + {autofocus} /> {#if asText.val} (_value.current = {})} /> diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte index 588b4cdaa4..86b7588099 100644 --- a/frontend/src/lib/components/RunsPage.svelte +++ b/frontend/src/lib/components/RunsPage.svelte @@ -755,6 +755,7 @@ })} bind:value={filters.val} placeholder="Filter runs..." + autofocus />
diff --git a/frontend/src/lib/components/TaggedTextInput.svelte b/frontend/src/lib/components/TaggedTextInput.svelte index ecf4904711..23bbc65e6b 100644 --- a/frontend/src/lib/components/TaggedTextInput.svelte +++ b/frontend/src/lib/components/TaggedTextInput.svelte @@ -6,6 +6,8 @@ highlights, onCurrentTagChange, onTextSegmentAtCursorChange, + onKeyDown, + autofocus, class: className = '' }: { tags: { regex: RegExp; id: string; onClear?: () => void }[] @@ -14,6 +16,8 @@ highlights?: { regex: RegExp; classes: string }[] onCurrentTagChange?: (tag: { id: string } | null) => void onTextSegmentAtCursorChange?: (segment: { text: string; start: number; end: number }) => void + onKeyDown?: (e: KeyboardEvent) => void + autofocus?: boolean class?: string } = $props() @@ -332,6 +336,7 @@ } function handleKeyDown(e: KeyboardEvent) { + onKeyDown?.(e) if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') return const cursorPos = getCursorPosition() const text = getTextContent() @@ -501,6 +506,7 @@ } +
diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index e919f7d484..158e4be8df 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -877,7 +877,7 @@ {/snippet} {#if $mode === 'preview'} - +
{/if} - +
- import { Drawer, DrawerContent, UndoRedo } from '$lib/components/common' + import { Drawer, DrawerContent } from '$lib/components/common' import Button from '$lib/components/common/button/Button.svelte' import Toggle from '$lib/components/Toggle.svelte' import { AppService, DraftService, type Policy } from '$lib/gen' import { redo, undo } from '$lib/history.svelte' import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores' - import type { Item } from '$lib/utils' + import { isMac, type Item, userPathPrefix } from '$lib/utils' + import { random_adj } from '$lib/components/random_positive_adjetive' import { AlignHorizontalSpaceAround, BellOff, @@ -24,6 +25,8 @@ Sun, Moon, SunMoon, + Undo, + Redo, Zap, Globe } from 'lucide-svelte' @@ -52,7 +55,9 @@ import AppReportsDrawer from './AppReportsDrawer.svelte' import DebugPanel from './contextPanel/DebugPanel.svelte' - import Summary from '$lib/components/Summary.svelte' + import EditorHeader from '$lib/components/EditorHeader.svelte' + import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker' + import { goto } from '$app/navigation' import HideButton from './settingsPanel/HideButton.svelte' import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte' @@ -119,7 +124,20 @@ onHideBottomPanel }: Props = $props() - let newEditedPath = $state('') + /** Mirror of the path the user is editing in the pen popover. Initialized + * once from `newPath` (or a synthesized path for new apps) and only + * updated by user input from then on — we deliberately do NOT sync from + * `newPath` afterwards so the user's in-flight rename isn't clobbered by + * a parent reload that re-supplies the saved path. The fallback chain at + * read sites (`newEditedPath || savedApp?.draft?.path || savedApp?.path`) + * handles the case where `newEditedPath` is briefly empty before the + * synthesized initialization runs — falls through to the saved path so + * rename detection still works. */ + let newEditedPath = $state( + untrack(() => + newApp ? userPathPrefix($userStore?.username) + random_adj() + '_app' : (newPath ?? '') + ) + ) let deployedValue: Value | undefined = $state(undefined) // Value to diff against let deployedBy: string | undefined = $state(undefined) // Author let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning @@ -283,6 +301,7 @@ preserve_on_behalf_of: preserveOnBehalfOf || undefined } }) + invalidatePicker($workspaceStore!, 'app') savedApp = { summary: $summary, value: structuredClone($state.snapshot($app)), @@ -511,19 +530,12 @@ lock = true - switch (event.key) { - case 'Z': - if (event.ctrlKey || event.metaKey) { - const napp = redo(history) - for (const key in napp) { - $app[key] = napp[key] - } - event.preventDefault() - } - break + // Only lowercase single-char keys — named keys (`ArrowDown`, etc.) must + // stay PascalCase to match their switch cases. + switch (event.key.length === 1 ? event.key.toLowerCase() : event.key) { case 'z': if (event.ctrlKey || event.metaKey) { - const napp = undo(history, $app) + const napp = event.shiftKey ? redo(history) : undo(history, $app) for (const key in napp) { $app[key] = napp[key] } @@ -558,7 +570,37 @@ lock = false } + const mod = isMac() ? '⌘' : 'Ctrl+' + + function handleUndo() { + const napp = undo(history, $app) + for (const key in napp) { + $app[key] = napp[key] + } + } + function handleRedo() { + const napp = redo(history) + for (const key in napp) { + $app[key] = napp[key] + } + } + let moreItems = $derived([ + { + displayName: 'Undo', + icon: Undo, + action: () => handleUndo(), + disabled: $history?.index === 0, + shortcut: `${mod}Z` + }, + { + displayName: 'Redo', + icon: Redo, + action: () => handleRedo(), + disabled: $history && $history?.index === $history.history.length - 1, + shortcut: `${mod}⇧Z`, + separatorBottom: true + }, { displayName: 'Deployment history', icon: History, @@ -890,28 +932,17 @@
- + goto(editPathFor(item))} + />
- { - const napp = undo(history, $app) - for (const key in napp) { - $app[key] = napp[key] - } - }} - on:redo={() => { - const napp = redo(history) - for (const key in napp) { - $app[key] = napp[key] - } - }} - /> - {#if $app} (summary = v)} + textClass="text-xs font-semibold text-emphasis" +/> +``` + +The current value isn't bound — `onSave` is fired with the trimmed draft +whenever it differs from the prior `value`, including with `''` when the +user clears the field. Callers that want to reject empty commits should +guard inside their `onSave` handler. The parent owns the canonical state; +this component just proposes new values. +--> + + +{#if editing} + + + +{:else} + +{/if} + + diff --git a/frontend/src/lib/components/common/button/Button.svelte b/frontend/src/lib/components/common/button/Button.svelte index 077994d842..b739076cee 100644 --- a/frontend/src/lib/components/common/button/Button.svelte +++ b/frontend/src/lib/components/common/button/Button.svelte @@ -385,7 +385,11 @@ {@render children?.()} {/if} {#if endIcon?.icon} - + {/if} {#if shortCut && !shortCut.hide}
+ {/if} {#if shortCut && !shortCut.hide} {@const Icon = shortCut.Icon} diff --git a/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte b/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte index 20ebe04c85..1305d611c1 100644 --- a/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte +++ b/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte @@ -58,11 +58,8 @@ open = true } if (savedValue && modifiedValue) { - const draftOrDeployed = cleanValueProperties({ - ...((savedValue.draft || savedValue) ?? {}), - path: undefined - }) - const current = cleanValueProperties({ ...(modifiedValue ?? {}), path: undefined }) + const draftOrDeployed = cleanValueProperties((savedValue.draft || savedValue) ?? {}) + const current = cleanValueProperties(modifiedValue ?? {}) if ( orderedJsonStringify(replaceFalseWithUndefined(draftOrDeployed)) === diff --git a/frontend/src/lib/components/common/table/RowIcon.svelte b/frontend/src/lib/components/common/table/RowIcon.svelte index 7906b01d33..67a707ff1e 100644 --- a/frontend/src/lib/components/common/table/RowIcon.svelte +++ b/frontend/src/lib/components/common/table/RowIcon.svelte @@ -53,9 +53,10 @@ | 'email_trigger' /** For 'trigger' kind, specifies the specific trigger type (routes, schedules, etc.) */ triggerKind?: string | undefined + size?: number } - let { kind, triggerKind = undefined }: Props = $props() + let { kind, triggerKind = undefined, size = 16 }: Props = $props() // Map per-kind backend names (e.g. `kafka_trigger`) to the legacy short // names the icon switch already handles, so we don't have to duplicate cases. @@ -79,44 +80,44 @@
{#if effectiveKind === 'flow'} - + {:else if effectiveKind === 'app' || effectiveKind === 'raw_app'} - + {:else if effectiveKind === 'script'} - + {:else if effectiveKind === 'variable'} - + {:else if effectiveKind === 'resource'} - + {:else if effectiveKind === 'resource_type'} -
+
{:else if effectiveKind === 'folder'} - + {:else if effectiveKind === 'schedule' || effectiveKind === 'schedules'} - + {:else if effectiveKind === 'routes'} - + {:else if effectiveKind === 'websockets'} - + {:else if effectiveKind === 'postgres'} - + {:else if effectiveKind === 'kafka'} - + {:else if effectiveKind === 'nats'} - + {:else if effectiveKind === 'mqtt'} - + {:else if effectiveKind === 'sqs'} - + {:else if effectiveKind === 'gcp'} - + {:else if effectiveKind === 'azure'} - + {:else if effectiveKind === 'emails'} - + {:else if effectiveKind === 'trigger'} - + {:else} -
+
{/if}
diff --git a/frontend/src/lib/components/flow_builder.ts b/frontend/src/lib/components/flow_builder.ts index d847664fa7..427fb9a9fd 100644 --- a/frontend/src/lib/components/flow_builder.ts +++ b/frontend/src/lib/components/flow_builder.ts @@ -6,46 +6,48 @@ import type { DiffDrawerI } from './diff_drawer' import type { FlowBuilderWhitelabelCustomUi } from './custom_ui' import type { ScheduleTrigger } from './triggers' import type { stepState } from './stepHistoryLoader.svelte' +import type { WorkspaceItem } from './workspacePicker' export type FlowBuilderProps = { - initialPath?: string - pathStoreInit?: string | undefined - newFlow: boolean - selectedId: string | undefined - initialArgs?: Record - loading?: boolean - flowStore: StateStore - flowStateStore: StateStore - savedFlow?: FlowWithDraftAndDraftTriggers | undefined - diffDrawer?: DiffDrawerI | undefined - customUi?: FlowBuilderWhitelabelCustomUi - disableAi?: boolean - disabledFlowInputs?: boolean - savedPrimarySchedule?: ScheduleTrigger | undefined // used to set the primary schedule in the legacy primaryScheduleStore - version?: number | undefined - setSavedraftCb?: ((cb: () => void) => void) | undefined - draftTriggersFromUrl?: Trigger[] | undefined - selectedTriggerIndexFromUrl?: number | undefined - children?: import('svelte').Snippet - loadedFromHistoryFromUrl?: { - flowJobInitial: boolean | undefined - stepsState: Record - } - noInitial?: boolean - onSaveInitial?: ({ path, id }: { path: string; id: string }) => void - onSaveDraft?: ({ - path, - savedAtNewPath, - newFlow - }: { - path: string - savedAtNewPath: boolean - newFlow: boolean - }) => void - onSaveDraftError?: ({ error }: { error: any }) => void - onSaveDraftOnlyAtNewPath?: ({ path, selectedId }: { path: string; selectedId: string }) => void - onDeploy?: ({ path }: { path: string }) => void - onDeployError?: ({ error }: { error: any }) => void - onDetails?: ({ path }: { path: string }) => void - onHistoryRestore?: () => void + initialPath?: string + pathStoreInit?: string | undefined + newFlow: boolean + selectedId: string | undefined + initialArgs?: Record + loading?: boolean + flowStore: StateStore + flowStateStore: StateStore + savedFlow?: FlowWithDraftAndDraftTriggers | undefined + diffDrawer?: DiffDrawerI | undefined + customUi?: FlowBuilderWhitelabelCustomUi + disableAi?: boolean + disabledFlowInputs?: boolean + savedPrimarySchedule?: ScheduleTrigger | undefined // used to set the primary schedule in the legacy primaryScheduleStore + version?: number | undefined + setSavedraftCb?: ((cb: () => void) => void) | undefined + draftTriggersFromUrl?: Trigger[] | undefined + selectedTriggerIndexFromUrl?: number | undefined + children?: import('svelte').Snippet + loadedFromHistoryFromUrl?: { + flowJobInitial: boolean | undefined + stepsState: Record + } + noInitial?: boolean + onSaveInitial?: ({ path, id }: { path: string; id: string }) => void + onSaveDraft?: ({ + path, + savedAtNewPath, + newFlow + }: { + path: string + savedAtNewPath: boolean + newFlow: boolean + }) => void + onSaveDraftError?: ({ error }: { error: any }) => void + onSaveDraftOnlyAtNewPath?: ({ path, selectedId }: { path: string; selectedId: string }) => void + onDeploy?: ({ path }: { path: string }) => void + onDeployError?: ({ error }: { error: any }) => void + onDetails?: ({ path }: { path: string }) => void + onHistoryRestore?: () => void + onNavigate?: (item: WorkspaceItem) => void } diff --git a/frontend/src/lib/components/flows/content/FlowSettings.svelte b/frontend/src/lib/components/flows/content/FlowSettings.svelte index 353c9ba563..3c6b268e00 100644 --- a/frontend/src/lib/components/flows/content/FlowSettings.svelte +++ b/frontend/src/lib/components/flows/content/FlowSettings.svelte @@ -139,6 +139,14 @@ namePlaceholder="flow" kind="flow" /> + {#if $initialPathStore && $pathStore && $pathStore !== $initialPathStore} + + {/if} {/if} diff --git a/frontend/src/lib/components/icons/BarsStaggered.svelte b/frontend/src/lib/components/icons/BarsStaggered.svelte index dbfebdeb1d..eedc72de8a 100644 --- a/frontend/src/lib/components/icons/BarsStaggered.svelte +++ b/frontend/src/lib/components/icons/BarsStaggered.svelte @@ -1,18 +1,18 @@ diff --git a/frontend/src/lib/components/meltComponents/Popover.svelte b/frontend/src/lib/components/meltComponents/Popover.svelte index a88d90c7ff..87d5ff3f83 100644 --- a/frontend/src/lib/components/meltComponents/Popover.svelte +++ b/frontend/src/lib/components/meltComponents/Popover.svelte @@ -52,6 +52,9 @@ closeOnOtherPopoverOpen?: boolean allowFullScreen?: boolean extraProps?: Record + /** Attributes spread onto the trigger button. Use for `aria-label`, + * `aria-current`, etc. — overrides the default `aria-label="Popup button"`. */ + triggerAttrs?: Record disabled?: boolean documentationLink?: string | undefined disableFocusTrap?: boolean @@ -90,6 +93,7 @@ closeOnOtherPopoverOpen = false, allowFullScreen = false, extraProps = {}, + triggerAttrs = {}, disabled = false, documentationLink = undefined, disableFocusTrap = false, @@ -219,6 +223,7 @@ class={className} use:melt={$_trigger} aria-label="Popup button" + {...triggerAttrs} disabled={disablePopup || disabled} onmouseenter={() => { if (openOnHover) { diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 8d9fa070af..247a0486af 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -943,7 +943,7 @@ onApply={handleYamlApply} /> - + - import { Badge, Drawer, DrawerContent } from '$lib/components/common' + import { Drawer, DrawerContent } from '$lib/components/common' import Button from '$lib/components/common/button/Button.svelte' - import UndoRedo from '$lib/components/common/button/UndoRedo.svelte' + import { isMac, userPathPrefix } from '$lib/utils' + import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker' import { AppService, DraftService, type Policy } from '$lib/gen' import { rawAppToHubUrl } from '$lib/hub' @@ -15,18 +16,19 @@ FileJson, Globe, History, - Pen, + Redo, Save, + Undo, WandSparkles } from 'lucide-svelte' - import { createEventDispatcher } from 'svelte' + import { createEventDispatcher, untrack } from 'svelte' import { cleanValueProperties, orderedJsonStringify, type Value, - replaceFalseWithUndefined, - defaultIfEmptyString + replaceFalseWithUndefined } from '../../utils' + import { random_adj } from '$lib/components/random_positive_adjetive' // import { allItems, toStatic } from '../apps/editor/settingsPanel/utils' import AppExportButton from '../apps/editor/AppExportButton.svelte' @@ -37,7 +39,8 @@ import Awareness from '$lib/components/Awareness.svelte' import type DiffDrawer from '$lib/components/DiffDrawer.svelte' - import Summary from '$lib/components/Summary.svelte' + import EditorHeader from '$lib/components/EditorHeader.svelte' + import { goto } from '$app/navigation' import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte' import AppJobsDrawer from '../apps/editor/AppJobsDrawer.svelte' @@ -130,7 +133,13 @@ onOpenYamlEditor = undefined }: Props = $props() - let newEditedPath = $state('') + let newEditedPath = $state( + untrack(() => + newApp + ? userPathPrefix($userStore?.username) + random_adj() + '_app' + : newPath || appPath || '' + ) + ) let deployedValue: Value | undefined = $state(undefined) // Value to diff against let deployedBy: string | undefined = $state(undefined) // Author @@ -332,6 +341,7 @@ css } }) + invalidatePicker($workspaceStore!, 'app') savedApp = { summary: summary, value: structuredClone(stateSnapshot(app)), @@ -562,7 +572,24 @@ } } - let moreItems = [ + const mod = isMac() ? '⌘' : 'Ctrl+' + + let moreItems = $derived([ + { + displayName: 'Undo', + icon: Undo, + action: () => onUndo?.(), + disabled: !canUndo, + shortcut: `${mod}Z` + }, + { + displayName: 'Redo', + icon: Redo, + action: () => onRedo?.(), + disabled: !canRedo, + shortcut: `${mod}⇧Z`, + separatorBottom: true + }, { displayName: 'Deployment history', icon: History, @@ -617,7 +644,7 @@ }, disabled: !savedApp } - ] + ]) const dispatch = createEventDispatcher() @@ -824,52 +851,20 @@ />
- -
- onUndo?.()} - on:redo={() => onRedo?.()} + goto(editPathFor(item))} /> +
-
- {#if newPath || newEditedPath} -
-
- -
- { - currentTarget.select() - }} - /> -
- {/if} -
{#if $enterpriseLicense && appPath != ''} {/if} diff --git a/frontend/src/lib/components/script_builder.ts b/frontend/src/lib/components/script_builder.ts index 24d8e69d32..378e562e98 100644 --- a/frontend/src/lib/components/script_builder.ts +++ b/frontend/src/lib/components/script_builder.ts @@ -5,6 +5,7 @@ import type { DiffDrawerI } from './diff_drawer' import type { ScriptBuilderFunctionExports } from './scriptBuilder' import type { ScheduleTrigger } from './triggers' import type { NewScriptWithDraftAndDraftTriggers, Trigger } from './triggers/utils' +import type { WorkspaceItem } from './workspacePicker' export interface ScriptBuilderProps { script: NewScript & { @@ -44,4 +45,5 @@ export interface ScriptBuilderProps { onSaveDraft?: (e: { path: string; savedAtNewPath: boolean; script: NewScript }) => void onSeeDetails?: (e: { path: string }) => void onSaveDraftError?: (e: { path: string; error: any }) => void + onNavigate?: (item: WorkspaceItem) => void } diff --git a/frontend/src/lib/components/workspacePicker.ts b/frontend/src/lib/components/workspacePicker.ts new file mode 100644 index 0000000000..ba2985ecbb --- /dev/null +++ b/frontend/src/lib/components/workspacePicker.ts @@ -0,0 +1,151 @@ +import type { Flow, ListableApp, Script } from '$lib/gen' + +export type WorkspaceItemKind = 'flow' | 'script' | 'app' + +export type WorkspaceItem = { + path: string + summary: string + kind: WorkspaceItemKind + raw_app?: boolean +} + +/** Display label for kinds — used in the picker rows and search section headers. */ +export const KIND_LABEL: Record = { + flow: 'Flows', + script: 'Scripts', + app: 'Apps' +} + +/** Lowercase variant — matches the editor breadcrumb style. */ +export const KIND_LABEL_LOWER: Record = { + flow: 'flows', + script: 'scripts', + app: 'apps' +} + +/** Composite keys used for keyboard nav and `initialHighlight`. The picker and + * its callers must agree on these — keep them all here. `'all'` is a virtual + * kind for the cross-kind root view; only valid for `kindKey` / `dirKey` + * (leaves always belong to a real kind). */ +export const kindKey = (k: WorkspaceItemKind | 'all') => `kind:${k}` +export const dirKey = (k: WorkspaceItemKind | 'all', fullPath: string) => `dir:${k}:${fullPath}` +export const leafKeyFor = (k: WorkspaceItemKind, path: string) => `leaf:${k}:${path}` + +export function editPathFor(item: WorkspaceItem): string { + if (item.kind === 'flow') return `/flows/edit/${item.path}` + if (item.kind === 'script') return `/scripts/edit/${item.path}` + return item.raw_app ? `/apps_raw/edit/${item.path}` : `/apps/edit/${item.path}` +} + +type WorkspaceCache = { + flow?: WorkspaceItem[] + script?: WorkspaceItem[] + app?: WorkspaceItem[] +} + +/** Module-level session cache. Persists across picker mounts within a single + * page session. NOT invalidated automatically — call `invalidate()` after + * creating/deleting an item if the picker may be opened again before a full + * reload. */ +const cache = new Map() +const inflight = new Map>() +/** Bumped by `invalidate()`. Each in-flight `loadKind` captures the version + * at start and only writes back to the cache if it still matches — so a + * deploy mid-fetch can't have its stale predecessor repopulate the cache. */ +const cacheVersion = new Map() + +const cacheKey = (workspace: string, kind: WorkspaceItemKind) => `${workspace}:${kind}` + +const KINDS: WorkspaceItemKind[] = ['flow', 'script', 'app'] + +function bumpVersion(workspace: string, kind: WorkspaceItemKind) { + const k = cacheKey(workspace, kind) + cacheVersion.set(k, (cacheVersion.get(k) ?? 0) + 1) +} + +export function getCachedItems( + workspace: string, + kind: WorkspaceItemKind +): WorkspaceItem[] | undefined { + return cache.get(workspace)?.[kind] +} + +/** Drop a workspace+kind (or a whole workspace) from the cache so the next + * picker open re-fetches. Use after creating/deleting items. Also bumps the + * version so any in-flight `loadKind` started before the invalidate won't + * write its (now-stale) result back to the cache. */ +export function invalidate(workspace: string, kind?: WorkspaceItemKind) { + if (!kind) { + cache.delete(workspace) + for (const k of KINDS) bumpVersion(workspace, k) + return + } + const bucket = cache.get(workspace) + if (bucket) delete bucket[kind] + bumpVersion(workspace, kind) +} + +export async function loadKind( + workspace: string, + kind: WorkspaceItemKind +): Promise { + const existing = cache.get(workspace)?.[kind] + if (existing) return existing + const key = cacheKey(workspace, kind) + const flying = inflight.get(key) + if (flying) return flying + + const startVersion = cacheVersion.get(key) ?? 0 + const promise = (async () => { + const { ScriptService, FlowService, AppService } = await import('$lib/gen') + let items: WorkspaceItem[] + if (kind === 'flow') { + const flows = await FlowService.listFlows({ + workspace, + includeDraftOnly: true, + withoutDescription: true + }) + items = flows.map((f: Flow) => ({ + path: f.path, + summary: f.summary ?? '', + kind: 'flow' as const + })) + } else if (kind === 'script') { + const scripts = await ScriptService.listScripts({ + workspace, + includeDraftOnly: true, + withoutDescription: true + }) + items = scripts.map((s: Script) => ({ + path: s.path, + summary: s.summary ?? '', + kind: 'script' as const + })) + } else { + const apps = await AppService.listApps({ + workspace, + includeDraftOnly: true + }) + items = apps.map((a: ListableApp) => ({ + path: a.path, + summary: a.summary ?? '', + kind: 'app' as const, + raw_app: a.raw_app ?? false + })) + } + // Only commit if the cache version hasn't changed since we started — + // otherwise we'd overwrite a deliberate `invalidate()` with stale data. + if ((cacheVersion.get(key) ?? 0) === startVersion) { + const bucket = cache.get(workspace) ?? {} + bucket[kind] = items + cache.set(workspace, bucket) + } + return items + })() + inflight.set(key, promise) + try { + return await promise + } finally { + inflight.delete(key) + } +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 0911fe44e1..20d1e496ff 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1022,6 +1022,16 @@ export function generateRandomString(len: number = 24): string { return result } +/** `u//` — the default scope prefix for items owned by the + * current user. The username is normalized so emails and special characters + * don't leak into paths. */ +export function userPathPrefix(username: string | undefined): string { + const u = username?.includes('@') + ? username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '') + : (username ?? '') + return `u/${u}/` +} + export function deepMergeWithPriority(target: T, source: T): T { if (typeof target !== 'object' || typeof source !== 'object') { return source diff --git a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte index 471a3c1643..d22875f9b6 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -33,7 +33,6 @@ } | undefined = $state(undefined) let redraw = $state(0) - let path = page.params.path ?? '' let nodraft = page.url.searchParams.get('nodraft') @@ -48,11 +47,17 @@ let stateLoadedFromLocalStorage = initialState != undefined ? decodeState(initialState) : undefined + /** Increments per `loadApp` call. Stale loads (e.g. when picker + * navigation races a draft-discard reload) bail at the next checkpoint + * after their captured token no longer matches. */ + let loadAppToken = 0 async function loadApp(): Promise { + const tok = ++loadAppToken const app_w_draft = await AppService.getAppByPathWithDraft({ - path, + path: page.params.path ?? '', workspace: $workspaceStore! }) + if (tok !== loadAppToken) return const app_w_draft_: AppWithLastVersionWDraft = structuredClone(stateSnapshot(app_w_draft)) savedApp = { summary: app_w_draft_.summary, @@ -160,8 +165,16 @@ } $effect(() => { + // Re-run on workspace OR path change so navigating from one app editor + // to another (e.g. via the workspace picker) reloads the new app. + const newPath = page.params.path if ($workspaceStore) { untrack(() => { + // Clear the app so AppEditor unmounts; it will remount once loadApp + // completes with fresh data, re-initializing its internal stores. + app = undefined + const s = nodraft ? undefined : localStorage.getItem(`app-${newPath}`) + stateLoadedFromLocalStorage = s != undefined ? decodeState(s) : undefined loadApp() }) } diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index b22d1884be..3f9d08b224 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -37,7 +37,6 @@ } | undefined = $state(undefined) let redraw = $state(0) - let path = page.params.path ?? '' let nodraft = page.url.searchParams.get('nodraft') @@ -78,11 +77,17 @@ let stateLoadedFromLocalStorage = initialState != undefined ? decodeState(initialState) : undefined + /** Increments per `loadApp` call. Stale loads (e.g. when picker + * navigation races a draft-discard reload) bail at the next checkpoint + * after their captured token no longer matches. */ + let loadAppToken = 0 async function loadApp(): Promise { + const tok = ++loadAppToken const app_w_draft = await AppService.getAppByPathWithDraft({ - path, + path: page.params.path ?? '', workspace: $workspaceStore! }) + if (tok !== loadAppToken) return const app_w_draft_ = structuredClone(stateSnapshot(app_w_draft)) savedApp = { summary: app_w_draft_.summary, @@ -128,9 +133,7 @@ } sendUserToast('App restored from browser storage', false, actions) app_w_draft.value = stateLoadedFromLocalStorage - const rawValue = app_w_draft.value as any - files = rawValue.files as any - runnables = rawValue.runnables as any + extractRawApp(app_w_draft) redraw += 1 } else if (app_w_draft.draft) { extractRawApp(app_w_draft.draft) @@ -170,7 +173,15 @@ } run(() => { + // Re-run on workspace OR path change so navigating from one raw app editor + // to another (e.g. via the workspace picker) reloads the new app. + const newPath = page.params.path if ($workspaceStore) { + // Clear files so RawAppEditor unmounts; it will remount when loadApp + // completes with fresh data, re-initializing its internal stores. + files = undefined + const s = nodraft ? undefined : localStorage.getItem(`rawapp-${newPath}`) + stateLoadedFromLocalStorage = s != undefined ? decodeState(s) : undefined loadApp() } }) diff --git a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte index 1b8503163e..0fc9c0219d 100644 --- a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte @@ -4,6 +4,7 @@ import { page } from '$app/state' import FlowBuilder from '$lib/components/FlowBuilder.svelte' + import { editPathFor, invalidate } from '$lib/components/workspacePicker' import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' import { importFlowStore, initFlow } from '$lib/components/flows/flowStore.svelte' import { FlowService, type Flow } from '$lib/gen' @@ -179,7 +180,7 @@ await tick() let attempts = 0 while (attempts < 20 && !document.querySelector('#flow-editor-virtual-Input')) { - await new Promise(resolve => setTimeout(resolve, 100)) + await new Promise((resolve) => setTimeout(resolve, 100)) attempts++ } flowBuilder?.triggerTutorial() @@ -193,14 +194,17 @@ { + if ($workspaceStore) invalidate($workspaceStore, 'flow') goto(`/flows/edit/${e.path}?selected=${e.id}`) }} onDeploy={(e) => { + if ($workspaceStore) invalidate($workspaceStore, 'flow') goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) }} onDetails={(e) => { goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) }} + onNavigate={(item) => goto(editPathFor(item))} {initialPath} {pathStoreInit} bind:this={flowBuilder} diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index 7bc082b29d..a0b21703f4 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -2,6 +2,7 @@ import { FlowService, type Flow, DraftService } from '$lib/gen' import FlowBuilder from '$lib/components/FlowBuilder.svelte' + import { editPathFor, invalidate } from '$lib/components/workspacePicker' import { initialArgsStore, workspaceStore } from '$lib/stores' import { cleanValueProperties, @@ -24,10 +25,11 @@ import { page } from '$app/state' let version: undefined | number = $state(undefined) - let nodraft = page.url.searchParams.get('nodraft') - const initialState = nodraft ? undefined : localStorage.getItem(`flow-${page.params.path}`) - let stateLoadedFromUrl = initialState != undefined ? decodeState(initialState) : undefined + // `initialArgs` is captured once at mount — it's the session's initial + // argument set. Per-flow autosave (`stateLoadedFromUrl`) and the + // `nodraft` flag are re-read inside `loadFlow` / the navigation hook so + // picker navigation doesn't reuse the original path's state. const urlArgs = page.url.searchParams.get('initial_args') let initialArgs = $state({}) @@ -45,7 +47,7 @@ | undefined = $state(undefined) afterNavigate(() => { - if (nodraft) { + if (page.url.searchParams.get('nodraft')) { let url = new URL(page.url.href) url.search = '' replaceState(url.toString(), page.state) @@ -72,9 +74,15 @@ let nobackenddraft = false - let savedPrimarySchedule: ScheduleTrigger | undefined = $state( - stateLoadedFromUrl?.primarySchedule - ) + // One-shot read of mount-time autosave, used only to seed the initial + // `savedPrimarySchedule` before `loadFlow` runs. `loadFlow` itself + // re-reads localStorage on every invocation (see comment there). + const initialAutosave = (() => { + if (page.url.searchParams.get('nodraft')) return undefined + const raw = localStorage.getItem(`flow-${page.params.path}`) + return raw != undefined ? decodeState(raw) : undefined + })() + let savedPrimarySchedule: ScheduleTrigger | undefined = $state(initialAutosave?.primarySchedule) let draftTriggersFromUrl: Trigger[] | undefined = $state(undefined) let selectedTriggerIndexFromUrl: number | undefined = $state(undefined) @@ -84,20 +92,36 @@ let flowBuilder: FlowBuilder | undefined = $state(undefined) let notFound = $state(false) + /** Increments per `loadFlow` call. Each in-flight load checks its captured + * token against this before writing shared state — if a newer load started + * (e.g. picker navigation while a draft-discard reload is in flight), + * the older promise no-ops at the next checkpoint. */ + let loadFlowToken = 0 async function loadFlow(): Promise { - console.log('loadFlow') + const tok = ++loadFlowToken loading = true + + // Re-read autosave per load. The component doesn't remount when the + // picker navigates between flows, so capturing at module init would + // keep reusing the original flow's state. + const stored = page.url.searchParams.get('nodraft') + ? undefined + : localStorage.getItem(`flow-${page.params.path}`) + const stateLoadedFromUrl = stored != undefined ? decodeState(stored) : undefined + let flow: Flow let statePath = stateLoadedFromUrl?.path if (stateLoadedFromUrl != undefined && statePath == page.params.path) { // Currently there is no way to get version of flow with flow. // So we have to request it here - version = ( + const v = ( await FlowService.getFlowLatestVersion({ workspace: $workspaceStore!, path: statePath }) )?.id + if (tok !== loadFlowToken) return + version = v if (version == undefined) { notFound = true @@ -105,10 +129,12 @@ return } - savedFlow = await FlowService.getFlowByPathWithDraft({ + const sf = await FlowService.getFlowByPathWithDraft({ workspace: $workspaceStore!, path: statePath }) + if (tok !== loadFlowToken) return + savedFlow = sf const draftOrDeployed = cleanValueProperties(savedFlow?.draft || savedFlow) const urlScript = cleanValueProperties( @@ -126,7 +152,16 @@ flowBuilder?.setLoadedFromHistory(loadedFromHistoryFromUrl) const selectedId = stateLoadedFromUrl?.selectedId ?? 'settings-metadata' const reloadAction = () => { - stateLoadedFromUrl = undefined + // Discard the localStorage autosave so the next `loadFlow` + // (re-)read sees an empty slot and falls through to the + // fetch branch — otherwise we'd re-enter this branch and + // loop. Scripts dodge this because their state lives in + // the URL fragment, which `goto` clears for us. + try { + localStorage.removeItem(`flow-${statePath}`) + } catch (e) { + console.error('error interacting with local storage', e) + } goto(`/flows/edit/${statePath}?selected=${selectedId}`) loadFlow() } @@ -156,17 +191,20 @@ } else { // Currently there is no way to get version of flow with flow. // So we have to request it here - version = ( + const v = ( await FlowService.getFlowLatestVersion({ workspace: $workspaceStore!, path: page.params.path ?? '' }) ).id + if (tok !== loadFlowToken) return + version = v const flowWithDraft = await FlowService.getFlowByPathWithDraft({ workspace: $workspaceStore!, path: page.params.path ?? '' }) + if (tok !== loadFlowToken) return savedFlow = { ...structuredClone($state.snapshot(flowWithDraft)), draft: flowWithDraft.draft @@ -190,7 +228,6 @@ const deployed = cleanValueProperties(flowWithDraft) const draft = cleanValueProperties(flow) const reloadAction = async () => { - stateLoadedFromUrl = undefined await DraftService.deleteDraft({ workspace: $workspaceStore!, kind: 'flow', @@ -226,12 +263,16 @@ } await initFlow(flow, flowStore, flowStateStore) + if (tok !== loadFlowToken) return loading = false selectedId = stateLoadedFromUrl?.selectedId ?? page.url.searchParams.get('selected') flowBuilder?.loadFlowState() } $effect(() => { + // Re-run on workspace OR path change so navigating from one flow editor + // to another (e.g. via the workspace picker) reloads the new flow. + page.params.path if ($workspaceStore) { untrack(() => loadFlow()) } @@ -245,7 +286,6 @@ return } diffDrawer?.closeDrawer() - stateLoadedFromUrl = undefined goto(`/flows/edit/${savedFlow.draft.path}`) loadFlow() } @@ -263,7 +303,6 @@ path: savedFlow.path }) } - stateLoadedFromUrl = undefined goto(`/flows/edit/${savedFlow.path}`) loadFlow() } @@ -280,6 +319,7 @@ {:else} { + if ($workspaceStore) invalidate($workspaceStore, 'flow') goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) }} onDetails={(e) => { @@ -291,6 +331,7 @@ onHistoryRestore={() => { loadFlow() }} + onNavigate={(item) => goto(editPathFor(item))} {flowStore} {flowStateStore} initialPath={page.params.path ?? ''} diff --git a/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte index 8f43dde796..ad2452dce1 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte @@ -4,6 +4,7 @@ import { page } from '$app/state' import { defaultScripts, initialArgsStore, workspaceStore } from '$lib/stores' import ScriptBuilder from '$lib/components/ScriptBuilder.svelte' + import { editPathFor, invalidate } from '$lib/components/workspacePicker' import type { Schema } from '$lib/common' import { decodeState, emptySchema, emptyString, sendUserToast } from '$lib/utils' import { goto } from '$lib/navigation' @@ -63,9 +64,12 @@ schema: schema, is_template: false, extra_perms: {}, - language: (wacParam === 'python' ? 'python3' : wacParam === 'typescript' ? 'bun' : null) ?? collabLang ?? ($defaultScripts?.order?.filter( - (x) => $defaultScripts?.hidden == undefined || !$defaultScripts.hidden.includes(x) - )?.[0] ?? 'bun') as ScriptLang, + language: + (wacParam === 'python' ? 'python3' : wacParam === 'typescript' ? 'bun' : null) ?? + collabLang ?? + (($defaultScripts?.order?.filter( + (x) => $defaultScripts?.hidden == undefined || !$defaultScripts.hidden.includes(x) + )?.[0] ?? 'bun') as ScriptLang), kind: 'script' } } @@ -139,8 +143,7 @@ extra_perms: {} } if (isWac) { - importedWacTemplate = - imported.language === 'python3' ? 'wac_python' : 'wac_typescript' + importedWacTemplate = imported.language === 'python3' ? 'wac_python' : 'wac_typescript' sendUserToast('WAC script loaded from YAML/JSON') } else { sendUserToast('Script loaded from YAML/JSON') @@ -159,13 +162,21 @@ {initialArgs} bind:this={scriptBuilder} lockedLanguage={templatePath != null || hubPath != null} - template={importedWacTemplate ?? (wacParam === 'python' ? 'wac_python' : wacParam === 'typescript' ? 'wac_typescript' : 'script')} + template={importedWacTemplate ?? + (wacParam === 'python' + ? 'wac_python' + : wacParam === 'typescript' + ? 'wac_typescript' + : 'script')} onDeploy={(e) => { + if ($workspaceStore) invalidate($workspaceStore, 'script') goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`) }} onSaveInitial={(e) => { + if ($workspaceStore) invalidate($workspaceStore, 'script') goto(`/scripts/edit/${e.path}`) }} + onNavigate={(item) => goto(editPathFor(item))} searchParams={page.url.searchParams} bind:script {showMeta} diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index 8afaa2ce61..f3002daedc 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -3,6 +3,7 @@ import { initialArgsStore, workspaceStore } from '$lib/stores' import ScriptBuilder from '$lib/components/ScriptBuilder.svelte' + import { editPathFor, invalidate } from '$lib/components/workspacePicker' import { decodeState, cleanValueProperties, orderedJsonStringify } from '$lib/utils' import { goto } from '$lib/navigation' import { replaceState } from '$app/navigation' @@ -15,16 +16,13 @@ import { untrack } from 'svelte' import { page } from '$app/state' - let initialState = window.location.hash != '' ? window.location.hash.slice(1) : undefined + // `initialArgs` is intentionally captured once at mount — it's the + // session's initial argument set, not per-script. URL-derived state + // (`hash`, `topHash`, fragment autosave) is re-read inside `loadScript` + // because picker navigation reuses this component without remounting. let initialArgs = get(initialArgsStore) ?? {} if (get(initialArgsStore)) $initialArgsStore = undefined - let topHash = page.url.searchParams.get('topHash') ?? undefined - - let hash = page.url.searchParams.get('hash') ?? undefined - - let scriptLoadedFromUrl = initialState != undefined ? decodeState(initialState) : undefined - let script: (NewScript & { draft_triggers?: Trigger[] }) | undefined = $state(undefined) let initialPath: string = $state('') @@ -38,21 +36,36 @@ let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined) + /** Increments per `loadScript` call. Stale loads (e.g. when picker + * navigation races a draft-discard reload) bail at the next checkpoint + * after their captured token no longer matches. */ + let loadScriptToken = 0 async function loadScript(): Promise { + const tok = ++loadScriptToken fullyLoaded = false + + // Re-read URL-derived state on every load. The component doesn't + // remount when the picker navigates between scripts, so capturing + // these at module init would leave them stale. + const urlFragment = window.location.hash != '' ? window.location.hash.slice(1) : undefined + const scriptLoadedFromUrl = urlFragment != undefined ? decodeState(urlFragment) : undefined + const hash = page.url.searchParams.get('hash') ?? undefined + const topHash = page.url.searchParams.get('topHash') ?? undefined + if (scriptLoadedFromUrl != undefined && scriptLoadedFromUrl.path == page.params.path) { script = scriptLoadedFromUrl reloadAction = async () => { - scriptLoadedFromUrl = undefined goto(`/scripts/edit/${script!.path}`) loadScript() } async function compareAutosave() { - savedScript = await ScriptService.getScriptByPathWithDraft({ + const sf = await ScriptService.getScriptByPathWithDraft({ workspace: $workspaceStore!, path: script!.path }) + if (tok !== loadScriptToken) return + savedScript = sf const draftOrDeployed = cleanValueProperties(savedScript?.draft || savedScript) const urlScript = cleanValueProperties(scriptLoadedFromUrl) @@ -87,6 +100,7 @@ workspace: $workspaceStore!, hash }) + if (tok !== loadScriptToken) return savedScript = structuredClone($state.snapshot(scriptByHash)) as NewScriptWithDraft script = { ...scriptByHash, parent_hash: hash, lock: undefined } } else { @@ -94,6 +108,7 @@ workspace: $workspaceStore!, path: page.params.path ?? '' }) + if (tok !== loadScriptToken) return savedScript = structuredClone($state.snapshot(scriptWithDraft)) if (scriptWithDraft.draft != undefined) { script = scriptWithDraft.draft @@ -105,7 +120,6 @@ if (!scriptWithDraft.draft_only) { reloadAction = async () => { - scriptLoadedFromUrl = undefined await DraftService.deleteDraft({ workspace: $workspaceStore!, kind: 'script', @@ -164,6 +178,9 @@ } $effect(() => { + // Re-run on workspace OR path change so navigating from one script editor + // to another (e.g. via the workspace picker) reloads the new script. + page.params.path if ($workspaceStore) { untrack(() => loadScript()) } @@ -178,7 +195,6 @@ } diffDrawer?.closeDrawer() goto(`/scripts/edit/${savedScript.draft.path}`) - scriptLoadedFromUrl = undefined loadScript() } @@ -196,7 +212,6 @@ }) } goto(`/scripts/edit/${savedScript.path}`) - scriptLoadedFromUrl = undefined loadScript() } @@ -214,14 +229,17 @@ {savedPrimarySchedule} searchParams={page.url.searchParams} onDeploy={(e) => { + if ($workspaceStore) invalidate($workspaceStore, 'script') goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`) }} onSaveInitial={(e) => { + if ($workspaceStore) invalidate($workspaceStore, 'script') goto(`/scripts/edit/${e.path}`) }} onSeeDetails={(e) => { goto(`/scripts/get/${e.path}?workspace=${$workspaceStore}`) }} + onNavigate={(item) => goto(editPathFor(item))} replaceStateFn={(path) => { replaceState(path, page.state) }} From 7ebb08133cd4027bc00bacc4a0fc5865cd5709ec Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 13 May 2026 06:38:57 +0000 Subject: [PATCH 102/313] fix(operator): refresh IAM RDS / Entra ID tokens in operator process (#9141) * fix(operator): refresh IAM RDS / Entra ID tokens in operator process * fix: gate DEFAULT_MAX_CONNECTIONS_OPERATOR on operator feature --- backend/src/db_connect.rs | 135 +++++++++++++++++++++++--------------- backend/src/main.rs | 19 +++++- 2 files changed, 99 insertions(+), 55 deletions(-) diff --git a/backend/src/db_connect.rs b/backend/src/db_connect.rs index 3860c18909..deda032058 100644 --- a/backend/src/db_connect.rs +++ b/backend/src/db_connect.rs @@ -6,6 +6,8 @@ use windmill_common::{ pub const DEFAULT_MAX_CONNECTIONS_SERVER: u32 = 50; pub const DEFAULT_MAX_CONNECTIONS_WORKER: u32 = 5; pub const DEFAULT_MAX_CONNECTIONS_INDEXER: u32 = 5; +#[cfg(feature = "operator")] +pub const DEFAULT_MAX_CONNECTIONS_OPERATOR: u32 = 2; pub async fn initial_connection() -> Result, error::Error> { let connect_options = get_database_url().await?.connect_options().await?; @@ -16,12 +18,35 @@ pub async fn initial_connection() -> Result, error::E .map_err(|err| Error::ConnectingToDatabase(err.to_string())) } +/// Connect to the database for the Kubernetes operator process. +/// +/// Long-running operator pods need IAM RDS / Entra ID token refresh just like the server, +/// otherwise new pool connections start failing once the initial token expires (~15 min). +#[cfg(feature = "operator")] +pub async fn operator_connection( + #[cfg(all(feature = "enterprise", feature = "private"))] + killpill_rx: tokio::sync::broadcast::Receiver<()>, +) -> anyhow::Result> { + let database_url = get_database_url().await?; + let pool = connect( + database_url.clone(), + DEFAULT_MAX_CONNECTIONS_OPERATOR, + false, + ) + .await?; + + #[cfg(all(feature = "enterprise", feature = "private"))] + spawn_token_refresh_task(pool.clone(), database_url, killpill_rx); + + Ok(pool) +} + pub async fn connect_db( server_mode: bool, indexer_mode: bool, worker_mode: bool, num_workers: i32, - #[cfg(feature = "private")] mut killpill_rx: tokio::sync::broadcast::Receiver<()>, + #[cfg(feature = "private")] killpill_rx: tokio::sync::broadcast::Receiver<()>, ) -> anyhow::Result> { use anyhow::Context; @@ -43,70 +68,72 @@ pub async fn connect_db( let pool = connect(database_url.clone(), max_connections, worker_mode).await?; #[cfg(all(feature = "enterprise", feature = "private"))] - { - let needs_token_refresh = matches!( - database_url, - DatabaseUrl::IamRds(_) | DatabaseUrl::EntraId(_) - ); - let label = match &database_url { - DatabaseUrl::IamRds(_) => "IAM RDS", - DatabaseUrl::EntraId(_) => "Entra ID", - DatabaseUrl::Static(_) => "", - }; - if needs_token_refresh { - let pool2 = pool.clone(); - let database_url2 = database_url.clone(); - tokio::spawn(async move { - loop { - tokio::select! { - _ = killpill_rx.recv() => { - break; - } - _ = tokio::time::sleep(std::time::Duration::from_secs(10)) => { - if !database_url2.needs_refresh().await { - continue; - } - let new_url = tokio::time::timeout( - std::time::Duration::from_secs(10), - get_database_url(), - ) - .await; - match new_url { - Ok(Ok(new_url)) => { - match new_url.connect_options().await { - Ok(connect_options) => { - pool2.set_connect_options(connect_options); - tracing::info!("Refreshed {label} URL successfully"); - } - Err(e) => { - tracing::error!( - "Error getting {label} connect options, retrying in 10s: {e}" - ); - continue; - } - } - } - Ok(Err(e)) => { - tracing::error!( - "Error refreshing {label} URL, trying again in 10s: {e}" - ); - continue; + spawn_token_refresh_task(pool.clone(), database_url, killpill_rx); + + Ok(pool) +} + +/// Spawn a background task that refreshes IAM RDS / Entra ID tokens before they expire +/// and updates the pool's connect options so new connections use the fresh token. +/// No-op for static (password-based) database URLs. +#[cfg(all(feature = "enterprise", feature = "private"))] +pub fn spawn_token_refresh_task( + pool: sqlx::Pool, + database_url: DatabaseUrl, + mut killpill_rx: tokio::sync::broadcast::Receiver<()>, +) { + let label = match &database_url { + DatabaseUrl::IamRds(_) => "IAM RDS", + DatabaseUrl::EntraId(_) => "Entra ID", + DatabaseUrl::Static(_) => return, + }; + tokio::spawn(async move { + loop { + tokio::select! { + _ = killpill_rx.recv() => { + break; + } + _ = tokio::time::sleep(std::time::Duration::from_secs(10)) => { + if !database_url.needs_refresh().await { + continue; + } + let new_url = tokio::time::timeout( + std::time::Duration::from_secs(10), + get_database_url(), + ) + .await; + match new_url { + Ok(Ok(new_url)) => { + match new_url.connect_options().await { + Ok(connect_options) => { + pool.set_connect_options(connect_options); + tracing::info!("Refreshed {label} URL successfully"); } Err(e) => { tracing::error!( - "Timeout after 10s refreshing {label} URL, trying again in 10s: {e}" + "Error getting {label} connect options, retrying in 10s: {e}" ); continue; } } } + Ok(Err(e)) => { + tracing::error!( + "Error refreshing {label} URL, trying again in 10s: {e}" + ); + continue; + } + Err(e) => { + tracing::error!( + "Timeout after 10s refreshing {label} URL, trying again in 10s: {e}" + ); + continue; + } } } - }); + } } - } - - Ok(pool) + }); } pub async fn connect( diff --git a/backend/src/main.rs b/backend/src/main.rs index 9a2709e9a9..74e2c240ba 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -670,7 +670,24 @@ async fn windmill_main() -> anyhow::Result<()> { tracing_subscriber::fmt::init(); tracing::info!("Starting Windmill Kubernetes operator..."); tracing::info!("Connecting to database..."); - let db = crate::db_connect::initial_connection().await?; + + #[cfg(all(feature = "enterprise", feature = "private"))] + let (operator_killpill_tx, operator_killpill_rx) = + tokio::sync::broadcast::channel::<()>(2); + + let db = crate::db_connect::operator_connection( + #[cfg(all(feature = "enterprise", feature = "private"))] + operator_killpill_rx, + ) + .await?; + + #[cfg(all(feature = "enterprise", feature = "private"))] + tokio::spawn(async move { + if let Ok(()) = tokio::signal::ctrl_c().await { + let _ = operator_killpill_tx.send(()); + } + }); + tracing::info!("Database connected. Starting ConfigMap watcher..."); windmill_operator::run(db).await?; return Ok(()); From 79c5b7b8b7676b0a06fa6480dd04b7105d39d250 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 13 May 2026 07:09:45 +0000 Subject: [PATCH 103/313] fix(cli): prevent !inline-corruption in flow push/pull (#9142) * fix(cli): hard-fail flow push on missing inline files; guard extractor * fix(cli): gate !inline extractor guard with opt-in flag * test(cli): fix createFlowFixture !inline path to be relative to flow folder --- cli/src/commands/flow/flow.ts | 12 ++-- cli/src/commands/flow/flow_metadata.ts | 30 +++++++-- cli/src/commands/sync/sync.ts | 6 +- ..._scripts_failure_preprocessor_unit.test.ts | 65 +++++++++++++++++++ cli/test/sync_pull_push.test.ts | 8 ++- .../src/inline-scripts/extractor.ts | 47 +++++++++++--- 6 files changed, 145 insertions(+), 23 deletions(-) diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index efd05c376a..b76b890053 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -174,10 +174,14 @@ export async function pushFlow( await replaceInlineScripts([localFlow.value.preprocessor_module], fileReader, log, localPath, SEP, undefined, missingFiles); } if (missingFiles.length > 0) { - log.warn(colors.yellow( - `Warning: missing inline script file(s): ${missingFiles.join(", ")}. ` + - `The flow will be pushed with unresolved !inline references.` - )); + // Hard-fail rather than push the literal `!inline path` text as + // rawscript.content. That string would be persisted in flow_version.value + // and round-trip as the script body on the next pull, overwriting the + // user's local handler with the directive — see GIT-871 / #9140. + throw new Error( + `Cannot push flow ${remotePath}: missing inline script file(s): ${missingFiles.join(", ")}. ` + + `Either restore the file(s) or remove the !inline reference(s) from flow.yaml before pushing.` + ); } const hasOnBehalfOf = (localFlow as any).has_on_behalf_of ?? !!localFlow.on_behalf_of_email; diff --git a/cli/src/commands/flow/flow_metadata.ts b/cli/src/commands/flow/flow_metadata.ts index f33ef07bcc..5c3ca578c7 100644 --- a/cli/src/commands/flow/flow_metadata.ts +++ b/cli/src/commands/flow/flow_metadata.ts @@ -266,19 +266,32 @@ export async function generateFlowLockInternal( return tree.isStale(treePath); }) : changedScripts; + const missingFiles: string[] = []; await replaceInlineScripts( flowValue.value.modules, fileReader, log, folder + SEP!, SEP, - locksToRemove + locksToRemove, + missingFiles ); if (flowValue.value.failure_module) { - await replaceInlineScripts([flowValue.value.failure_module], fileReader, log, folder + SEP!, SEP, locksToRemove); + await replaceInlineScripts([flowValue.value.failure_module], fileReader, log, folder + SEP!, SEP, locksToRemove, missingFiles); } if (flowValue.value.preprocessor_module) { - await replaceInlineScripts([flowValue.value.preprocessor_module], fileReader, log, folder + SEP!, SEP, locksToRemove); + await replaceInlineScripts([flowValue.value.preprocessor_module], fileReader, log, folder + SEP!, SEP, locksToRemove, missingFiles); + } + if (missingFiles.length > 0) { + // Abort before updateFlow rather than push the literal `!inline path` + // string as rawscript.content (GIT-871 / #9140). Note: at this point + // replaceInlineScripts has already mutated `flowValue.value` in place + // for the modules that *did* resolve. All current callers re-throw on + // this error; do not catch and reuse `flowValue` without re-parsing. + throw new Error( + `Cannot regenerate lock for flow ${remote_path}: missing inline script file(s): ${missingFiles.join(", ")}. ` + + `Either restore the file(s) or remove the !inline reference(s) from flow.yaml before retrying.` + ); } //removeChangedLocks @@ -304,18 +317,23 @@ export async function generateFlowLockInternal( const lockAssigner = newPathAssigner(opts.defaultTs ?? "bun", { skipInlineScriptSuffix: getNonDottedPaths(), }); + // flowValue.value here is the backend's response from updateFlow, so a + // rawscript whose content is `!inline ...` is corruption (GIT-871) — fail + // fast rather than writing the literal directive back to a script file. + const extractOpts = { skipInlineScriptSuffix: getNonDottedPaths(), failOnInlineDirective: true }; const inlineScripts = extractInlineScriptsForFlows( flowValue.value.modules, currentMapping, SEP, opts.defaultTs, - lockAssigner + lockAssigner, + extractOpts ); if (flowValue.value.failure_module) { - inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.failure_module], currentMapping, SEP, opts.defaultTs, lockAssigner)); + inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.failure_module], currentMapping, SEP, opts.defaultTs, lockAssigner, extractOpts)); } if (flowValue.value.preprocessor_module) { - inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.preprocessor_module], currentMapping, SEP, opts.defaultTs, lockAssigner)); + inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.preprocessor_module], currentMapping, SEP, opts.defaultTs, lockAssigner, extractOpts)); } inlineScripts.forEach((s) => { writeIfChanged(process.cwd() + SEP + folder + SEP + s.path, s.content); diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 6cd3545c5d..215a0d3120 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -950,7 +950,7 @@ function ZipFSElement( SEP, defaultTs, assigner, - { skipInlineScriptSuffix: getNonDottedPaths() }, + { skipInlineScriptSuffix: getNonDottedPaths(), failOnInlineDirective: true }, ); if (flow.value.failure_module) { inlineScripts.push(...extractInlineScriptsForFlows( @@ -959,7 +959,7 @@ function ZipFSElement( SEP, defaultTs, assigner, - { skipInlineScriptSuffix: getNonDottedPaths() }, + { skipInlineScriptSuffix: getNonDottedPaths(), failOnInlineDirective: true }, )); } if (flow.value.preprocessor_module) { @@ -969,7 +969,7 @@ function ZipFSElement( SEP, defaultTs, assigner, - { skipInlineScriptSuffix: getNonDottedPaths() }, + { skipInlineScriptSuffix: getNonDottedPaths(), failOnInlineDirective: true }, )); } } catch (error) { diff --git a/cli/test/inline_scripts_failure_preprocessor_unit.test.ts b/cli/test/inline_scripts_failure_preprocessor_unit.test.ts index 78af37a02e..a5d1298f15 100644 --- a/cli/test/inline_scripts_failure_preprocessor_unit.test.ts +++ b/cli/test/inline_scripts_failure_preprocessor_unit.test.ts @@ -574,3 +574,68 @@ describe("extractInlineScripts with mapping preserves file paths", () => { expect(lockScript!.path).toBe("my.inline_script.lock"); }); }); + +// --------------------------------------------------------------------------- +// failOnInlineDirective option (GIT-871 / #9140) +// --------------------------------------------------------------------------- + +describe("failOnInlineDirective option", () => { + test("default behavior: yaml-parsed module with !inline content extracts without throwing", () => { + // Simulates flow_metadata / dev callers: yaml-parsed local flow whose + // rawscript.content is the literal `!inline foo.ts` directive (the + // legitimate on-disk shape after extraction). + const mod = makeRawscriptModule("a", "!inline a.inline_script.ts", "bun"); + expect(() => + extractInlineScripts([mod], {}, "/", "bun"), + ).not.toThrow(); + }); + + test("yaml-parsed !inline content round-trips as the script's body", () => { + const mod = makeRawscriptModule("a", "!inline a.inline_script.ts", "bun"); + const scripts = extractInlineScripts([mod], {}, "/", "bun"); + const script = scripts.find((s) => !s.is_lock); + expect(script).toBeDefined(); + expect(script!.content).toBe("!inline a.inline_script.ts"); + }); + + test("opt-in: failOnInlineDirective=true throws on !inline content", () => { + // Simulates the sync-pull call site: rawscript came from the backend's + // flow_version.value, so `!inline ...` content means the row is corrupt. + const mod = makeRawscriptModule("failure", "!inline Handle_error.ts", "bun"); + expect(() => + extractInlineScripts([mod], {}, "/", "bun", undefined, { + failOnInlineDirective: true, + }), + ).toThrow(/corrupted inline script/); + }); + + test("opt-in: real script content still extracts cleanly", () => { + const mod = makeRawscriptModule( + "failure", + 'export function main() { return 1; }', + "bun", + ); + expect(() => + extractInlineScripts([mod], {}, "/", "bun", undefined, { + failOnInlineDirective: true, + }), + ).not.toThrow(); + }); + + test("opt-in: throws for nested rawscript inside branchall", () => { + const inner = makeRawscriptModule("inner", "!inline poisoned.ts", "bun"); + const outer: FlowModule = { + id: "branch", + value: { + type: "branchall" as const, + branches: [{ summary: "b1", expr: "true", modules: [inner], skip_failure: false, parallel: false }], + parallel: false, + }, + }; + expect(() => + extractInlineScripts([outer], {}, "/", "bun", undefined, { + failOnInlineDirective: true, + }), + ).toThrow(/corrupted inline script/); + }); +}); diff --git a/cli/test/sync_pull_push.test.ts b/cli/test/sync_pull_push.test.ts index e31c4e1084..4b5b8e5389 100644 --- a/cli/test/sync_pull_push.test.ts +++ b/cli/test/sync_pull_push.test.ts @@ -111,6 +111,10 @@ kind: script function createFlowFixture(name: string): Record { const flowSuffix = getFolderSuffix("flow"); const metadataFile = getMetadataFileName("flow", "yaml"); + // !inline paths are resolved relative to the flow folder (see + // pushFlow's fileReader in cli/src/commands/flow/flow.ts), so the + // path inside the directive must NOT include the flow folder prefix. + const scriptFile = "a.ts"; return { metadata: { @@ -122,7 +126,7 @@ value: - id: a value: type: rawscript - content: "!inline ${name}${flowSuffix}/a.ts" + content: "!inline ${scriptFile}" language: bun input_transforms: {} schema: @@ -133,7 +137,7 @@ schema: `, }, inlineScript: { - path: `${name}${flowSuffix}/a.ts`, + path: `${name}${flowSuffix}/${scriptFile}`, content: `export async function main() {\n return "Hello from flow ${name}";\n}`, }, }; diff --git a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts index e472372a99..f556b32c57 100644 --- a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts +++ b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts @@ -20,13 +20,28 @@ function extractRawscriptInline( rawscript: RawScript, mapping: Record, separator: string, - assigner: PathAssigner + assigner: PathAssigner, + failOnInlineDirective: boolean ): InlineScript[] { const [basePath, ext] = assigner.assignPath(summary ?? id, rawscript.language); const mappedPath = mapping[id]; const path = mappedPath ?? basePath + ext; const language = rawscript.language; const content = rawscript.content; + // Opt-in defensive guard: when extracting from backend-shaped data (i.e. + // sync pull), a rawscript whose content is itself an `!inline ...` directive + // means the backend was poisoned by a prior push that sent the unresolved + // directive as the script body (GIT-871 / #9140). Refuse to write it back + // to disk. Off by default because callers that operate on YAML-parsed local + // flows (flow_metadata, dev) legitimately see `!inline foo.ts` as content. + if (failOnInlineDirective && typeof content === "string" && content.startsWith("!inline ")) { + throw new Error( + `Refusing to extract corrupted inline script for module '${id}': ` + + `rawscript.content is the literal string \`${content.split("\n")[0]}\` ` + + `instead of script source. The backend's flow_version.value is corrupt — ` + + `re-push from a known-good local copy to repair it.` + ); + } const r = [{ path: path, content: content, language, is_lock: false}]; rawscript.content = "!inline " + path.replaceAll(separator, "/"); const lock = rawscript.lock; @@ -50,6 +65,15 @@ function extractRawscriptInline( export interface ExtractInlineScriptsOptions { /** When true, skip the .inline_script. suffix in file names */ skipInlineScriptSuffix?: boolean; + /** + * When true, throw if a `rawscript.content` is itself an `!inline ...` + * directive. Set this only at the sync-pull call site, where the input + * comes from the backend's `flow_version.value` and `!inline ...` content + * means the row is corrupt (GIT-871 / #9140). Leave off for callers that + * pass YAML-parsed local flows — the directive is the legitimate on-disk + * shape there. + */ + failOnInlineDirective?: boolean; } /** @@ -74,6 +98,7 @@ export function extractInlineScripts( ): InlineScript[] { // Create pathAssigner only if not provided (top-level call), but reuse it for nested calls const assigner = pathAssigner ?? newPathAssigner(defaultTs ?? "bun", { skipInlineScriptSuffix: options?.skipInlineScriptSuffix }); + const failOnInlineDirective = options?.failOnInlineDirective ?? false; return modules.flatMap((m) => { if (m.value.type == "rawscript") { @@ -83,7 +108,8 @@ export function extractInlineScripts( m.value, mapping, separator, - assigner + assigner, + failOnInlineDirective ); } else if (m.value.type == "forloopflow") { return extractInlineScripts( @@ -91,11 +117,12 @@ export function extractInlineScripts( mapping, separator, defaultTs, - assigner + assigner, + options ); } else if (m.value.type == "branchall") { return m.value.branches.flatMap((b) => - extractInlineScripts(b.modules, mapping, separator, defaultTs, assigner) + extractInlineScripts(b.modules, mapping, separator, defaultTs, assigner, options) ); } else if (m.value.type == "whileloopflow") { return extractInlineScripts( @@ -103,7 +130,8 @@ export function extractInlineScripts( mapping, separator, defaultTs, - assigner + assigner, + options ); } else if (m.value.type == "branchone") { return [ @@ -113,7 +141,8 @@ export function extractInlineScripts( mapping, separator, defaultTs, - assigner + assigner, + options ) ), ...extractInlineScripts( @@ -121,7 +150,8 @@ export function extractInlineScripts( mapping, separator, defaultTs, - assigner + assigner, + options ), ]; } else if (m.value.type == "aiagent") { @@ -138,7 +168,8 @@ export function extractInlineScripts( toolValue, mapping, separator, - assigner + assigner, + failOnInlineDirective ); }); } else { From b348119ab9c76e3d80102d1e62d4037bdf901594 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 13 May 2026 07:11:24 +0000 Subject: [PATCH 104/313] publish CLI skills + AGENTS.md to windmill-cli-docs for context7 (#9143) * feat: publish CLI skills + AGENTS.md to windmill-cli-docs for context7 Auto-generates a public docs snapshot (AGENTS.md, full CLI reference, all rendered skills) and pushes it to windmill-labs/windmill-cli-docs on every release tag, so context7 can index Windmill CLI docs. - generate.py: new --context7-dir flag rendering fully-resolved skills + AGENTS.md (extracted from cli/src/guidance/core.ts to avoid drift) + cli-commands.md + README.md + manifest.json into a docs-repo checkout. Preserves .git, .github, LICENSE, context7.json across regenerations. - publish-cli-docs.yml: GitHub Action on v* tag and workflow_dispatch that regenerates the docs repo and pushes via the CLI_DOCS_DEPLOY_KEY SSH deploy key. * fix: skip tag mirror on workflow_dispatch from non-tag ref * docs: turn windmill-cli-docs README into a CLI quickstart * fix: address PR review (target safety, regex anchor, concurrency, tag mirror) - Refuse to wipe --context7-dir unless empty, has a context7 marker, or points at the windmill-cli-docs remote (P1, prevents typo blast). - Anchor AGENTS.md template regex on `generateAgentsMdContent` so adding other template-returning functions to core.ts can't silently retarget it. - Decode TS escapes in one pass to avoid order-sensitive mangling. - Include Windmill version (from version.txt) in manifest.json so each snapshot is self-describing. - Add concurrency group on the publish workflow. - Always mirror version tag on tag pushes, even when content is unchanged, so the docs repo has a tag for every Windmill release. - Expand preserve list with .gitignore, .gitattributes, CODEOWNERS. * fix: validate manifest.json content, not just presence, before wipe --- .github/workflows/publish-cli-docs.yml | 84 +++++ system_prompts/README.md | 15 + system_prompts/generate.py | 404 +++++++++++++++++++++++++ 3 files changed, 503 insertions(+) create mode 100644 .github/workflows/publish-cli-docs.yml diff --git a/.github/workflows/publish-cli-docs.yml b/.github/workflows/publish-cli-docs.yml new file mode 100644 index 0000000000..9e76117eb5 --- /dev/null +++ b/.github/workflows/publish-cli-docs.yml @@ -0,0 +1,84 @@ +name: Publish CLI docs repo + +# Regenerates the windmill-cli-docs repo (consumed by context7) from the +# canonical sources in this repo on every Windmill release. +# +# Required secret: +# CLI_DOCS_DEPLOY_KEY — ed25519 private key whose public half is registered +# as a write-access deploy key on +# windmill-labs/windmill-cli-docs. + +on: + push: + tags: + - "v*" + workflow_dispatch: + +# Serialize pushes to windmill-cli-docs so two release tags landing close +# together (e.g. a release-please bump + a hotfix) can't race to force-push +# the docs repo. +concurrency: + group: publish-cli-docs + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout windmill (source of truth) + uses: actions/checkout@v4 + with: + path: windmill + + - name: Checkout windmill-cli-docs (publish target) + uses: actions/checkout@v4 + with: + repository: windmill-labs/windmill-cli-docs + path: windmill-cli-docs + ssh-key: ${{ secrets.CLI_DOCS_DEPLOY_KEY }} + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install pyyaml + + - name: Regenerate docs + run: | + python3 windmill/system_prompts/generate.py \ + --context7-dir "$GITHUB_WORKSPACE/windmill-cli-docs" + + - name: Commit and push if changed + working-directory: windmill-cli-docs + env: + REF_NAME: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} + run: | + git config user.name "windmill-bot" + git config user.email "bot@windmill.dev" + git add -A + if git diff --cached --quiet; then + echo "No doc changes for ${REF_NAME}." + committed=false + else + committed=true + if [ "${REF_TYPE}" = "tag" ]; then + git commit -m "chore: sync from windmill ${REF_NAME}" + else + git commit -m "chore: sync from windmill (manual dispatch from ${REF_NAME})" + fi + git push origin HEAD + fi + # Always mirror the version tag on tag pushes, even when content + # didn't change — downstream consumers tie snapshots to releases by + # tag, and skipping it would leave the docs repo without a tag for + # the new Windmill release. + # workflow_dispatch from a non-tag ref skips this so we don't + # create a junk tag named after a branch. + if [ "${REF_TYPE}" = "tag" ]; then + git tag -f "${REF_NAME}" + git push origin "${REF_NAME}" --force + echo "Mirrored tag ${REF_NAME} to windmill-cli-docs (content changed: ${committed})." + fi diff --git a/system_prompts/README.md b/system_prompts/README.md index cd3a123b6a..cc9449f93e 100644 --- a/system_prompts/README.md +++ b/system_prompts/README.md @@ -38,6 +38,21 @@ python system_prompts/generate.py --plugin-dir ~/windmill-claude-plugin - a plugin root such as `plugins/windmill-code-plugin` - a direct `skills/` directory +To regenerate the public docs repo (consumed by context7): + +```bash +python system_prompts/generate.py --context7-dir ~/windmill-cli-docs +``` + +`--context7-dir` writes a fully-rendered snapshot (`AGENTS.md`, +`cli-commands.md`, `skills//SKILL.md`, `README.md`, `manifest.json` +with the Windmill `version`) with all template placeholders resolved — +suitable for ingestion by docs aggregators. In CI this runs from +`.github/workflows/publish-cli-docs.yml` on every release tag. The +generator refuses to wipe the target directory unless it's empty or has +a context7 marker (`context7.json`, `manifest.json`, or a +`windmill-cli-docs` git remote), so a typo can't delete unrelated files. + This will: 1. Parse TypeScript and Python SDK files to extract function signatures diff --git a/system_prompts/generate.py b/system_prompts/generate.py index 87f0b63576..92090d525e 100644 --- a/system_prompts/generate.py +++ b/system_prompts/generate.py @@ -12,6 +12,7 @@ This script: Usage: python generate.py python generate.py --plugin-dir /path/to/windmill-claude-plugin + python generate.py --context7-dir /path/to/windmill-cli-docs """ import argparse @@ -1778,6 +1779,395 @@ def generate_plugin_skills( return skills_dir +# ============================================================================= +# Context7 Docs Repo Generation +# ============================================================================= + +# Files in the context7 target directory that must survive a regeneration +# (everything else is wiped to keep the export deterministic). +CONTEXT7_PRESERVE = frozenset( + { + ".git", + ".github", + ".gitignore", + ".gitattributes", + "CODEOWNERS", + "LICENSE", + "LICENSE.md", + "context7.json", + } +) + +# Name written into manifest.json — also used to recognise the docs repo +# when re-generating into an existing checkout. +CONTEXT7_REPO_NAME = "windmill-cli-docs" + + +def extract_agents_md_template() -> str: + """Extract the AGENTS.md template string from cli/src/guidance/core.ts. + + Keeping a single source of truth in TypeScript avoids drift between what + `wmill init` writes locally and what we publish for context7 ingestion. + """ + core_ts_path = SCRIPT_DIR.parent / "cli" / "src" / "guidance" / "core.ts" + content = core_ts_path.read_text() + # Anchor on the function name so adding other template-literal-returning + # functions to core.ts can't silently re-target the regex. + match = re.search( + r"function\s+generateAgentsMdContent\b[\s\S]*?return\s+`([\s\S]*?)`;", + content, + ) + if not match: + raise RuntimeError( + f"Could not extract AGENTS.md template from {core_ts_path}" + ) + return _unescape_ts_template_literal(match.group(1)) + + +def _unescape_ts_template_literal(raw: str) -> str: + """Decode TS template-literal escapes in one pass. + + Multi-pass `.replace()` would mangle e.g. `\\\\` -> `\\` -> `` ` `` if the + template ever contained a literal backslash followed by a backtick. A + single-pass scan is order-independent. + """ + return re.sub( + r"\\(.)", + lambda m: {"`": "`", "$": "$", "\\": "\\"}.get(m.group(1), m.group(0)), + raw, + ) + + +def render_agents_md_for_docs( + skills: list[str], skill_desc_map: dict[str, str] +) -> str: + """Render AGENTS.md exactly as `wmill init` would, for the docs repo.""" + template = extract_agents_md_template() + skills_reference = "\n".join( + f"- `.claude/skills/{name}/SKILL.md` - {skill_desc_map[name]}" + for name in skills + if name in skill_desc_map + ) + return template.replace("${skillsReference}", skills_reference) + + +def build_skill_desc_map(skills: list[str]) -> dict[str, str]: + """Map each skill name to its user-facing description. + + Mirrors the logic in `generate_skills_ts_export`: language skills draw from + LANGUAGE_METADATA, everything else from SKILL_DEFINITIONS. + """ + desc_map = {s["name"]: s["description"] for s in SKILL_DEFINITIONS} + for skill in skills: + if skill.startswith("write-script-"): + lang_key = skill.replace("write-script-", "") + metadata = LANGUAGE_METADATA.get(lang_key) + if metadata: + desc_map[skill] = metadata["description"] + return desc_map + + +def _looks_like_windmill_manifest(path: Path) -> bool: + """Return True iff `path` is a JSON file whose top-level `name` is ours. + + Used to distinguish a previously-generated docs repo from an unrelated + project that happens to have a `manifest.json` (Chrome extensions, npm + packages, web app manifests, etc.). + """ + try: + data = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return False + return isinstance(data, dict) and data.get("name") == CONTEXT7_REPO_NAME + + +def _verify_context7_target(target_dir: Path) -> None: + """Refuse to wipe a non-empty dir that doesn't look like the docs repo. + + A typo such as `--context7-dir .`, `~`, or the wrong checkout could + otherwise nuke unrelated files. We accept the target if it's empty/new, + if it has our ownership file, if its `manifest.json` self-identifies as + the windmill-cli-docs repo, or if its git origin points at one. + """ + if not target_dir.exists() or not any(target_dir.iterdir()): + return + + if (target_dir / "context7.json").exists(): + return + + manifest_path = target_dir / "manifest.json" + if manifest_path.exists() and _looks_like_windmill_manifest(manifest_path): + return + + git_dir = target_dir / ".git" + if git_dir.exists(): + import subprocess + + try: + origin = subprocess.run( + ["git", "-C", str(target_dir), "config", "--get", "remote.origin.url"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + if CONTEXT7_REPO_NAME in origin: + return + except subprocess.CalledProcessError: + pass + + raise RuntimeError( + f"Refusing to overwrite {target_dir}: target does not look like the " + f"{CONTEXT7_REPO_NAME} docs repo.\n" + f"Expected one of:\n" + f" - a `context7.json` at the top level,\n" + f" - a `manifest.json` whose top-level `name` is {CONTEXT7_REPO_NAME!r},\n" + f" - a git remote `origin` containing '{CONTEXT7_REPO_NAME}'.\n" + f"If this is the right directory, add a `context7.json` and retry." + ) + + +def clear_context7_dir(target_dir: Path) -> None: + """Wipe the docs repo dir of previously generated content. + + Preserves a small allowlist (.git, .github, LICENSE, context7.json, etc.) + so this can run against a real checkout without nuking version control or + CI config. + """ + if not target_dir.exists(): + return + for entry in target_dir.iterdir(): + if entry.name in CONTEXT7_PRESERVE: + continue + if entry.is_dir(): + shutil.rmtree(entry) + else: + entry.unlink() + + +def _read_windmill_version() -> str | None: + """Return the Windmill release version (e.g. '1.700.2'), or None if absent. + + Sourced from `version.txt` at the repo root — the same file release-please + updates on every release. + """ + version_file = SCRIPT_DIR.parent / "version.txt" + if not version_file.exists(): + return None + return version_file.read_text().strip() or None + + +def generate_context7_repo( + target_dir: Path, + skills: list[str], + schema_yaml_content: dict[str, str], + cli_commands_md: str, +) -> Path: + """Generate a fully-rendered docs repo suitable for context7 ingestion. + + Layout written to `target_dir`: + AGENTS.md # the prompt agents see in their projects + README.md # stable intro for humans / context7 + manifest.json # version + skill list (for indexing) + cli-commands.md # full CLI flag reference + skills//SKILL.md # one rendered skill per file + """ + target_dir = target_dir.expanduser().resolve() + target_dir.mkdir(parents=True, exist_ok=True) + _verify_context7_target(target_dir) + clear_context7_dir(target_dir) + + skill_desc_map = build_skill_desc_map(skills) + + # AGENTS.md — the same content `wmill init` writes locally. + (target_dir / "AGENTS.md").write_text( + render_agents_md_for_docs(skills, skill_desc_map) + ) + + # Full CLI reference at top level. + (target_dir / "cli-commands.md").write_text(cli_commands_md) + + # One markdown per skill, with schemas inlined (no template placeholders). + skills_dir = target_dir / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + for skill_name in skills: + skill_dir = skills_dir / skill_name + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text( + render_plugin_skill_content(skill_name, schema_yaml_content) + ) + + # Stable README so the GitHub repo landing page tells readers (and + # context7's crawler) what they're looking at. + (target_dir / "README.md").write_text(_context7_readme(skills)) + + # Machine-readable index for context7 / downstream consumers. + # Note: the `name` field is also the marker `_verify_context7_target` + # uses to distinguish our `manifest.json` from generic ones. + manifest = { + "name": CONTEXT7_REPO_NAME, + "description": ( + "Auto-generated Windmill CLI docs: agent prompt, skills, and " + "full CLI reference. Source: github.com/windmill-labs/windmill." + ), + "skills": [ + {"name": name, "description": skill_desc_map.get(name, "")} + for name in skills + ], + } + version = _read_windmill_version() + if version: + manifest["version"] = version + (target_dir / "manifest.json").write_text( + json.dumps(manifest, indent=2) + "\n" + ) + + print(f"\nGenerated for context7 docs repo:") + print(f" - {target_dir} ({len(skills)} skills + AGENTS.md + cli-commands.md)") + return target_dir + + +def _context7_readme(skills: list[str]) -> str: + """Render the README that ships at the root of the docs repo. + + Doubles as a CLI quickstart for humans landing on the GitHub page and as + the top-level entry point context7 indexes first — keep it actionable. + """ + skill_lines = "\n".join(f"- `skills/{name}/SKILL.md`" for name in skills) + return f"""# Windmill CLI Quickstart + +[`wmill`](https://www.windmill.dev/docs/advanced/cli) is the official command +line interface for [Windmill](https://www.windmill.dev) — an open-source +platform for internal tools, workflows, API integrations, background jobs, and +UIs. Use it to authenticate against a workspace, scaffold local projects, +sync scripts/flows/apps between your filesystem and a workspace, and run or +debug jobs from your terminal. + +## Install + +```sh +npm install -g windmill-cli +wmill --version +``` + +Upgrade later with `wmill upgrade`. + +## Connect to a workspace + +```sh +wmill workspace add +``` + +This walks you through adding a workspace profile — a `(name, remote URL, +workspace id, token)` tuple stored under `~/.config/windmill`. You can have +multiple profiles and switch between them with `wmill workspace switch `. + +A workspace token is created from the Windmill UI under +`User Settings → Tokens`. For self-hosted instances, point the remote at your +own URL (e.g. `https://windmill.example.com`). + +## Initialize a project directory + +```sh +wmill init +``` + +`wmill init` creates: + +- `wmill.yaml` — sync configuration (which folders/types to track). +- `AGENTS.md` + `CLAUDE.md` — the agent prompt published in this repo. +- `.claude/skills/` and `.agents/skills/` — per-task guides used by AI coding + assistants (Claude Code, Codex, Pi). These are the same `SKILL.md` files + you'll find under `skills/` in this repo. + +It also offers to bind a workspace profile to the current git branch and to +import git-sync settings from the backend if any are configured. + +## Sync between local files and a workspace + +```sh +wmill sync pull # workspace → local (writes flows, scripts, apps, etc.) +wmill sync push # local → workspace +``` + +Sync is idempotent and diff-aware: `wmill sync push --dry-run` previews the +changes without applying them. Use `--yaml` (recommended) to keep specs as +YAML rather than JSON. + +For individual entities you can also use the type-specific commands: + +```sh +wmill script push path/to/script.ts +wmill flow push path/to/flow.yaml +wmill app push path/to/app.yaml +wmill resource push path/to/resource.yaml +``` + +## Run, inspect, and debug jobs + +```sh +wmill script run u/me/my_script --data '{{"foo": "bar"}}' +wmill flow run u/me/my_flow --data @inputs.json +wmill job list --failed --limit 20 +wmill job get +wmill job logs +``` + +Logs and flow steps stream as the job runs. For flow failures, `wmill job get` +shows the step tree with each sub-job's id so you can drill in with +`wmill job logs `. + +## Scaffold new entities + +```sh +wmill script new u/me/path --language bun +wmill flow new u/me/path --summary "..." +wmill app new u/me/path --summary "..." --framework svelte +``` + +These create the correct folder layout and a minimal spec file, then print +next-step hints. Prefer them over hand-creating the folders — they pick the +right naming conventions for your workspace. + +## Triggers and schedules + +Triggers (HTTP routes, WebSocket, Kafka, NATS, MQTT, SQS, GCP Pub/Sub, Azure +Event Hubs, Email, Postgres CDC) and cron schedules are tracked as YAML files +synced alongside your scripts and flows. See `skills/triggers/SKILL.md` and +`skills/schedules/SKILL.md` for the full schemas. + +## Completion + +```sh +source <(wmill completions bash) # bash, zsh: source <(wmill completions zsh) +source (wmill completions fish | psub) # fish +``` + +## Reference + +- `cli-commands.md` — every `wmill` command and flag, generated from the + source. +- `AGENTS.md` — the top-level prompt the CLI installs into each project (and + the same instructions AI coding assistants follow when working in a + Windmill repo). +- `skills//SKILL.md` — one self-contained guide per common task. + +### Skills index + +{skill_lines} + +## About this repo + +Auto-generated mirror of the Windmill CLI's bundled AI-agent guidance and +command reference, published for ingestion by docs aggregators such as +[context7](https://context7.com). + +**Do not edit by hand.** This repo is regenerated from +[windmill-labs/windmill](https://github.com/windmill-labs/windmill) on every +release. Open issues and PRs in the source repo, not here. The generator is +`system_prompts/generate.py --context7-dir`. +""" + + # ============================================================================= # Main Entry Point # ============================================================================= @@ -1799,6 +2189,15 @@ def parse_args() -> argparse.Namespace: "a plugin root, or a skills directory, and refreshes standalone skills there." ), ) + parser.add_argument( + "--context7-dir", + type=Path, + help=( + "Optional path to a docs-repo checkout (e.g. windmill-cli-docs). " + "Writes AGENTS.md, cli-commands.md, skills/, README.md, and manifest.json " + "with all placeholders resolved, suitable for context7 ingestion." + ), + ) return parser.parse_args() @@ -2117,6 +2516,11 @@ export declare function getWorkflowAsCodePrompt(language?: string): string; if args.plugin_dir: generate_plugin_skills(args.plugin_dir, skills, schema_yaml_content) + if args.context7_dir: + generate_context7_repo( + args.context7_dir, skills, schema_yaml_content, cli_commands + ) + print("\nDone!") From 7a7d246a6e27aef6bc15c2e88a4163874f474b86 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 13 May 2026 10:20:30 +0200 Subject: [PATCH 105/313] test: add global ai eval mode (#9129) * feat: add global ai eval mode * fix: improve global eval validation feedback --- ai_evals/AGENTS.md | 11 + ai_evals/README.md | 16 +- ai_evals/adapters/frontend/benchmarkRunner.ts | 7 +- .../frontend/core/global/globalEvalRunner.ts | 127 ++++++ ai_evals/adapters/frontend/progress.ts | 2 +- ai_evals/adapters/frontend/runtime.ts | 2 +- .../adapters/frontend/vitestAdapter.test.ts | 174 ++++++++ ai_evals/cases/global.yaml | 55 +++ ai_evals/cli/index.ts | 7 +- ai_evals/core/cases.test.ts | 20 + ai_evals/core/models.ts | 2 +- ai_evals/core/types.ts | 26 +- ai_evals/core/validators.test.ts | 225 +++++++++++ ai_evals/core/validators.ts | 376 ++++++++++++++++++ .../initial/format_greeting_script.json | 23 ++ ai_evals/modes/global.ts | 81 ++++ 16 files changed, 1142 insertions(+), 12 deletions(-) create mode 100644 ai_evals/adapters/frontend/core/global/globalEvalRunner.ts create mode 100644 ai_evals/cases/global.yaml create mode 100644 ai_evals/fixtures/frontend/global/initial/format_greeting_script.json create mode 100644 ai_evals/modes/global.ts diff --git a/ai_evals/AGENTS.md b/ai_evals/AGENTS.md index 096baf5b58..d26e6d60ea 100644 --- a/ai_evals/AGENTS.md +++ b/ai_evals/AGENTS.md @@ -6,6 +6,7 @@ This folder contains black-box benchmark cases for: - `app` - `script` - `cli` +- `global` The goal is to test the current production prompts and guidance with realistic user requests, not to test one exact implementation shape. @@ -75,6 +76,16 @@ Still, avoid benchmark phrasing. The prompt should read like a repo task, not a When relevant, ask the assistant to tell the user which `wmill` commands to run next. That is part of the benchmarked behavior. +## Global-specific rules + +Global prompts should exercise workspace-level drafting behavior: + +- inspecting existing scripts, flows, apps, schedules, triggers, resources, and variables when relevant +- writing AI drafts rather than saving or deploying by default +- producing coherent multi-artifact changes when the request crosses artifact boundaries + +Keep deterministic validation focused on the draft contract: required draft type/path, required content snippets, forbidden draft paths, and forbidden mutating tools such as deploy/delete unless the case explicitly asks for them. + ## Deterministic validation Use deterministic validation only for hard failures such as: diff --git a/ai_evals/README.md b/ai_evals/README.md index 2e1f3210f8..6982d70da9 100644 --- a/ai_evals/README.md +++ b/ai_evals/README.md @@ -1,11 +1,12 @@ # AI Evals -Small benchmark runner for the four Windmill AI generation modes: +Small benchmark runner for the Windmill AI generation modes: - `cli` - `flow` - `script` - `app` +- `global` The benchmark always tests the current production prompts, tools, and guidance in this checkout. @@ -57,6 +58,7 @@ bun run cli -- run flow flow-test0-sum-two-numbers --runs 3 --verbose bun run cli -- run flow --record GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000 bun run cli -- run flow --backend-validation preview +bun run cli -- run global global-test1-script-create bun run cli -- run cli bun-hello-script ``` @@ -94,7 +96,7 @@ Today: Notes: - the command also prints accepted alias spellings such as `gpt-4o`, `claude-opus-4.6`, and `claude-haiku-4.5` -- frontend modes (`flow`, `script`, `app`) can use Anthropic, OpenAI, and Gemini-backed aliases +- frontend modes (`flow`, `script`, `app`, `global`) can use Anthropic, OpenAI, and Gemini-backed aliases - `cli` mode always uses the Anthropic agent SDK, so only Anthropic aliases are valid there - the judge model is separate and currently defaults to `claude-sonnet-4-6` @@ -133,6 +135,13 @@ For `app` mode, `validate` can express narrow hard requirements such as: - minimum datatable / datatable-table counts - specific required datatable tables +For `global` mode, `validate` can express draft-level requirements such as: + +- required draft type/path/language +- required or forbidden snippets in draft values +- required or forbidden draft counts +- forbidden draft paths + App fixtures can also include an optional `datatables.json` file at the fixture root. For `flow` mode, an `initial` fixture can also include a benchmark workspace catalog of @@ -174,6 +183,7 @@ If `--record` is used, the CLI also appends one compact JSON line to: - `ai_evals/history/flow.jsonl` - `ai_evals/history/script.jsonl` - `ai_evals/history/app.jsonl` +- `ai_evals/history/global.jsonl` - `ai_evals/history/cli.jsonl` Each recorded line contains: @@ -194,6 +204,7 @@ Typical artifacts by mode: - `flow`: `flow.json` - `script`: `script.json` plus the generated script file - `app`: `app.json` plus frontend/backend files +- `global`: `global-drafts.json` - `cli`: `assistant-output.txt`, `trace.json`, `wmill-invocations.jsonl`, plus generated workspace files - backend-validated attempts also include `backend-preview.json` @@ -209,6 +220,7 @@ Typical artifacts by mode: ## Notes - Frontend modes reuse the production frontend chat code through the Vitest bridge. +- Global mode evaluates the production global AI tools and validates the resulting AI draft store. - CLI mode creates an isolated workspace, writes the current checkout guidance into it, and benchmarks the real skills / `AGENTS.md` flow. - CLI mode now also records a structured trace of invoked skills, tool calls, proposed `wmill` commands, and any attempted `wmill` executions. - Frontend progress streams live while the benchmark is running. diff --git a/ai_evals/adapters/frontend/benchmarkRunner.ts b/ai_evals/adapters/frontend/benchmarkRunner.ts index 50a0d10c3c..14be108a10 100644 --- a/ai_evals/adapters/frontend/benchmarkRunner.ts +++ b/ai_evals/adapters/frontend/benchmarkRunner.ts @@ -12,10 +12,11 @@ import { resolveWindmillBackendSettings } from "../../core/windmillBackendSettin 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"; +export type FrontendBenchmarkMode = "flow" | "app" | "script" | "global"; export async function runFrontendBenchmarkFromEnv(): Promise { const mode = parseMode(process.env.WMILL_FRONTEND_AI_EVAL_MODE); @@ -85,11 +86,13 @@ function getModeRunner( backendValidation, backendSettings, ); + case "global": + return createGlobalModeRunner(model, backendSettings); } } function parseMode(value: string | undefined): FrontendBenchmarkMode { - if (value === "flow" || value === "app" || value === "script") { + if (value === "flow" || value === "app" || value === "script" || value === "global") { return value; } throw new Error(`Unsupported frontend benchmark mode: ${String(value)}`); diff --git a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts new file mode 100644 index 0000000000..5e00dd6f34 --- /dev/null +++ b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts @@ -0,0 +1,127 @@ +import { mkdtemp, rm } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import type { AIProvider } from "$lib/gen/types.gen"; +import { + globalTools, + prepareGlobalSystemMessage, + prepareGlobalUserMessage, +} from "../../../../../frontend/src/lib/components/copilot/chat/global/core"; +import { globalDraftStore } from "../../../../../frontend/src/lib/components/copilot/chat/global/draftStore.svelte"; +import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared"; +import type { ModeRunContext } from "../../../../core/types"; +import type { GlobalDraftState } from "../../../../core/validators"; +import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings"; +import { + registerBenchmarkWorkspaceRunnables, + unregisterBenchmarkWorkspaceRunnables, + type BenchmarkWorkspaceRunnables, +} from "../../mockBackend"; +import { runEval } from "../shared"; +import type { TokenUsage, ToolCallDetail } from "../shared/types"; + +const MUTATING_GLOBAL_TOOLS = new Set([ + "deploy_workspace_item", + "delete_workspace_item", +]); + +export interface GlobalEvalResult { + success: boolean; + state: GlobalDraftState; + error?: string; + assistantMessageCount: number; + toolCallCount: number; + toolsUsed: string[]; + toolCallDetails: ToolCallDetail[]; + tokenUsage: TokenUsage; +} + +export interface GlobalEvalOptions { + workspaceFixtures?: BenchmarkWorkspaceRunnables; + model?: string; + maxIterations?: number; + provider?: AIProvider; + backend: WindmillBackendSettings; + workspaceRoot?: string; + runContext?: ModeRunContext; +} + +export async function runGlobalEval( + userPrompt: string, + apiKey: string, + options: GlobalEvalOptions, +): Promise { + const workspaceRoot = + options.workspaceRoot ?? + (await mkdtemp(join(tmpdir(), "wmill-frontend-global-benchmark-"))); + + globalDraftStore.clearDrafts(workspaceRoot); + registerBenchmarkWorkspaceRunnables(workspaceRoot, options.workspaceFixtures ?? {}); + + try { + const model = options.model ?? "claude-haiku-4-5-20251001"; + const rawResult = await runEval({ + userPrompt, + systemMessage: prepareGlobalSystemMessage(), + userMessage: prepareGlobalUserMessage(userPrompt), + tools: getGlobalEvalTools(), + helpers: {}, + apiKey, + getOutput: () => ({ drafts: globalDraftStore.listDrafts(workspaceRoot) }), + onAssistantMessageStart: options.runContext?.onAssistantMessageStart, + onAssistantToken: options.runContext?.onAssistantChunk, + onAssistantMessageEnd: options.runContext?.onAssistantMessageEnd, + onToolCall: options.runContext?.onToolCall, + options: { + maxIterations: options.maxIterations, + model, + workspace: workspaceRoot, + provider: options.provider, + backend: options.backend, + caseId: options.runContext?.caseId, + attempt: options.runContext?.attempt, + }, + }); + + return { + state: rawResult.output, + success: rawResult.success, + error: rawResult.error, + assistantMessageCount: rawResult.iterations, + toolCallCount: rawResult.toolCallsCount, + toolsUsed: rawResult.toolsCalled, + toolCallDetails: rawResult.toolCallDetails, + tokenUsage: rawResult.tokenUsage, + }; + } finally { + globalDraftStore.clearDrafts(workspaceRoot); + unregisterBenchmarkWorkspaceRunnables(workspaceRoot); + if (!options.workspaceRoot) { + await rm(workspaceRoot, { recursive: true, force: true }); + } + } +} + +function getGlobalEvalTools(): ProductionTool<{}>[] { + return (globalTools as ProductionTool<{}>[]).map((tool) => { + if (!MUTATING_GLOBAL_TOOLS.has(tool.def.function.name)) { + return tool; + } + + return { + ...tool, + requiresConfirmation: false, + validateBeforeConfirmation: undefined, + fn: async () => + JSON.stringify( + { + success: false, + error: + "This mutating workspace tool is disabled during ai_evals global mode.", + }, + null, + 2, + ), + }; + }); +} diff --git a/ai_evals/adapters/frontend/progress.ts b/ai_evals/adapters/frontend/progress.ts index b5b8f12c83..3a4810c4a3 100644 --- a/ai_evals/adapters/frontend/progress.ts +++ b/ai_evals/adapters/frontend/progress.ts @@ -1,4 +1,4 @@ -export type FrontendBenchmarkProgressSurface = 'flow' | 'app' | 'script' +export type FrontendBenchmarkProgressSurface = 'flow' | 'app' | 'script' | 'global' export type FrontendBenchmarkProgressEvent = | { diff --git a/ai_evals/adapters/frontend/runtime.ts b/ai_evals/adapters/frontend/runtime.ts index 180c7a2993..347e15191c 100644 --- a/ai_evals/adapters/frontend/runtime.ts +++ b/ai_evals/adapters/frontend/runtime.ts @@ -16,7 +16,7 @@ const FRONTEND_BENCHMARK_TEST = const FRONTEND_BENCHMARK_CONFIG = "../ai_evals/adapters/frontend/vitest.config.ts"; -export type FrontendMode = "flow" | "app" | "script"; +export type FrontendMode = "flow" | "app" | "script" | "global"; export async function runFrontendBenchmarkAdapter(input: { mode: FrontendMode; diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index 542feaf89b..1275acf0b4 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -65,6 +65,10 @@ vi.mock('$lib/gen', async () => { hasBenchmarkWorkspace(data.workspace) ? (listBenchmarkScripts(data.workspace) ?? []) : actual.ScriptService.listScripts(data), + existsScriptByPath: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) + ? Boolean(getBenchmarkScriptByPath(data.workspace, data.path)) + : actual.ScriptService.existsScriptByPath(data), getScriptByPath: async (data: { workspace: string; path: string }) => { if (hasBenchmarkWorkspace(data.workspace)) { const script = getBenchmarkScriptByPath(data.workspace, data.path) @@ -91,6 +95,10 @@ vi.mock('$lib/gen', async () => { hasBenchmarkWorkspace(data.workspace) ? (listBenchmarkFlows(data.workspace) ?? []) : actual.FlowService.listFlows(data), + existsFlowByPath: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) + ? Boolean(getBenchmarkFlowByPath(data.workspace, data.path)) + : actual.FlowService.existsFlowByPath(data), getFlowByPath: async (data: { workspace: string; path: string }) => { if (hasBenchmarkWorkspace(data.workspace)) { const flow = getBenchmarkFlowByPath(data.workspace, data.path) @@ -142,6 +150,16 @@ vi.mock('$lib/gen', async () => { } }), ScheduleService: wrapService(actual.ScheduleService, { + existsSchedule: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.ScheduleService.existsSchedule(data), + listSchedules: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.ScheduleService.listSchedules(data), + getSchedule: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`Schedule "${data.path}" not found in benchmark workspace`) + } + return actual.ScheduleService.getSchedule(data) + }, previewSchedule: async (data: { requestBody?: Record }) => previewBenchmarkSchedule(data), createSchedule: async (data: { workspace: string; requestBody: Record }) => @@ -149,11 +167,167 @@ vi.mock('$lib/gen', async () => { ? createBenchmarkSchedule(data) : actual.ScheduleService.createSchedule(data) }), + ResourceService: wrapService(actual.ResourceService, { + existsResource: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.ResourceService.existsResource(data), + listResource: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.listResource(data), + getResource: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`Resource "${data.path}" not found in benchmark workspace`) + } + return actual.ResourceService.getResource(data) + }, + queryResourceTypes: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.queryResourceTypes(data) + }), + VariableService: wrapService(actual.VariableService, { + existsVariable: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.VariableService.existsVariable(data), + listVariable: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.VariableService.listVariable(data), + getVariable: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`Variable "${data.path}" not found in benchmark workspace`) + } + return actual.VariableService.getVariable(data) + } + }), + AppService: wrapService(actual.AppService, { + existsApp: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.AppService.existsApp(data), + listApps: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.AppService.listApps(data), + getAppByPath: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`App "${data.path}" not found in benchmark workspace`) + } + return actual.AppService.getAppByPath(data) + } + }), HttpTriggerService: wrapService(actual.HttpTriggerService, { + existsHttpTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.HttpTriggerService.existsHttpTrigger(data), + listHttpTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.HttpTriggerService.listHttpTriggers(data), + getHttpTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`HTTP trigger "${data.path}" not found in benchmark workspace`) + } + return actual.HttpTriggerService.getHttpTrigger(data) + }, createHttpTrigger: async (data: { workspace: string; requestBody: Record }) => hasBenchmarkWorkspace(data.workspace) ? createBenchmarkHttpTrigger(data) : actual.HttpTriggerService.createHttpTrigger(data) + }), + WebsocketTriggerService: wrapService(actual.WebsocketTriggerService, { + existsWebsocketTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) + ? false + : actual.WebsocketTriggerService.existsWebsocketTrigger(data), + listWebsocketTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) + ? [] + : actual.WebsocketTriggerService.listWebsocketTriggers(data), + getWebsocketTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`Websocket trigger "${data.path}" not found in benchmark workspace`) + } + return actual.WebsocketTriggerService.getWebsocketTrigger(data) + } + }), + KafkaTriggerService: wrapService(actual.KafkaTriggerService, { + existsKafkaTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) + ? false + : actual.KafkaTriggerService.existsKafkaTrigger(data), + listKafkaTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.KafkaTriggerService.listKafkaTriggers(data), + getKafkaTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`Kafka trigger "${data.path}" not found in benchmark workspace`) + } + return actual.KafkaTriggerService.getKafkaTrigger(data) + } + }), + NatsTriggerService: wrapService(actual.NatsTriggerService, { + existsNatsTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.NatsTriggerService.existsNatsTrigger(data), + listNatsTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.NatsTriggerService.listNatsTriggers(data), + getNatsTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`NATS trigger "${data.path}" not found in benchmark workspace`) + } + return actual.NatsTriggerService.getNatsTrigger(data) + } + }), + PostgresTriggerService: wrapService(actual.PostgresTriggerService, { + existsPostgresTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) + ? false + : actual.PostgresTriggerService.existsPostgresTrigger(data), + listPostgresTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) + ? [] + : actual.PostgresTriggerService.listPostgresTriggers(data), + getPostgresTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`Postgres trigger "${data.path}" not found in benchmark workspace`) + } + return actual.PostgresTriggerService.getPostgresTrigger(data) + } + }), + MqttTriggerService: wrapService(actual.MqttTriggerService, { + existsMqttTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.MqttTriggerService.existsMqttTrigger(data), + listMqttTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.MqttTriggerService.listMqttTriggers(data), + getMqttTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`MQTT trigger "${data.path}" not found in benchmark workspace`) + } + return actual.MqttTriggerService.getMqttTrigger(data) + } + }), + SqsTriggerService: wrapService(actual.SqsTriggerService, { + existsSqsTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.SqsTriggerService.existsSqsTrigger(data), + listSqsTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.SqsTriggerService.listSqsTriggers(data), + getSqsTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`SQS trigger "${data.path}" not found in benchmark workspace`) + } + return actual.SqsTriggerService.getSqsTrigger(data) + } + }), + GcpTriggerService: wrapService(actual.GcpTriggerService, { + existsGcpTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.GcpTriggerService.existsGcpTrigger(data), + listGcpTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.GcpTriggerService.listGcpTriggers(data), + getGcpTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`GCP trigger "${data.path}" not found in benchmark workspace`) + } + return actual.GcpTriggerService.getGcpTrigger(data) + } + }), + AzureTriggerService: wrapService(actual.AzureTriggerService, { + existsAzureTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) + ? false + : actual.AzureTriggerService.existsAzureTrigger(data), + listAzureTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.AzureTriggerService.listAzureTriggers(data), + getAzureTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`Azure trigger "${data.path}" not found in benchmark workspace`) + } + return actual.AzureTriggerService.getAzureTrigger(data) + } }) } }) diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml new file mode 100644 index 0000000000..709081942b --- /dev/null +++ b/ai_evals/cases/global.yaml @@ -0,0 +1,55 @@ +- id: global-test1-script-create + prompt: |- + Create a draft Bun script at `f/evals/global/greet_user`. + It should take a string `name` input and return `Hello, ${name}!`. + Leave it as an AI draft only; do not deploy or save it. + runtime: + maxTurns: 8 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + path: f/evals/global/greet_user + language: bun + valueIncludes: + - name + - Hello + toolExpect: + requiredToolsUsed: + - write_script + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - creates a Bun script draft at f/evals/global/greet_user + - the script accepts a name input + - the script returns a greeting containing Hello, the provided name, and an exclamation mark + - the result stays as an AI draft and is not deployed or saved to the workspace + +- id: global-test2-script-edit-existing + prompt: |- + Update the existing workspace script at `f/evals/global/format_greeting`. + Keep it as a Bun script, but change the greeting so the provided name is uppercased and the returned message ends with an exclamation mark. + Leave the result as an AI draft only; do not deploy or save it. + initial: ai_evals/fixtures/frontend/global/initial/format_greeting_script.json + runtime: + maxTurns: 8 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + path: f/evals/global/format_greeting + language: bun + valueIncludes: + - toUpperCase + - "!" + toolExpect: + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - creates an AI draft for the existing f/evals/global/format_greeting script + - preserves the script as Bun + - uppercases the provided name in the greeting + - returns a message ending with an exclamation mark + - does not deploy or save the draft to the workspace diff --git a/ai_evals/cli/index.ts b/ai_evals/cli/index.ts index 259b055ff5..8ed61740c8 100644 --- a/ai_evals/cli/index.ts +++ b/ai_evals/cli/index.ts @@ -53,6 +53,7 @@ async function main() { " bun run cli -- run flow --record", " bun run cli -- run flow --backend-validation preview", " bun run cli -- run flow flow-test5-simple-modification --runs 3", + " bun run cli -- run global global-test1-script-create", " bun run cli -- run cli bun-hello-script", "", "Models:", @@ -70,7 +71,7 @@ async function main() { program .command("cases") .description("List available cases") - .argument("[mode]", "cli, flow, script, or app", parseOptionalMode) + .argument("[mode]", "cli, flow, script, app, or global", parseOptionalMode) .action(async (mode?: EvalMode) => { await handleCases(mode); }); @@ -78,7 +79,7 @@ async function main() { program .command("run") .description("Run one benchmark mode") - .argument("", "cli, flow, script, or app", parseMode) + .argument("", "cli, flow, script, app, or global", parseMode) .argument("[caseIds...]", "specific case ids to run") .option( "--runs ", @@ -152,7 +153,7 @@ function handleModels() { process.stdout.write("Available models\n"); for (const model of EVAL_MODELS) { const supports = [ - ...(model.frontend ? ["flow", "script", "app"] : []), + ...(model.frontend ? ["flow", "script", "app", "global"] : []), ...(model.cli ? ["cli"] : []), ]; const aliases = [ diff --git a/ai_evals/core/cases.test.ts b/ai_evals/core/cases.test.ts index 977bb71390..733d34ddd2 100644 --- a/ai_evals/core/cases.test.ts +++ b/ai_evals/core/cases.test.ts @@ -183,6 +183,26 @@ describe("loadCases", () => { }); }); + it("loads global draft validation and forbidden tool expectations", async () => { + const globalCases = await loadCases("global"); + const caseEntry = globalCases.find((entry) => entry.id === "global-test1-script-create"); + + expect(caseEntry?.validate).toMatchObject({ + draftCountExactly: 1, + requiredDrafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + }, + ], + }); + expect(caseEntry?.toolExpect).toMatchObject({ + requiredToolsUsed: ["write_script"], + forbiddenToolsUsed: ["deploy_workspace_item", "delete_workspace_item"], + }); + }); + it("loads tool expectations for workspace mutation cases", async () => { const scriptCases = await loadCases("script"); const caseEntry = scriptCases.find( diff --git a/ai_evals/core/models.ts b/ai_evals/core/models.ts index 9cc0ab0597..82f3b3f69b 100644 --- a/ai_evals/core/models.ts +++ b/ai_evals/core/models.ts @@ -145,7 +145,7 @@ export function resolveEvalModel(mode: EvalMode, alias?: string): EvalModelSpec export function getEvalModelHelpText(): string { return EVAL_MODELS.map((model) => { const modes = [ - ...(model.frontend ? ["flow", "script", "app"] : []), + ...(model.frontend ? ["flow", "script", "app", "global"] : []), ...(model.cli ? ["cli"] : []), ]; return ` ${model.id.padEnd(8)} ${model.label} (${modes.join(", ")})`; diff --git a/ai_evals/core/types.ts b/ai_evals/core/types.ts index dfc6882f84..2b42a0dfc5 100644 --- a/ai_evals/core/types.ts +++ b/ai_evals/core/types.ts @@ -1,4 +1,4 @@ -export const EVAL_MODES = ["cli", "flow", "script", "app"] as const; +export const EVAL_MODES = ["cli", "flow", "script", "app", "global"] as const; export type EvalMode = (typeof EVAL_MODES)[number]; @@ -108,6 +108,27 @@ export interface AppValidationSpec { forbiddenAppContent?: string[]; } +export interface GlobalDraftRequirement { + type: string; + path: string; + triggerKind?: string; + language?: string; + summaryIncludes?: string[]; + valueIncludes?: string[]; + valueExcludes?: string[]; +} + +export interface GlobalValidationSpec { + draftCountAtLeast?: number; + draftCountExactly?: number; + requiredDrafts?: GlobalDraftRequirement[]; + forbiddenDrafts?: Array<{ + type: string; + path: string; + triggerKind?: string; + }>; +} + export interface CliValidationSpec { requiredSkills?: string[]; forbiddenSkills?: string[]; @@ -136,10 +157,11 @@ export interface ToolCallArgumentRule { export interface ToolValidationSpec { requiredToolsUsed?: string[]; + forbiddenToolsUsed?: string[]; toolCallArgs?: ToolCallArgumentRule[]; } -export type EvalValidationSpec = FlowValidationSpec | AppValidationSpec; +export type EvalValidationSpec = FlowValidationSpec | AppValidationSpec | GlobalValidationSpec; export interface EvalCase { id: string; diff --git a/ai_evals/core/validators.test.ts b/ai_evals/core/validators.test.ts index 406172b955..d2a6e954bb 100644 --- a/ai_evals/core/validators.test.ts +++ b/ai_evals/core/validators.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import { validateAppState, validateCliWorkspace, + validateGlobalState, validateScriptState, validateToolExpectations, } from "./validators"; @@ -117,6 +118,230 @@ describe("validateToolExpectations", () => { details: 'rejected prefixes: schedules/; values: "schedules/greet_user_daily"', }); }); + + it("rejects forbidden tool usage", () => { + const checks = validateToolExpectations({ + run: { + success: true, + actual: {}, + assistantMessageCount: 1, + toolCallCount: 1, + toolsUsed: ["write_script", "deploy_workspace_item"], + skillsInvoked: [], + }, + toolExpect: { + forbiddenToolsUsed: ["deploy_workspace_item"], + }, + }); + + expect(checks).toContainEqual({ + name: "does not use deploy_workspace_item", + passed: false, + details: "tools used: write_script, deploy_workspace_item", + }); + }); +}); + +describe("validateGlobalState", () => { + it("accepts a required script draft", () => { + const checks = validateGlobalState({ + actual: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + value: + "export async function main(name: string) {\n return `Hello, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + validate: { + draftCountExactly: 1, + requiredDrafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + valueIncludes: ["Hello"], + }, + ], + }, + }); + + expect(checks.every((check) => check.passed)).toBe(true); + }); + + it("fails when a required draft is missing", () => { + const checks = validateGlobalState({ + actual: { + drafts: [], + }, + validate: { + requiredDrafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + }, + ], + }, + }); + + expect(checks).toContainEqual({ + name: "global includes script draft f/evals/global/greet_user", + passed: false, + details: "drafts: none", + }); + }); + + it("does not require a TypeScript entrypoint for non-TypeScript script drafts", () => { + const checks = validateGlobalState({ + actual: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_python", + language: "python3", + value: "def main(name: str):\n return f'Hello, {name}!'\n", + isDraft: true, + }, + ], + }, + }); + + expect(checks.some((check) => check.name.includes("exports entrypoint"))).toBe( + false + ); + expect(checks.every((check) => check.passed)).toBe(true); + }); + + it("allows read-only global cases without draft expectations", () => { + const checks = validateGlobalState({ + actual: { + drafts: [], + }, + }); + + expect( + checks.some( + (check) => check.name === "global produced at least one draft" + ) + ).toBe(false); + expect(checks.every((check) => check.passed)).toBe(true); + }); + + it("matches expected global draft fixtures", () => { + const checks = validateGlobalState({ + actual: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + value: + "export async function main(name: string) {\r\n return `Hello, ${name}!`\r\n}\r\n", + isDraft: true, + }, + ], + }, + expected: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + value: + "export async function main(name: string) {\n return `Hello, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + }); + + expect(checks).toContainEqual({ + name: "global drafts match expected", + passed: true, + }); + }); + + it("fails when expected global draft fixtures differ", () => { + const checks = validateGlobalState({ + actual: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + value: + "export async function main(name: string) {\n return `Hello, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + expected: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + value: + "export async function main(name: string) {\n return `Bonjour, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + }); + + const expectedMatchCheck = checks.find( + (check) => check.name === "global drafts match expected" + ); + expect(expectedMatchCheck?.passed).toBe(false); + expect(expectedMatchCheck?.details).toContain( + "script:f/evals/global/greet_user value differs" + ); + expect(expectedMatchCheck?.details).toContain("Hello"); + expect(expectedMatchCheck?.details).toContain("Bonjour"); + }); + + it("explains expected global draft metadata mismatches", () => { + const checks = validateGlobalState({ + actual: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + value: + "export async function main(name: string) {\n return `Hello, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + expected: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "python3", + value: + "export async function main(name: string) {\n return `Hello, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + }); + + const expectedMatchCheck = checks.find( + (check) => check.name === "global drafts match expected" + ); + expect(expectedMatchCheck?.passed).toBe(false); + expect(expectedMatchCheck?.details).toContain( + "script:f/evals/global/greet_user language differs" + ); + expect(expectedMatchCheck?.details).toContain('actual="bun"'); + expect(expectedMatchCheck?.details).toContain('expected="python3"'); + }); }); describe("validateAppState", () => { diff --git a/ai_evals/core/validators.ts b/ai_evals/core/validators.ts index e690b3d7eb..4f59368113 100644 --- a/ai_evals/core/validators.ts +++ b/ai_evals/core/validators.ts @@ -6,6 +6,7 @@ import type { CliTrace, CliValidationSpec, FlowValidationSpec, + GlobalValidationSpec, ModeRunOutput, ToolValidationSpec, } from "./types"; @@ -51,6 +52,20 @@ export interface AppDatatableState { error?: string; } +export interface GlobalDraftState { + drafts: GlobalDraft[]; +} + +export interface GlobalDraft { + type: string; + path: string; + triggerKind?: string; + summary?: string; + language?: string; + value?: unknown; + isDraft?: boolean; +} + const TS_LIKE_LANGUAGES = new Set(["bun", "deno", "nativets", "bunnative", "ts", "typescript"]); const CONTROL_FLOW_MODULE_TYPES = new Set(["branchone", "branchall", "forloopflow", "whileloopflow"]); @@ -154,6 +169,16 @@ export function validateToolExpectations(input: { ); } + for (const toolName of expect.forbiddenToolsUsed ?? []) { + checks.push( + check( + `does not use ${toolName}`, + !input.run.toolsUsed.includes(toolName), + `tools used: ${input.run.toolsUsed.join(", ") || "none"}` + ) + ); + } + for (const rule of expect.toolCallArgs ?? []) { const calls = toolCallDetails.filter((call) => call.name === rule.tool); checks.push( @@ -202,6 +227,161 @@ export function validateToolExpectations(input: { return checks; } +export function validateGlobalState(input: { + actual: GlobalDraftState; + expected?: GlobalDraftState; + validate?: GlobalValidationSpec; +}): BenchmarkCheck[] { + const drafts = input.actual.drafts ?? []; + const checks: BenchmarkCheck[] = []; + + // Read-only global cases are valid; only enforce draft production when the + // case explicitly asks for draft output. + if (globalValidationExpectsDrafts(input)) { + checks.push( + check( + "global produced at least one draft", + drafts.length > 0, + `drafts=${drafts.length}` + ) + ); + } + + checks.push( + check( + "all global outputs are drafts", + drafts.every((draft) => draft.isDraft === true), + summarizeGlobalDrafts(drafts) + ) + ); + + for (const draft of drafts) { + if (draft.type !== "script" || typeof draft.value !== "string") { + continue; + } + + const language = (draft.language ?? "bun").toLowerCase(); + const syntaxErrors = getScriptSyntaxErrors(draft.value, language); + if (TS_LIKE_LANGUAGES.has(language)) { + checks.push( + check( + `script draft ${draft.path} exports entrypoint`, + hasSupportedEntrypoint(draft.value) + ) + ); + } + checks.push( + check( + `script draft ${draft.path} has no syntax errors`, + syntaxErrors.length === 0, + summarizeProblems(syntaxErrors) + ) + ); + } + + if (input.expected) { + checks.push( + check( + "global drafts match expected", + globalDraftStatesEqual(input.actual, input.expected), + describeGlobalDraftStateMismatch(input.actual, input.expected) + ) + ); + } + + const validate = input.validate; + if (!validate) { + return checks; + } + + if (validate.draftCountAtLeast !== undefined) { + checks.push( + check( + `global includes at least ${validate.draftCountAtLeast} draft(s)`, + drafts.length >= validate.draftCountAtLeast, + `drafts=${drafts.length}` + ) + ); + } + + if (validate.draftCountExactly !== undefined) { + checks.push( + check( + `global includes exactly ${validate.draftCountExactly} draft(s)`, + drafts.length === validate.draftCountExactly, + `drafts=${drafts.length}` + ) + ); + } + + for (const required of validate.requiredDrafts ?? []) { + const draft = findGlobalDraft(drafts, required.type, required.path, required.triggerKind); + checks.push( + check( + `global includes ${required.type} draft ${required.path}`, + Boolean(draft), + summarizeGlobalDrafts(drafts) + ) + ); + if (!draft) { + continue; + } + + if (required.language !== undefined) { + checks.push( + check( + `${required.type} draft ${required.path} uses ${required.language}`, + draft.language === required.language, + `language=${draft.language ?? "(none)"}` + ) + ); + } + + for (const snippet of required.summaryIncludes ?? []) { + checks.push( + check( + `${required.type} draft ${required.path} summary includes '${snippet}'`, + normalizeText(draft.summary ?? "").includes(normalizeText(snippet)), + `summary=${draft.summary ?? ""}` + ) + ); + } + + const valueText = stringifyGlobalDraftValue(draft.value); + for (const snippet of required.valueIncludes ?? []) { + checks.push( + check( + `${required.type} draft ${required.path} value includes '${snippet}'`, + normalizeText(valueText).includes(normalizeText(snippet)), + truncateForDetails(valueText) + ) + ); + } + + for (const snippet of required.valueExcludes ?? []) { + checks.push( + check( + `${required.type} draft ${required.path} value excludes '${snippet}'`, + !normalizeText(valueText).includes(normalizeText(snippet)), + truncateForDetails(valueText) + ) + ); + } + } + + for (const forbidden of validate.forbiddenDrafts ?? []) { + checks.push( + check( + `global does not include ${forbidden.type} draft ${forbidden.path}`, + !findGlobalDraft(drafts, forbidden.type, forbidden.path, forbidden.triggerKind), + summarizeGlobalDrafts(drafts) + ) + ); + } + + return checks; +} + export function validateAppState(input: { actual: AppFilesState; initial?: AppFilesState; @@ -433,6 +613,202 @@ function summarizeProblems(problems: string[], limit = 5): string | undefined { return `${problems.slice(0, limit).join("; ")}; ...and ${problems.length - limit} more`; } +function findGlobalDraft( + drafts: GlobalDraft[], + type: string, + path: string, + triggerKind?: string +): GlobalDraft | undefined { + return drafts.find( + (draft) => + draft.type === type && + draft.path === path && + (triggerKind === undefined || draft.triggerKind === triggerKind) + ); +} + +function summarizeGlobalDrafts(drafts: GlobalDraft[]): string { + const summary = drafts + .map((draft) => formatGlobalDraftKey(draft)) + .join(", "); + return `drafts: ${summary || "none"}`; +} + +function formatGlobalDraftKey(draft: GlobalDraft): string { + return `${draft.type}${draft.triggerKind ? `:${draft.triggerKind}` : ""}:${draft.path}`; +} + +function globalValidationExpectsDrafts(input: { + expected?: GlobalDraftState; + validate?: GlobalValidationSpec; +}): boolean { + const validate = input.validate; + return ( + (input.expected?.drafts?.length ?? 0) > 0 || + (validate?.requiredDrafts?.length ?? 0) > 0 || + (validate?.draftCountAtLeast ?? 0) > 0 || + (validate?.draftCountExactly ?? 0) > 0 + ); +} + +function globalDraftStatesEqual(left: GlobalDraftState, right: GlobalDraftState): boolean { + return ( + JSON.stringify(canonicalizeGlobalDrafts(left.drafts ?? [])) === + JSON.stringify(canonicalizeGlobalDrafts(right.drafts ?? [])) + ); +} + +function describeGlobalDraftStateMismatch( + actual: GlobalDraftState, + expected: GlobalDraftState +): string { + const actualDrafts = actual.drafts ?? []; + const expectedDrafts = expected.drafts ?? []; + const actualByKey = new Map( + actualDrafts.map((draft) => [globalDraftSortKey(draft), draft] as const) + ); + const expectedByKey = new Map( + expectedDrafts.map((draft) => [globalDraftSortKey(draft), draft] as const) + ); + + for (const key of Array.from(expectedByKey.keys()).sort()) { + const expectedDraft = expectedByKey.get(key); + if (expectedDraft && !actualByKey.has(key)) { + return `missing expected draft ${formatGlobalDraftKey(expectedDraft)}; actual=${summarizeGlobalDrafts(actualDrafts)}`; + } + } + + for (const key of Array.from(actualByKey.keys()).sort()) { + const actualDraft = actualByKey.get(key); + if (actualDraft && !expectedByKey.has(key)) { + return `unexpected draft ${formatGlobalDraftKey(actualDraft)}; expected=${summarizeGlobalDrafts(expectedDrafts)}`; + } + } + + for (const key of Array.from(expectedByKey.keys()).sort()) { + const actualDraft = actualByKey.get(key); + const expectedDraft = expectedByKey.get(key); + if (!actualDraft || !expectedDraft) { + continue; + } + + const fieldMismatch = describeGlobalDraftFieldMismatch( + formatGlobalDraftKey(expectedDraft), + actualDraft, + expectedDraft + ); + if (fieldMismatch) { + return fieldMismatch; + } + } + + return `actual=${summarizeGlobalDrafts(actualDrafts)}; expected=${summarizeGlobalDrafts(expectedDrafts)}`; +} + +function describeGlobalDraftFieldMismatch( + key: string, + actual: GlobalDraft, + expected: GlobalDraft +): string | undefined { + const fields: Array<"language" | "summary" | "value" | "isDraft"> = [ + "language", + "summary", + "value", + "isDraft", + ]; + + for (const field of fields) { + const actualValue = comparableGlobalDraftFieldValue(actual, field); + const expectedValue = comparableGlobalDraftFieldValue(expected, field); + if (JSON.stringify(actualValue) === JSON.stringify(expectedValue)) { + continue; + } + + return `${key} ${field} differs: actual=${formatGlobalDraftFieldValue( + actualValue + )}; expected=${formatGlobalDraftFieldValue(expectedValue)}`; + } + + return undefined; +} + +function comparableGlobalDraftFieldValue( + draft: GlobalDraft, + field: "language" | "summary" | "value" | "isDraft" +): unknown { + if (field === "summary" && typeof draft.summary === "string") { + return normalizeText(draft.summary); + } + if (field === "value" && typeof draft.value === "string") { + return normalizeText(draft.value); + } + if (field === "value") { + return canonicalizeJsonValue(draft.value); + } + return draft[field]; +} + +function formatGlobalDraftFieldValue(value: unknown): string { + if (value === undefined) { + return "(missing)"; + } + return truncateForDetails(JSON.stringify(value), 300); +} + +function canonicalizeGlobalDrafts(drafts: GlobalDraft[]): unknown[] { + return drafts + .slice() + .sort((left, right) => globalDraftSortKey(left).localeCompare(globalDraftSortKey(right))) + .map((draft) => + canonicalizeJsonValue({ + type: draft.type, + path: draft.path, + triggerKind: draft.triggerKind, + language: draft.language, + summary: + typeof draft.summary === "string" ? normalizeText(draft.summary) : draft.summary, + value: + typeof draft.value === "string" + ? normalizeText(draft.value) + : canonicalizeJsonValue(draft.value), + isDraft: draft.isDraft, + }) + ); +} + +function globalDraftSortKey(draft: GlobalDraft): string { + return `${draft.type}:${draft.triggerKind ?? ""}:${draft.path}`; +} + +function canonicalizeJsonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalizeJsonValue); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => [key, canonicalizeJsonValue(nested)]) + ); + } + return value; +} + +function stringifyGlobalDraftValue(value: unknown): string { + if (typeof value === "string") { + return value; + } + return JSON.stringify(value ?? null, null, 2); +} + +function truncateForDetails(value: string, maxLength = 500): string { + const normalized = value.replace(/\s+/g, " ").trim(); + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`; +} + function validateCliExpectations( assistantOutput: string, trace: CliTrace | undefined, diff --git a/ai_evals/fixtures/frontend/global/initial/format_greeting_script.json b/ai_evals/fixtures/frontend/global/initial/format_greeting_script.json new file mode 100644 index 0000000000..e66eee2ed2 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/format_greeting_script.json @@ -0,0 +1,23 @@ +{ + "workspace": { + "scripts": [ + { + "path": "f/evals/global/format_greeting", + "summary": "Format a greeting for a provided name", + "description": "Returns a plain greeting for the provided name.", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + }, + "content": "export async function main(name: string) {\n return `Hello, ${name}`\n}\n" + } + ] + } +} diff --git a/ai_evals/modes/global.ts b/ai_evals/modes/global.ts new file mode 100644 index 0000000000..d68df9f5f8 --- /dev/null +++ b/ai_evals/modes/global.ts @@ -0,0 +1,81 @@ +import { readFile } from "node:fs/promises"; +import { runGlobalEval } from "../adapters/frontend/core/global/globalEvalRunner"; +import type { BenchmarkWorkspaceRunnables } from "../adapters/frontend/mockBackend"; +import type { FrontendEvalModelConfig } from "../core/models"; +import type { BenchmarkArtifactFile, GlobalValidationSpec, ModeRunner } from "../core/types"; +import { validateGlobalState, type GlobalDraftState } from "../core/validators"; +import type { WindmillBackendSettings } from "../core/windmillBackendSettings"; +import { getFrontendApiKey } from "./frontendCommon"; + +export interface GlobalInitialFixture { + workspace?: BenchmarkWorkspaceRunnables; +} + +export function createGlobalModeRunner( + modelConfig: FrontendEvalModelConfig, + backendSettings: WindmillBackendSettings, +): ModeRunner { + return { + mode: "global", + concurrency: 3, + judgeThreshold: 80, + async loadInitial(path) { + return path ? await loadGlobalInitialFixture(path) : undefined; + }, + async loadExpected(path) { + return path ? await loadGlobalExpectedFixture(path) : undefined; + }, + async run(prompt, initial, context) { + const result = await runGlobalEval( + prompt, + getFrontendApiKey(modelConfig.provider), + { + workspaceFixtures: initial?.workspace, + maxIterations: context.evalCase?.runtime?.maxTurns, + provider: modelConfig.provider, + model: modelConfig.model, + backend: backendSettings, + runContext: context, + }, + ); + + return { + success: result.success, + actual: result.state, + error: result.error, + assistantMessageCount: result.assistantMessageCount, + toolCallCount: result.toolCallCount, + toolsUsed: result.toolsUsed, + toolCallDetails: result.toolCallDetails, + skillsInvoked: [], + tokenUsage: result.tokenUsage, + }; + }, + validate({ evalCase, actual, expected }) { + return validateGlobalState({ + actual, + expected, + validate: evalCase.validate as GlobalValidationSpec | undefined, + }); + }, + buildArtifacts(actual): BenchmarkArtifactFile[] { + return [ + { + path: "global-drafts.json", + content: JSON.stringify(actual, null, 2) + "\n", + }, + ]; + }, + }; +} + +async function loadGlobalInitialFixture(path: string): Promise { + const parsed = JSON.parse(await readFile(path, "utf8")) as GlobalInitialFixture; + return { + workspace: parsed.workspace ?? {}, + }; +} + +async function loadGlobalExpectedFixture(path: string): Promise { + return JSON.parse(await readFile(path, "utf8")) as GlobalDraftState; +} From 2ec1863340e759bba3408dbc4f41b16912b959ea Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 13 May 2026 13:35:30 +0200 Subject: [PATCH 106/313] fix: scope promotion-mode debounce key per repo (#9145) * test(git-sync): regression tests for secondary promotion repos Adds two integration tests that reproduce the bug where a second promotion-mode repo's deployment callback was silently dropped via debounce-key collision, plus the EE ref bump that includes the fix. Updates the two existing promotion-mode debounce-key tests to expect the new repo-namespaced key shape. * test(git-sync): drop redundant distinct-debounce-keys test The behavior test (`test_two_promotion_repos_both_enqueue_callback`) already covers the same regression one layer up: if the debounce keys collide, one callback gets marked skipped, which the behavior test catches. * chore: update ee-repo-ref to 7a32388adaa37eb1dd1820b40e140ff1877110f2 This commit updates the EE repository reference after PR #572 was merged in windmill-ee-private. Previous ee-repo-ref: dbf26f5e4c01c0de536f606679be46eb316aaf31 New ee-repo-ref: 7a32388adaa37eb1dd1820b40e140ff1877110f2 Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- .../tests/workspace_dependencies_git_sync.rs | 171 +++++++++++++++++- 2 files changed, 167 insertions(+), 6 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 8a34b69a0e..cad5dee291 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -f9494c6320bb5fd07c1e9e09734b7fd5fbe7aa38 +7a32388adaa37eb1dd1820b40e140ff1877110f2 diff --git a/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs b/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs index 0049280aec..a5af874f8c 100644 --- a/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs +++ b/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs @@ -573,8 +573,10 @@ async fn test_promotion_individual_branch_debounces_per_path( // Wait for both deployment callbacks to resolve a debounce key. The // alpha key is polled first as a warm-up, then we also wait for beta // so the assertions don't race the second spawned callback. - let expected_alpha = "git_sync:script:f/target/alpha"; - let expected_beta = "git_sync:script:f/target/beta"; + // Keys are namespaced by the repo's resource path so multiple promotion + // repos don't collide on the same key. + let expected_alpha = "git_sync:$res:u/test-user/test_git_repo:script:f/target/alpha"; + let expected_beta = "git_sync:$res:u/test-user/test_git_repo:script:f/target/beta"; let _ = wait_for_debounce_key(&db, expected_alpha, Duration::from_secs(5)).await?; let keys = wait_for_debounce_key(&db, expected_beta, Duration::from_secs(5)).await?; assert!( @@ -589,6 +591,162 @@ async fn test_promotion_individual_branch_debounces_per_path( Ok(()) } +/// Create a second git repository resource for multi-repo tests. +#[allow(dead_code)] +async fn create_second_git_repo_resource(db: &Pool) -> anyhow::Result<()> { + sqlx::query( + r#" + INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) + VALUES ('test-workspace', 'u/test-user/test_git_repo_2', $1::jsonb, 'git_repository', '{}'::jsonb, 'test-user') + ON CONFLICT (workspace_id, path) DO NOTHING + "#, + ) + .bind(json!({ + "url": "https://github.com/test/test2.git", + "branch": "main", + "token": "test-token-2" + })) + .execute(db) + .await?; + Ok(()) +} + +/// Configure git sync with TWO promotion-mode repositories pointing at distinct +/// git repo resources. Both repos use the same sync script and the same item +/// filters — they only differ in the repo they target. +#[allow(dead_code)] +async fn setup_two_promotion_repos_config( + db: &Pool, + sync_script_path: &str, + group_by_folder: bool, +) -> anyhow::Result<()> { + let git_sync_config = json!({ + "include_type": ["script"], + "include_path": ["**"], + "repositories": [ + { + "script_path": sync_script_path, + "git_repo_resource_path": "$res:u/test-user/test_git_repo", + "use_individual_branch": true, + "group_by_folder": group_by_folder + }, + { + "script_path": sync_script_path, + "git_repo_resource_path": "$res:u/test-user/test_git_repo_2", + "use_individual_branch": true, + "group_by_folder": group_by_folder + } + ] + }); + + sqlx::query!( + "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", + git_sync_config, + "test-workspace" + ) + .execute(db) + .await?; + + Ok(()) +} + +/// Regression test for: when two promotion-mode repos are configured with the +/// same `use_individual_branch=true` settings (i.e. a primary and a secondary +/// promotion repo), deploying a single script must enqueue ONE deployment +/// callback per repo. Both callbacks must remain in the queue — neither may +/// be debounced into oblivion by the other. +/// +/// The bug this guards against: the debounce key for promotion mode was +/// derived only from (path_type, path) and omitted any per-repo identifier, +/// so the second repo's push hit ON CONFLICT in `upsert_debounce_key` and +/// `complete_debounced_job` flagged the first repo's job as `status='skipped'` +/// — silently dropping one of the two pushes. +#[cfg(all(feature = "enterprise", feature = "private"))] +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_two_promotion_repos_both_enqueue_callback(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + create_folder(&db, "28103").await?; + create_folder(&db, "target").await?; + create_git_repo_resource(&db).await?; + create_second_git_repo_resource(&db).await?; + let sync_script_path = "f/28103/test_sync_two_promotion_repos"; + create_sync_script(&db, sync_script_path).await?; + setup_two_promotion_repos_config(&db, sync_script_path, false).await?; + + let (client, _port, _server) = init_client(db.clone()).await; + + // Deploy a single script — handle_deployment_metadata should iterate + // both repos and create one callback job per repo. + create_test_script(&client, "f/target/alpha").await?; + + // Both callbacks should reach the queue. With the bug, only one survives + // (the other is moved to v2_job_completed with status='skipped'). + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + let mut last_jobs: Vec = vec![]; + loop { + last_jobs = + get_deployment_callback_jobs(&db, sync_script_path, Duration::from_millis(200)).await?; + if last_jobs.len() >= 2 { + break; + } + if tokio::time::Instant::now() >= deadline { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // Inspect what landed in v2_job_completed so failure messages explain why. + let skipped: Vec<(uuid::Uuid, String)> = sqlx::query_as( + r#" + SELECT c.id, c.status::text + FROM v2_job_completed c + JOIN v2_job j ON j.id = c.id + WHERE j.runnable_path = $1 AND j.kind = 'deploymentcallback' + "#, + ) + .bind(sync_script_path) + .fetch_all(&db) + .await?; + + assert_eq!( + last_jobs.len(), + 2, + "expected 2 deployment callback jobs in v2_job_queue (one per promotion repo), got {} queued + {:?} completed", + last_jobs.len(), + skipped, + ); + + // Per-repo args sanity check: the two jobs must target different repos. + let mut repo_paths: Vec = last_jobs + .iter() + .filter_map(|j| { + j.args + .as_ref() + .and_then(|a| a.get("repo_url_resource_path")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .collect(); + repo_paths.sort(); + repo_paths.dedup(); + assert_eq!( + repo_paths.len(), + 2, + "expected callbacks to target two distinct repos, got: {:?}", + last_jobs.iter().map(|j| &j.args).collect::>() + ); + + // No callback should have been silently skipped via debouncing collision. + assert!( + skipped.iter().all(|(_, s)| s != "skipped"), + "no deployment callback should be marked skipped, got: {:?}", + skipped, + ); + + Ok(()) +} + /// Promotion mode with group_by_folder: items destined for the same per-folder /// branch must share one debounce key so they accumulate into a single sync /// job; scripts in different folders must get distinct keys. @@ -615,8 +773,9 @@ async fn test_promotion_group_by_folder_debounces_per_folder( // One in a different folder — should get its own key. create_test_script(&client, "f/other/gamma").await?; - let expected_grouped = "git_sync:folder:f/grouped"; - let expected_other = "git_sync:folder:f/other"; + // Keys are namespaced by the repo's resource path. + let expected_grouped = "git_sync:$res:u/test-user/test_git_repo:folder:f/grouped"; + let expected_other = "git_sync:$res:u/test-user/test_git_repo:folder:f/other"; // Wait for BOTH folder keys to appear, not just the first one. let keys = wait_for_debounce_key(&db, expected_other, Duration::from_secs(5)).await?; assert!( @@ -629,7 +788,9 @@ async fn test_promotion_group_by_folder_debounces_per_folder( ); // Paths within the same folder must NOT leak as their own keys. assert!( - !keys.iter().any(|k| k.starts_with("git_sync:script:")), + !keys + .iter() + .any(|k| k.contains(":script:f/grouped/") || k.contains(":script:f/other/")), "group_by_folder mode should not emit per-path keys, got: {keys:?}" ); From 818cb31fbc731fa5c70ddf5942bb37bc4bc56e4d Mon Sep 17 00:00:00 2001 From: "windmill-internal-app[bot]" <217088191+windmill-internal-app[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 12:32:35 +0000 Subject: [PATCH 107/313] fix: send flow push-loop ping outside transaction so zombie monitor sees it (#9136) * fix: send flow push-loop ping outside transaction so zombie monitor sees it * fix: keep flow push-loop ping using now() with reusable sqlx cache --------- Co-authored-by: windmill-internal-app[bot] <1429786+windmill-internal-app[bot]@users.noreply.github.com> Co-authored-by: Ruben Fiszel --- backend/windmill-worker/src/worker_flow.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 53c94a7458..68ca83c270 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -3919,11 +3919,14 @@ async fn push_next_flow_job( for (i, payload_tag) in job_payloads.into_iter().enumerate() { if i % 100 == 0 && i != 0 { tracing::info!(id = %flow_job.id, root_id = %job_root, "pushed (non-commited yet) first {i} subflows of {len}"); + // Ping on the pool, outside `tx`, so the zombie flow monitor sees it before the + // push transaction commits — otherwise large parallel pushes can be flagged as + // zombie and trigger a cancel/push deadlock. sqlx::query!( - "UPDATE v2_job_runtime SET ping = now() WHERE id = $1 AND ping < now()", + "UPDATE v2_job_runtime SET ping = now() WHERE id = $1", flow_job.id, ) - .execute(&mut *tx) + .execute(db) .warn_after_seconds(3) .await?; } From d243e0cde899b6d34ee6d54be76bd46f08c4b64a Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 13 May 2026 14:38:49 +0200 Subject: [PATCH 108/313] align global flow tool arguments (#9146) --- ai_evals/cases/global.yaml | 34 +++++ .../copilot/chat/global/core.test.ts | 77 ++++++++-- .../components/copilot/chat/global/core.ts | 144 +++++++++++------- 3 files changed, 191 insertions(+), 64 deletions(-) diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 709081942b..b4526f8a85 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -53,3 +53,37 @@ - uppercases the provided name in the greeting - returns a message ending with an exclamation mark - does not deploy or save the draft to the workspace + +- id: global-test3-flow-create + prompt: |- + Create a draft flow at `f/evals/global/sum_numbers`. + It should take two numeric inputs, `a` and `b`, and return their sum. + Leave it as an AI draft only; do not deploy or save it. + runtime: + maxTurns: 8 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: flow + path: f/evals/global/sum_numbers + valueIncludes: + - modules + - rawscript + - flow_input.a + - flow_input.b + toolExpect: + requiredToolsUsed: + - write_flow + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: write_flow + field: modules + stringStartsWithAnyOf: + - "[" + judgeChecklist: + - creates a flow draft at f/evals/global/sum_numbers + - the flow accepts numeric inputs a and b + - the flow returns the sum of a and b + - the result stays as an AI draft and is not deployed or saved to the workspace 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 2664c1495d..2732db4dac 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -113,19 +113,17 @@ describe('global AI tools', () => { await callGlobalTool('write_flow', { path: 'f/flows/empty-module', summary: 'Flow with empty module', - value: JSON.stringify({ - modules: [ - { - id: 'empty_step', - value: { - type: 'rawscript', - language: 'bun', - content: '', - input_transforms: {} - } + modules: JSON.stringify([ + { + id: 'empty_step', + value: { + type: 'rawscript', + language: 'bun', + content: '', + input_transforms: {} } - ] - }) + } + ]) }) const code = 'export async function main() {\n\treturn 42\n}' @@ -145,4 +143,59 @@ describe('global AI tools', () => { }) ).resolves.toBe(code) }) + + it('writes flows with flow-mode arguments and reads compact flow value', async () => { + const writeResult = JSON.parse( + await callGlobalTool('write_flow', { + path: 'f/flows/with-schema-and-groups', + summary: 'Flow with schema and groups', + modules: JSON.stringify([ + { + id: 'start', + summary: 'Start', + value: { + type: 'identity' + } + } + ]), + schema: JSON.stringify({ + type: 'object', + properties: { + name: { type: 'string' } + }, + required: ['name'] + }), + groups: JSON.stringify([{ summary: 'Main', start_id: 'start', end_id: 'start' }]) + }) + ) + + expect(writeResult.item.value.value).toBeUndefined() + + const raw = await callGlobalTool('read_workspace_item', { + type: 'flow', + path: 'f/flows/with-schema-and-groups' + }) + const item = JSON.parse(raw) + + expect(item.value).toMatchObject({ + modules: [ + { + id: 'start', + summary: 'Start', + value: { type: 'identity' } + } + ], + schema: { + type: 'object', + properties: { + name: { type: 'string' } + }, + required: ['name'] + }, + preprocessor_module: null, + failure_module: null, + groups: [{ summary: 'Main', start_id: 'start', end_id: 'start' }] + }) + expect(item.value.value).toBeUndefined() + }) }) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index de18426a3c..210636e43d 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -37,6 +37,7 @@ import { import { applyEditableFlowJsonToFlow, buildEditableFlowJson, + type EditableFlowJson, validateEditableFlowJson } from '../flow/editableFlowJson' import { createInlineScriptSession } from '../flow/inlineScriptsUtils' @@ -59,7 +60,6 @@ import { type ToolCallbacks, type ToolDisplayAction } from '../shared' -import { flowModuleSchema, flowModulesSchema } from '../flow/openFlowZod.gen' import { resourceRequestSchema, scheduleRequestSchema, @@ -149,22 +149,6 @@ const writeScriptSchema = z.object({ content: z.string().describe('Full script source code.') }) -const flowValueSchema = z - .looseObject({ - modules: flowModulesSchema.describe('Sequential flow modules.'), - preprocessor_module: flowModuleSchema - .nullable() - .optional() - .describe( - "Optional preprocessor module with id 'preprocessor'. Runs before normal modules; cannot reference results.*." - ), - failure_module: flowModuleSchema - .nullable() - .optional() - .describe("Optional failure handler module with id 'failure'.") - }) - .describe('OpenFlow value: modules plus optional preprocessor_module and failure_module.') - const readFlowModuleCodeSchema = z.object({ path: z.string().describe('Workspace path of the flow.'), module_id: z @@ -184,24 +168,81 @@ const setFlowModuleCodeSchema = z.object({ code: z.string().describe('New script source. Replaces the module\'s value.content entirely.') }) -// `value` is taken as a JSON string rather than a typed object because the -// underlying flowValueSchema is recursive (modules can contain modules), which -// makes z.toJSONSchema emit $defs/$ref. Gemini's tools API rejects those -// keywords ("Unknown name $ref/$defs"). The string is parsed and validated -// against flowValueSchema inside the handler. Same trick as set_flow_json in -// chat/flow/core.ts (see comment on its schema). +// Flow structure fields are taken as JSON strings rather than typed objects +// because the underlying flow module schema is recursive (modules can contain +// modules), which makes z.toJSONSchema emit $defs/$ref. Gemini's tools API +// 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.'), summary: z.string().optional().describe('Short human-readable summary.'), - value: z + modules: z.string().describe('JSON string containing the complete flow modules array.'), + schema: z .string() + .optional() + .nullable() + .describe('JSON string containing the flow input schema.'), + preprocessor_module: z + .string() + .optional() + .nullable() + .describe('JSON string containing the optional preprocessor module.'), + failure_module: z + .string() + .optional() + .nullable() + .describe('JSON string containing the optional failure module.'), + groups: z + .string() + .optional() + .nullable() .describe( - 'JSON string of the OpenFlow value object: { modules, preprocessor_module?, failure_module? }. Pass it as a JSON-encoded string, not a nested object.' + 'JSON string containing the optional array of semantic flow groups. Pass null to clear groups.' ) }) +function parseOptionalJsonArg(value: unknown, field: string): unknown { + if (value === undefined || value === null) { + return value + } + + try { + return typeof value === 'string' ? JSON.parse(value) : value + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Invalid JSON for ${field}: ${message}`) + } +} + +function editableFlowToDraftValue(editable: EditableFlowJson): FlowDraftValue { + const value: FlowValue = { + modules: editable.modules, + preprocessor_module: editable.preprocessor_module ?? undefined, + failure_module: editable.failure_module ?? undefined, + groups: editable.groups ?? undefined + } + return { + value, + schema: editable.schema, + groups: editable.groups + } +} + +function flowDraftAsEditableInput(flowDraft: FlowDraftValue): { + value: FlowValue + schema?: Record | null | undefined +} { + return { + value: + flowDraft.groups === undefined + ? flowDraft.value + : { ...flowDraft.value, groups: flowDraft.groups ?? undefined }, + schema: flowDraft.schema + } +} + const writeScheduleSchema = scheduleRequestSchema const writeTriggerSchema = z.object({ @@ -428,7 +469,7 @@ Important rules: - 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. -- A workspace item is { type, path, summary?, language?, triggerKind?, value, isDraft }. For scripts, value is the source code string. For flows, value is { value: , schema, groups } so the inputs schema and groups round-trip through deploy. 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. +- 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. @@ -496,7 +537,7 @@ function serializeWorkspaceItemForRead(item: WorkspaceItem): unknown { if (item.type !== 'flow' || !item.value) return item const flowDraft = item.value as FlowDraftValue const session = createInlineScriptSession() - const editable = buildEditableFlowJson(flowDraft, session) + const editable = buildEditableFlowJson(flowDraftAsEditableInput(flowDraft), session) return { type: 'flow', path: item.path, @@ -1074,8 +1115,9 @@ function getFlowInstructions(): string { return `# Global draft flow instructions - Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata. -- A flow draft is a workspace item: \`{ type: 'flow', path, summary?, value, isDraft }\` where \`value\` is \`{ value: , schema, groups }\`. The inputs schema and groups are kept alongside the OpenFlow value so deploy round-trips them. -- \`value.modules\` contains normal sequential modules. Use top-level \`value.preprocessor_module\` and \`value.failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`value.modules\`. +- \`write_flow\` mirrors flow mode's \`set_flow_json\`: pass \`path\`, optional \`summary\`, required \`modules\`, and optional \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`. The flow-structure arguments are JSON strings, matching the tool schema descriptions. +- \`read_workspace_item\` returns a compact flow \`value\` object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`. +- \`modules\` contains normal sequential modules. Use top-level \`preprocessor_module\` and \`failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`modules\`. - Every module needs a stable unique \`id\` and a useful \`summary\` when the schema supports it. - Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed. - When writing rawscript module code, call \`get_instructions\` with \`subject: "script"\` and the rawscript language first. @@ -1087,7 +1129,7 @@ function getFlowInstructions(): string { - \`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. - 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 \`value\` argument is the **non-compact** OpenFlow value (rawscript content is the actual code, not a placeholder). +- \`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). # Windmill flow authoring reference @@ -1271,35 +1313,29 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( writeFlowSchema, 'write_flow', - 'Create or overwrite an AI draft flow. Does not save or deploy. Read the existing flow first when overwriting. value must be a JSON-encoded string of the OpenFlow value object.' + '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.' ), showDetails: true, streamArguments: true, showFade: true, fn: async (ctx) => { const parsed = writeFlowSchema.parse(ctx.args) - let value: unknown - try { - value = JSON.parse(parsed.value) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - throw new Error(`Invalid JSON for value: ${message}`) - } - const validated = flowValueSchema.safeParse(value) - if (!validated.success) { - throw new Error( - `Invalid flow value: ${validated.error.issues - .slice(0, 5) - .map((i) => `${i.path.join('.')}: ${i.message}`) - .join('; ')}` - ) - } + const editable = validateEditableFlowJson({ + modules: parseOptionalJsonArg(parsed.modules, 'modules'), + schema: parseOptionalJsonArg(parsed.schema, 'schema'), + preprocessor_module: parseOptionalJsonArg( + parsed.preprocessor_module, + 'preprocessor_module' + ), + failure_module: parseOptionalJsonArg(parsed.failure_module, 'failure_module'), + groups: parseOptionalJsonArg(parsed.groups, 'groups') + }) return writeDraft( { type: 'flow', path: parsed.path, summary: parsed.summary, - value: { value: validated.data as FlowValue, schema: null, groups: null }, + value: editableFlowToDraftValue(editable), isDraft: true }, ctx @@ -1683,7 +1719,7 @@ async function patchFlowJson( // model uses set_flow_module_code to change inline script bodies. const base = await loadFlowDraftValue(path, ctx.workspace) const session = createInlineScriptSession() - const editable = buildEditableFlowJson(base.flow, session) + const editable = buildEditableFlowJson(flowDraftAsEditableInput(base.flow), session) const currentJson = JSON.stringify(editable) const updatedJson = findAndReplace( currentJson, @@ -1730,7 +1766,7 @@ async function readFlowModuleCode( }) const base = await loadFlowDraftValue(args.path, workspace) const session = createInlineScriptSession() - buildEditableFlowJson(base.flow, session) + buildEditableFlowJson(flowDraftAsEditableInput(base.flow), session) const content = session.get(args.module_id) if (content === undefined) { throw new Error( @@ -1753,7 +1789,7 @@ async function setFlowModuleCode( }) const base = await loadFlowDraftValue(args.path, workspace) const session = createInlineScriptSession() - const editable = buildEditableFlowJson(base.flow, session) + const editable = buildEditableFlowJson(flowDraftAsEditableInput(base.flow), session) if (!session.has(args.module_id)) { throw new Error( `Module "${args.module_id}" is not an inline rawscript in flow "${args.path}". Use patch_flow_json or write_flow for structural changes.` @@ -2342,6 +2378,10 @@ async function writeDraft(item: WorkspaceItem, ctx: WriteDraftCtx): Promise Date: Wed, 13 May 2026 13:04:31 +0000 Subject: [PATCH 109/313] fix(bun): pass --preserve-symlinks on unbundled execution (#9147) * fix(bun): pass --preserve-symlinks on unbundled execution Bun 1.2/1.3 moved its global package cache to a content-addressed layout and the installer now creates a single directory symlink from node_modules/ to the cache entry. Without --preserve-symlinks, Bun resolves modules from each file's realpath, so any require/import inside an installed package walks up from cache_nomount/bun/... and never finds the sibling deps living under /node_modules/. This manifested as e.g. ENOENT while resolving package 'zod/v3' from '/tmp/windmill/cache_nomount/bun/@langchain/core@1.1.44@@@1/dist/...' on //nobundling scripts that pull @langchain/core, even though zod is correctly installed alongside it in node_modules. The bundled execution path already had --preserve-symlinks since #4132 (needed because we symlink the cached bundle file into the job dir). The unbundled path didn't, because at the time Bun installed via per- file hardlinks and the realpath of node_modules entries was the job dir itself. The Bun installer's layout change made the flag necessary on the unbundled path as well. Add the flag to all three unbundled `bun run` invocations: - nsjail unbundled path - non-nsjail unbundled path - dedicated worker (always unbundled) This also fixes a latent bug on the first run of any bun script that imports a package whose internals reference siblings (the build_cache path runs unbundled this round while it builds the bundle for next time). Co-Authored-By: Claude Opus 4.7 (1M context) * test(bun): regression test for nobundling + transitive require resolution Adds an integration test that mirrors the original failure: a //nobundling script importing @langchain/core, which (in its CJS internals) does require('zod/v3'). Before --preserve-symlinks was added to the unbundled bun run invocations, this failed with: ENOENT while resolving package 'zod/v3' from '.../cache_nomount/bun/@langchain/core@@@@1/dist/runnables/base.js' The test covers the non-nsjail unbundled path. Reproducibility of the pre-fix failure depends on Bun's installer choosing the directory-symlink layout for the node_modules entry (the default on Bun 1.2/1.3+ with the new content-addressed global cache that produced the user's error). Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- backend/tests/bun_jobs.rs | 57 +++++++++++++++++++++ backend/windmill-worker/src/bun_executor.rs | 3 ++ 2 files changed, 60 insertions(+) diff --git a/backend/tests/bun_jobs.rs b/backend/tests/bun_jobs.rs index fa15866bca..d51e51f58b 100644 --- a/backend/tests/bun_jobs.rs +++ b/backend/tests/bun_jobs.rs @@ -577,6 +577,63 @@ export function main() { Ok(()) } +/// Regression test: a `//nobundling` script that pulls a package whose CJS +/// internals do bare-specifier `require()` of a sibling dependency. +/// +/// Before the `--preserve-symlinks` fix, Bun 1.2/1.3+ would follow the +/// directory symlink in `node_modules/@langchain/core` to its global cache +/// entry, walk parent dirs from the cache realpath, and fail to find +/// `node_modules/zod` — producing: +/// ENOENT while resolving package 'zod/v3' from +/// '.../cache_nomount/bun/@langchain/core@@@@1/dist/runnables/base.js' +/// +/// The fix passes `--preserve-symlinks` so Bun resolves from the +/// symlink path under `/node_modules/`, where `zod` is a sibling. +#[sqlx::test(fixtures("base"))] +async fn test_bun_nobundling_transitive_require(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let content = r#"//nobundling +import { ChatPromptTemplate } from "@langchain/core/prompts"; + +export async function main() { + const tpl = ChatPromptTemplate.fromMessages([ + ["system", "you are a {role}"], + ["human", "{input}"], + ]); + const out = await tpl.formatMessages({ role: "tester", input: "ping" }); + return out.length; +} +"# + .to_owned(); + + let job = JobPayload::Code(RawCode { + hash: None, + content, + path: None, + language: ScriptLang::Bun, + lock: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + modules: None, + tag: None, + }); + + let result = run_job_in_new_worker_until_complete(&db, false, job, port) + .await + .json_result() + .unwrap(); + + assert_eq!(result, serde_json::json!(2)); + Ok(()) +} + // ============================================================================ // Native Mode Tests (requires deno_core feature) // ============================================================================ diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 5e640c1fde..97ff28cae1 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -2172,6 +2172,7 @@ try {{ "--", &BUN_PATH, "run", + "--preserve-symlinks", "-i", "--prefer-offline", "-r", @@ -2238,6 +2239,7 @@ try {{ } else { vec![ "run", + "--preserve-symlinks", "-i", "--prefer-offline", "-r", @@ -3895,6 +3897,7 @@ pub async fn start_worker( common_bun_proc_envs, vec![ "run", + "--preserve-symlinks", "-i", "--prefer-offline", "-r", From dd19e52a84fb9a9f48e3ad061b084841c2ee7464 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 13 May 2026 14:58:35 +0000 Subject: [PATCH 110/313] perf(dynselect): only retrigger when helper args actually change (#9148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(dynselect): only retrigger when helper-script args actually change Parse the inline helper's signature with the existing WASM parser and restrict the form-arg diff to keys the helper actually consumes. Typing into unrelated fields no longer queues a dynselect job every second. Falls back to the previous full-args comparison when the helper is deployed or parsing fails. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(dynselect): avoid double helper-script fetch on mount usePromise defaults to loadInit=true, so refresh() ran before the JobLoader child was bound (firing a no-op pending promise) and the $effect then fired a second refresh once the bind:this resolved. Disable loadInit so the effect owns the single first call. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(dynselect): use parser directly instead of inferArgs inferArgs mutates a Schema object we never use and goes through a shared cache; when fed an empty schema for non-main entrypoints the caller cannot reliably read back the resulting properties. Add parseEntrypointArgs that just runs the parser and returns the parameter name Set (or undefined when unknown / unsupported / has rest args / function not found). DynamicInput uses that and keeps the previous params in flight while the next parse is computing. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(dynselect): support deployed helpers in smart retrigger Add getHelperEntrypointArgs which dispatches on HelperScript.source: inline parses immediately; deployed fetches the script (or the flow's inline dyn-select code) once and caches per (workspace, kind, path, entrypoint). Without this the /scripts/get/* run view fell back to the full-args comparison and still retriggered on unrelated fields. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(dynselect): zero-arg helpers report empty deps, not unknown Codex review flagged that a valid zero-parameter entrypoint was being treated as "couldn't determine signature" and falling back to the full-args comparison. Distinguish "function found with no params" from "function not found" via the parser's auto_kind field — only the latter sets it, so empty args + auto_kind=null means a real zero-arg helper and we return an empty Set (no retrigger on unrelated fields). Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/lib/components/DynamicInput.svelte | 41 ++++++++- frontend/src/lib/infer.ts | 90 ++++++++++++++++++- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/DynamicInput.svelte b/frontend/src/lib/components/DynamicInput.svelte index 692272d50e..f4e2ba9c75 100644 --- a/frontend/src/lib/components/DynamicInput.svelte +++ b/frontend/src/lib/components/DynamicInput.svelte @@ -25,6 +25,7 @@ import { type DynamicInput } from '$lib/utils' import { deepEqual } from 'fast-equals' import { untrack } from 'svelte' + import { getHelperEntrypointArgs } from '$lib/infer' interface Props { value?: any @@ -48,7 +49,9 @@ }) let resultJobLoader: JobLoader | undefined = $state() - let _items = usePromise(getItemsFromOptions, { clearValueOnRefresh: false }) + // loadInit:false — the $effect below owns the first refresh once + // resultJobLoader is bound; without this the promise is kicked off twice. + let _items = usePromise(getItemsFromOptions, { clearValueOnRefresh: false, loadInit: false }) let items = $derived(_items.value) let filterText: string = $state('') @@ -125,9 +128,43 @@ }, 1000) }) + // Parameter names declared by the helper function. When known, we restrict + // the change-detection to only those keys so typing in unrelated form fields + // no longer retriggers the dynselect job. `undefined` means we couldn't + // determine the signature → fall back to a full-args comparison. + let helperParams = $state | undefined>(undefined) + + $effect(() => { + const script = helperScript + const ep = entrypoint + if (!script) { + helperParams = undefined + return + } + let cancelled = false + void getHelperEntrypointArgs(script, ep || undefined).then((params) => { + if (!cancelled) helperParams = params + }) + return () => { + cancelled = true + } + }) + + function filterArgs(args: Record | undefined) { + if (!args || !helperParams) return args + const filtered: Record = {} + for (const k of helperParams) { + if (k in args) filtered[k] = args[k] + } + return filtered + } + $effect(() => { ;[filterText, entrypoint, helperScript] - if (resultJobLoader && (open || neverLoaded || !deepEqual(lastArgs, nargs))) { + if ( + resultJobLoader && + (open || neverLoaded || !deepEqual(filterArgs(lastArgs), filterArgs(nargs))) + ) { neverLoaded = false lastArgs = $state.snapshot(otherArgs) _items.refresh() diff --git a/frontend/src/lib/infer.ts b/frontend/src/lib/infer.ts index 1c5ec7557e..7c8b83a64f 100644 --- a/frontend/src/lib/infer.ts +++ b/frontend/src/lib/infer.ts @@ -7,7 +7,13 @@ import { } from '$lib/gen' import { get, writable } from 'svelte/store' import type { Schema, SupportedLanguage } from './common.js' -import { emptySchema, getHubFlowIdFromPath, isHubFlowPath, sortObject } from './utils.js' +import { + type DynamicInput, + emptySchema, + getHubFlowIdFromPath, + isHubFlowPath, + sortObject +} from './utils.js' import { tick } from 'svelte' import initTsParser, { parse_deno, parse_outputs } from 'windmill-parser-wasm-ts' @@ -286,6 +292,88 @@ const SQL_LANGUAGES = [ 'duckdb' ] +/** + * Returns the parameter names of `entrypoint` in `code` (or `main` if not given), + * or `undefined` if the function can't be found, the code can't be parsed, the + * language isn't supported here, or the signature contains rest/keyword args + * (in which case the callee should fall back to a conservative full comparison). + * + * Lighter than {@link inferArgs} — does not touch any schema. + */ +export async function parseEntrypointArgs( + language: SupportedLanguage | 'bunnative' | undefined, + code: string, + entrypoint?: string +): Promise | undefined> { + if (!code) return undefined + try { + let sig: MainArgSignature + if (language === 'python3') { + await initWasmPython() + sig = JSON.parse(parse_python(code, entrypoint)) + } else if ( + language === 'deno' || + language === 'nativets' || + language === 'bun' || + language === 'bunnative' + ) { + await initWasmTs() + sig = JSON.parse(parse_deno(code, entrypoint)) + } else { + return undefined + } + if (sig.type === 'Invalid') return undefined + if (sig.star_args || sig.star_kwargs) return undefined + if (!Array.isArray(sig.args)) return undefined + // The parser sets auto_kind when no matching entrypoint function was + // found — empty args in that case means "unknown signature", not + // "function takes no params", so we fall back to a full comparison. + if (sig.args.length === 0 && sig.auto_kind != null) return undefined + return new Set(sig.args.map((a) => a.name)) + } catch { + return undefined + } +} + +const helperEntrypointCache = new Map | undefined>() + +/** + * Resolves a {@link DynamicInput.HelperScript} to its entrypoint parameter + * names. For deployed helpers it fetches the script (or the flow's inline + * dyn-select code) once and caches the result per workspace+path+entrypoint. + */ +export async function getHelperEntrypointArgs( + helper: DynamicInput.HelperScript, + entrypoint?: string +): Promise | undefined> { + if (helper.source === 'inline') { + return parseEntrypointArgs(helper.lang, helper.code, entrypoint) + } + const workspace = get(workspaceStore) + if (!workspace) return undefined + const cacheKey = `${workspace}::${helper.runnable_kind}::${helper.path}::${entrypoint ?? ''}` + if (helperEntrypointCache.has(cacheKey)) return helperEntrypointCache.get(cacheKey) + let result: Set | undefined + try { + if (helper.runnable_kind === 'script') { + const script = await ScriptService.getScriptByPath({ workspace, path: helper.path }) + result = await parseEntrypointArgs(script.language, script.content ?? '', entrypoint) + } else { + const flow = await FlowService.getFlowByPath({ workspace, path: helper.path }) + const schema = flow.schema as Record | undefined + const code = schema?.['x-windmill-dyn-select-code'] + const lang = schema?.['x-windmill-dyn-select-lang'] + if (typeof code === 'string' && typeof lang === 'string') { + result = await parseEntrypointArgs(lang as SupportedLanguage, code, entrypoint) + } + } + } catch { + result = undefined + } + helperEntrypointCache.set(cacheKey, result) + return result +} + export async function inferArgs( language: SupportedLanguage | 'bunnative' | undefined, code: string, From c5092069cbeda2c4c18bea80dd629c7c087b30bf Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 13 May 2026 15:05:25 +0000 Subject: [PATCH 111/313] fix: align script path existence check with deploy logic; hide Delete for non-admin (#9152) - exists_script_by_path now filters archived = false, matching the conflict check in create_script_internal. Previously the frontend blocked creating a new script at a path occupied only by archived scripts, even though renaming to that same path was allowed. - Hide the Delete entry in the script details "..." menu unless the user is admin. The backend delete_script_by_hash already requires admin, so non-admins would always see an error after clicking. --- ...a8a5f2e75d3b12ee4718452e82c7318b1bcf4.json | 23 ------------------- backend/windmill-api-scripts/src/scripts.rs | 22 ++++++++---------- .../scripts/get/[...hash]/+page.svelte | 18 ++++++++------- 3 files changed, 19 insertions(+), 44 deletions(-) delete mode 100644 backend/.sqlx/query-2a49e5b5486b650d96f3e9038cba8a5f2e75d3b12ee4718452e82c7318b1bcf4.json diff --git a/backend/.sqlx/query-2a49e5b5486b650d96f3e9038cba8a5f2e75d3b12ee4718452e82c7318b1bcf4.json b/backend/.sqlx/query-2a49e5b5486b650d96f3e9038cba8a5f2e75d3b12ee4718452e82c7318b1bcf4.json deleted file mode 100644 index 77f61ccc47..0000000000 --- a/backend/.sqlx/query-2a49e5b5486b650d96f3e9038cba8a5f2e75d3b12ee4718452e82c7318b1bcf4.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1)", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "exists", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "2a49e5b5486b650d96f3e9038cba8a5f2e75d3b12ee4718452e82c7318b1bcf4" -} diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index de0f38cd34..93c524f10a 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -978,12 +978,10 @@ async fn create_script_internal<'c>( .fetch_one(&mut *tx) .await?; } - let clashing_script = sqlx::query_as::<_, Script>( - &format!( - "SELECT {} FROM script WHERE path = $1 AND archived = false AND workspace_id = $2", - windmill_common::scripts::SCRIPT_COLUMNS, - ), - ) + let clashing_script = sqlx::query_as::<_, Script>(&format!( + "SELECT {} FROM script WHERE path = $1 AND archived = false AND workspace_id = $2", + windmill_common::scripts::SCRIPT_COLUMNS, + )) .bind(&ns.path) .bind(&w_id) .fetch_optional(&mut *tx) @@ -2248,7 +2246,7 @@ async fn exists_script_by_path( let path = path.to_path(); let exists = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1)", + "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false ORDER BY created_at DESC LIMIT 1)", path, w_id ) @@ -2282,12 +2280,10 @@ async fn get_script_by_hash_internal<'c>( .fetch_optional(&mut **db) .await? } else { - sqlx::query_as::<_, ScriptWithStarred>( - &format!( - "SELECT {}, NULL as starred FROM script WHERE hash = $1 AND workspace_id = $2", - windmill_common::scripts::SCRIPT_COLUMNS, - ), - ) + sqlx::query_as::<_, ScriptWithStarred>(&format!( + "SELECT {}, NULL as starred FROM script WHERE hash = $1 AND workspace_id = $2", + windmill_common::scripts::SCRIPT_COLUMNS, + )) .bind(hash) .bind(workspace_id) .fetch_optional(&mut **db) diff --git a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte index ff80e76177..12ca517490 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte @@ -565,14 +565,16 @@ }) } - menuItems.push({ - label: 'Delete', - Icon: Trash, - onclick: async () => { - deleteScript(script.hash) - }, - color: 'red' - }) + if ($userStore?.is_admin) { + menuItems.push({ + label: 'Delete', + Icon: Trash, + onclick: async () => { + deleteScript(script.hash) + }, + color: 'red' + }) + } } return menuItems From 110bef0a6e76615c7b371c5c0f5bc1f4e7a73a64 Mon Sep 17 00:00:00 2001 From: "windmill-internal-app[bot]" <217088191+windmill-internal-app[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 15:12:11 +0000 Subject: [PATCH 112/313] fix: Allow devops role to use all_workspaces runs filter in admins workspace (#9153) Co-authored-by: windmill-internal-app[bot] <1429786+windmill-internal-app[bot]@users.noreply.github.com> --- frontend/src/lib/components/RunsPage.svelte | 6 +++--- frontend/src/lib/components/runs/runsFilter.ts | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte index 86b7588099..360eb9276d 100644 --- a/frontend/src/lib/components/RunsPage.svelte +++ b/frontend/src/lib/components/RunsPage.svelte @@ -10,7 +10,7 @@ } from '$lib/gen' import { sendUserToast } from '$lib/toast' - import { userStore, workspaceStore, userWorkspaces, superadmin } from '$lib/stores' + import { userStore, workspaceStore, userWorkspaces, superadmin, devopsRole } from '$lib/stores' import { Button, ButtonType, @@ -82,7 +82,7 @@ usernames, folders, jobTriggerKinds, - isSuperAdmin: !!$superadmin, + isSuperAdminOrDevops: !!$superadmin || !!$devopsRole, isAdminsWorkspace: $workspaceStore === 'admins' }) ) @@ -750,7 +750,7 @@ )} schema={runsFilterSearchbarSchema} presets={buildRunsFilterPresets({ - isSuperadmin: !!$superadmin, + isSuperAdminOrDevops: !!$superadmin || !!$devopsRole, isAdminsWorkspace: $workspaceStore === 'admins' })} bind:value={filters.val} diff --git a/frontend/src/lib/components/runs/runsFilter.ts b/frontend/src/lib/components/runs/runsFilter.ts index cbece7aefb..9d0b85dd75 100644 --- a/frontend/src/lib/components/runs/runsFilter.ts +++ b/frontend/src/lib/components/runs/runsFilter.ts @@ -23,14 +23,14 @@ export function buildRunsFilterSearchbarSchema({ usernames, folders, jobTriggerKinds, - isSuperAdmin, + isSuperAdminOrDevops, isAdminsWorkspace }: { paths: string[] usernames: string[] folders: string[] jobTriggerKinds: JobTriggerKind[] - isSuperAdmin: boolean + isSuperAdminOrDevops: boolean isAdminsWorkspace: boolean }) { return { @@ -206,12 +206,12 @@ export function buildRunsFilterSearchbarSchema({ label: 'Show future jobs (Default: true)', description: 'Include jobs that are planned later' }, - ...(isSuperAdmin && + ...(isSuperAdminOrDevops && isAdminsWorkspace && { all_workspaces: { type: 'boolean' as const, label: 'All workspaces', - description: 'Show jobs of all workspaces (superadmin only)' + description: 'Show jobs of all workspaces (superadmin or devops only)' } }) } satisfies FilterSchemaRec @@ -230,16 +230,16 @@ export function allowWildcards(filters: Partial | undefined) } export const buildRunsFilterPresets = ({ - isSuperadmin, + isSuperAdminOrDevops, isAdminsWorkspace }: { - isSuperadmin: boolean + isSuperAdminOrDevops: boolean isAdminsWorkspace: boolean }) => [ { name: 'Hide schedules', value: 'job_trigger_kind:\\ !schedule' }, { name: 'Hide future jobs', value: 'show_future_jobs:\\ false' }, { name: 'Show skipped', value: 'show_skipped:\\ true' }, - ...(isSuperadmin && isAdminsWorkspace + ...(isSuperAdminOrDevops && isAdminsWorkspace ? [{ name: 'All workspaces', value: 'all_workspaces:\\ true' }] : []) ] From d666e8431cdbf14d9373d9ef625b5aafc50ac50a Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 13 May 2026 15:26:21 +0000 Subject: [PATCH 113/313] feat: read-only flag on API tokens (#9144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: read-only flag on API tokens, orthogonal to scopes Add a per-token `read_only` boolean set at creation time. When true, the token can only call HTTP methods classified as Read (GET/HEAD/OPTIONS). Mutating methods and job-run actions are rejected with 403, regardless of which scopes are attached. Surfaced as a prominent toggle in the standard token-creation flow and a discreet `2xs` toggle in MCP mode (where users often want write access, so we don't bias them toward enabling it). MCP enforcement: read-only tokens hide all script/flow/hub tools from `list_tools` and only see endpoint tools whose method is GET, and the runner rejects `call_tool` on anything mutating. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: review fixes for read-only token flag - Exempt /api/mcp/* and /mcp/* paths from the read-only middleware check. MCP transport runs over POST (streamable HTTP / SSE), so otherwise the middleware would 403 every MCP request before the runner could enforce read-only at the tool-call level. - Tighten is_endpoint_read_only to GET only, matching the read_only_hint that create_endpoint_annotations actually emits. - Add unit test for check_read_only_for_route covering GET/HEAD/OPTIONS, mutating methods, and run paths. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: bump ee-repo-ref to read-only-trigger-toggle Co-Authored-By: Claude Opus 4.7 (1M context) * fix(frontend): make read-only toggle discreet in both modes Match the MCP-mode treatment in standard mode: text-tertiary, 2xs, shared "Read-only" label. The tooltip switches per mode so the explanation still fits the context. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(frontend): gate read-only toggle behind Limit token permissions The read-only toggle now only shows when the user has limited the token's scopes (standard mode) or in MCP mode (which always picks an MCP scope). Turning the limit off also resets read-only so it doesn't silently stick. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): hide incompatible MCP tools when read-only is on When the read-only toggle is on in MCP mode: - Endpoint badges and the custom-mode endpoint MultiSelect filter to GET. - Already-selected non-GET endpoints are pruned from the scope. - The scripts/flows preview is replaced with a note explaining they're hidden (the runner already rejects script/flow runs for read-only). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(frontend): place read-only toggle at top of limited scope area The previous gate required at least one scope to be picked before the read-only toggle appeared, which made it look missing while the user was still building their scope list. Move the toggle inside ScopesPicker: - Standard mode: sits directly under the "Limit token permissions" toggle whenever Limit is on, before the scope selector. - MCP mode: sits at the top of the MCP scope block. readOnly is now $bindable on ScopesPicker so CreateToken still owns the value. The auto-reset on un-limit moves into ScopesPicker too. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(frontend): nest read-only toggle inside the scope list card Place the read-only toggle at the top of the scope list (between the Selected Scopes summary and the bordered domain list) via a new optional topSlot snippet on ScopeSelector. Keeps ScopeSelector decoupled from read-only specifics; ScopesPicker fills the slot. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: update ee-repo-ref to 9bc8160be50b3e57a60daf4e1b71c389a6e02b8a This commit updates the EE repository reference after PR #571 was merged in windmill-ee-private. Previous ee-repo-ref: f53d26e6685dfd60bfa67686fbd7358169cfd130 New ee-repo-ref: 9bc8160be50b3e57a60daf4e1b71c389a6e02b8a Automated by sync-ee-ref workflow. * fix: address CI review for read-only token flag - P1 (Codex): narrow the MCP middleware exemption from "any /api/mcp/*" to just the streamable HTTP transport endpoints (/api/mcp/gateway, /api/mcp/w/{ws}/{mcp,sse,list_tools}). Without this, a read-only token could POST /api/mcp/gateway/oauth/server/approve and mint a follow-on non-read-only MCP token via the OAuth code/token exchange. - P2 (Claude/cubic): fix test comment/assertion mismatch — the run-path assertion now exercises GET (which is what the RUN_PATH_ACTIONS elevation comment describes) in addition to POST. Add a regression assertion for /api/mcp/gateway/oauth/server/approve. - P2 (cubic): short-circuit script/flow/hub-script/resource fetches in MCP list_tools when read_only is on — they would only be discarded below, so skipping the DB and resource fan-out is pure win. - P2 (cubic): when scopes are pre-supplied via the CreateToken prop, the ScopesPicker isn't rendered, which previously hid the read-only toggle entirely. Render it next to the pre-supplied scopes display. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...104a5af2fd565706204b7d5d33594ff91e61e.json | 66 ++++++ ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...ef468969b5452150ee6fffe31d59b52a4f1c2.json | 23 ++ ...c9fb0714c9c91a7ea3924811d345bee94c013.json | 66 ++++++ ...43e8e250cc9772cca4a7dae3442be86f4060c.json | 53 +++++ backend/ee-repo-ref.txt | 2 +- .../20260513095235_token_read_only.down.sql | 1 + .../20260513095235_token_read_only.up.sql | 4 + backend/tests/trigger_listener_queries.rs | 1 + backend/windmill-api-auth/src/auth.rs | 54 ++++- backend/windmill-api-auth/src/lib.rs | 17 +- backend/windmill-api-auth/src/scopes.rs | 41 ++++ .../tests/native_triggers.rs | 1 + backend/windmill-api-users/src/users.rs | 5 +- backend/windmill-api/openapi.yaml | 9 + backend/windmill-api/src/lib.rs | 1 + backend/windmill-mcp/src/server/backend.rs | 7 + backend/windmill-mcp/src/server/endpoints.rs | 6 + backend/windmill-mcp/src/server/mod.rs | 2 +- backend/windmill-mcp/src/server/runner.rs | 197 ++++++++++-------- .../windmill-native-triggers/src/handler.rs | 1 + .../components/mcp/McpScopeSelector.svelte | 70 +++++-- .../components/settings/CreateToken.svelte | 18 +- .../components/settings/ScopeSelector.svelte | 17 +- .../components/settings/ScopesPicker.svelte | 57 ++++- .../components/settings/TokensTable.svelte | 11 +- 26 files changed, 604 insertions(+), 128 deletions(-) create mode 100644 backend/.sqlx/query-52777947d60d5ddd6d28852a1cf104a5af2fd565706204b7d5d33594ff91e61e.json create mode 100644 backend/.sqlx/query-9a1bf7c424154172b56bbd29b51ef468969b5452150ee6fffe31d59b52a4f1c2.json create mode 100644 backend/.sqlx/query-e001fb68c60fa736cecce52be49c9fb0714c9c91a7ea3924811d345bee94c013.json create mode 100644 backend/.sqlx/query-ed82ffc4d806a19519701e39abc43e8e250cc9772cca4a7dae3442be86f4060c.json create mode 100644 backend/migrations/20260513095235_token_read_only.down.sql create mode 100644 backend/migrations/20260513095235_token_read_only.up.sql diff --git a/backend/.sqlx/query-52777947d60d5ddd6d28852a1cf104a5af2fd565706204b7d5d33594ff91e61e.json b/backend/.sqlx/query-52777947d60d5ddd6d28852a1cf104a5af2fd565706204b7d5d33594ff91e61e.json new file mode 100644 index 0000000000..3aba64d16c --- /dev/null +++ b/backend/.sqlx/query-52777947d60d5ddd6d28852a1cf104a5af2fd565706204b7d5d33594ff91e61e.json @@ -0,0 +1,66 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes, workspace_id, read_only FROM token WHERE email = $1\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "label", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "token_prefix", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "expiration", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "read_only", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + true, + false, + true, + false, + false, + true, + true, + false + ] + }, + "hash": "52777947d60d5ddd6d28852a1cf104a5af2fd565706204b7d5d33594ff91e61e" +} diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 36ddb8ab9f..713ccb9dd3 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - true + null ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-9a1bf7c424154172b56bbd29b51ef468969b5452150ee6fffe31d59b52a4f1c2.json b/backend/.sqlx/query-9a1bf7c424154172b56bbd29b51ef468969b5452150ee6fffe31d59b52a4f1c2.json new file mode 100644 index 0000000000..b4715fa36b --- /dev/null +++ b/backend/.sqlx/query-9a1bf7c424154172b56bbd29b51ef468969b5452150ee6fffe31d59b52a4f1c2.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id, read_only)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10\n WHERE $9::varchar IS NULL OR NOT EXISTS(\n SELECT 1 FROM workspace WHERE id = $9 AND deleted = true\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Timestamptz", + "Bool", + "TextArray", + "Varchar", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "9a1bf7c424154172b56bbd29b51ef468969b5452150ee6fffe31d59b52a4f1c2" +} diff --git a/backend/.sqlx/query-e001fb68c60fa736cecce52be49c9fb0714c9c91a7ea3924811d345bee94c013.json b/backend/.sqlx/query-e001fb68c60fa736cecce52be49c9fb0714c9c91a7ea3924811d345bee94c013.json new file mode 100644 index 0000000000..d0d9bd8d95 --- /dev/null +++ b/backend/.sqlx/query-e001fb68c60fa736cecce52be49c9fb0714c9c91a7ea3924811d345bee94c013.json @@ -0,0 +1,66 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes, workspace_id, read_only FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL)\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "label", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "token_prefix", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "expiration", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "read_only", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + true, + false, + true, + false, + false, + true, + true, + false + ] + }, + "hash": "e001fb68c60fa736cecce52be49c9fb0714c9c91a7ea3924811d345bee94c013" +} diff --git a/backend/.sqlx/query-ed82ffc4d806a19519701e39abc43e8e250cc9772cca4a7dae3442be86f4060c.json b/backend/.sqlx/query-ed82ffc4d806a19519701e39abc43e8e250cc9772cca4a7dae3442be86f4060c.json new file mode 100644 index 0000000000..b1f0fc0cff --- /dev/null +++ b/backend/.sqlx/query-ed82ffc4d806a19519701e39abc43e8e250cc9772cca4a7dae3442be86f4060c.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE token SET last_used_at = now() WHERE\n token_hash = $1\n AND (expiration > NOW() OR expiration IS NULL)\n AND (workspace_id IS NULL OR workspace_id = $2)\n RETURNING owner, email, super_admin, scopes, label, read_only", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "owner", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "super_admin", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 4, + "name": "label", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "read_only", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true, + true, + false, + true, + true, + false + ] + }, + "hash": "ed82ffc4d806a19519701e39abc43e8e250cc9772cca4a7dae3442be86f4060c" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index cad5dee291..92dd5ba787 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -7a32388adaa37eb1dd1820b40e140ff1877110f2 +9bc8160be50b3e57a60daf4e1b71c389a6e02b8a diff --git a/backend/migrations/20260513095235_token_read_only.down.sql b/backend/migrations/20260513095235_token_read_only.down.sql new file mode 100644 index 0000000000..e5380a3b02 --- /dev/null +++ b/backend/migrations/20260513095235_token_read_only.down.sql @@ -0,0 +1 @@ +ALTER TABLE token DROP COLUMN IF EXISTS read_only; diff --git a/backend/migrations/20260513095235_token_read_only.up.sql b/backend/migrations/20260513095235_token_read_only.up.sql new file mode 100644 index 0000000000..4fdeb9db3a --- /dev/null +++ b/backend/migrations/20260513095235_token_read_only.up.sql @@ -0,0 +1,4 @@ +-- Add a flag to restrict a token to read-only HTTP endpoints. +-- Orthogonal to `scopes`: even if scopes grant write/run, this flag denies +-- mutating methods (POST/PUT/PATCH/DELETE) and Run actions. +ALTER TABLE token ADD COLUMN read_only BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/tests/trigger_listener_queries.rs b/backend/tests/trigger_listener_queries.rs index c169d5289a..d1c20da407 100644 --- a/backend/tests/trigger_listener_queries.rs +++ b/backend/tests/trigger_listener_queries.rs @@ -174,6 +174,7 @@ fn make_authed() -> windmill_api_auth::ApiAuthed { scopes: None, username_override: None, token_prefix: None, + read_only: false, } } diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index ac4017b81c..bda9f54bdd 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -194,6 +194,7 @@ impl AuthCache { scopes: None, username_override, token_prefix: claims.audit_span, + read_only: false, }; let job_id = claims.job_id.and_then(|j| uuid::Uuid::from_str(&j).ok()); AUTH_CACHE.insert( @@ -221,11 +222,20 @@ impl AuthCache { token_hash = $1 AND (expiration > NOW() OR expiration IS NULL) AND (workspace_id IS NULL OR workspace_id = $2) - RETURNING owner, email, super_admin, scopes, label", + RETURNING owner, email, super_admin, scopes, label, read_only", t_hash, w_id.as_ref(), ) - .map(|x| (x.owner, x.email, x.super_admin, x.scopes, x.label)) + .map(|x| { + ( + x.owner, + x.email, + x.super_admin, + x.scopes, + x.label, + x.read_only, + ) + }) .fetch_optional(&self.db) .await .ok() @@ -234,7 +244,9 @@ impl AuthCache { if let Some(user) = user_o { let authed_o = { match user { - (Some(owner), Some(email), super_admin, _, label) if w_id.is_some() => { + (Some(owner), Some(email), super_admin, _, label, read_only) + if w_id.is_some() => + { let username_override = username_override_from_label(label); if let Some((prefix, name)) = owner.split_once('/') { if prefix == "u" { @@ -280,6 +292,7 @@ impl AuthCache { scopes: None, username_override, token_prefix: Some(safe_token_prefix(token)), + read_only, }) } else { let groups = vec![name.to_string()]; @@ -305,6 +318,7 @@ impl AuthCache { scopes: None, username_override, token_prefix: Some(safe_token_prefix(token)), + read_only, }) } } else { @@ -320,10 +334,11 @@ impl AuthCache { scopes: None, username_override, token_prefix: Some(safe_token_prefix(token)), + read_only, }) } } - (_, Some(email), super_admin, scopes, label) => { + (_, Some(email), super_admin, scopes, label, read_only) => { let username_override = username_override_from_label(label); if w_id.is_some() { let row_o = sqlx::query!( @@ -368,6 +383,7 @@ impl AuthCache { scopes, username_override, token_prefix: Some(safe_token_prefix(token)), + read_only, }) } None if super_admin => Some(ApiAuthed { @@ -380,6 +396,7 @@ impl AuthCache { scopes, username_override, token_prefix: Some(safe_token_prefix(token)), + read_only, }), None => None, } @@ -394,6 +411,7 @@ impl AuthCache { scopes, username_override, token_prefix: Some(safe_token_prefix(token)), + read_only, }) } } @@ -428,6 +446,7 @@ impl AuthCache { scopes: None, username_override: None, token_prefix: Some(safe_token_prefix(token)), + read_only: false, }; Some(OptJobAuthed { authed, job_id: None }) } else { @@ -630,6 +649,7 @@ pub async fn resolve_opt_job_authed( scopes: None, username_override: None, token_prefix: None, + read_only: false, }; return Ok((OptJobAuthed { authed, job_id: None }, parts)); } @@ -667,12 +687,11 @@ pub async fn resolve_opt_job_authed( cache.get_opt_job_authed(workspace_id.clone(), &token).await { let authed = &mut opt_job_authed.authed; + let path = original_uri.path(); + let method = parts.method.as_str(); if authed.scopes.is_some() { transform_old_scope_to_new_scope(authed.scopes.as_mut()); - let path = original_uri.path(); - let method = parts.method.as_str(); - if let Err(err) = crate::scopes::check_scopes_for_route( authed.scopes.as_deref(), path, @@ -681,6 +700,27 @@ pub async fn resolve_opt_job_authed( return Err((err, parts)); } } + if authed.read_only { + // MCP transport runs over POST (streamable HTTP / SSE handshake), + // so the middleware can't safely reject mutating methods on it — + // the MCP runner itself filters out write tools and rejects + // mutating tool calls for read-only tokens. Narrow to the actual + // transport endpoints: anything else under `/api/mcp/*` (OAuth + // approve, token exchange, client registration) must still go + // through the read-only check, otherwise a read-only token + // could approve an OAuth flow that mints a new non-read-only + // token. + let is_mcp_transport = path == "/api/mcp/gateway" + || (path.starts_with("/api/mcp/w/") + && (path.ends_with("/mcp") + || path.ends_with("/sse") + || path.ends_with("/list_tools"))); + if !is_mcp_transport { + if let Err(err) = crate::scopes::check_read_only_for_route(path, method) { + return Err((err, parts)); + } + } + } parts.extensions.insert(authed.clone()); Span::current().record("username", &authed.username.as_str()); diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index c5b9b5adc7..b9bc2e2417 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -55,6 +55,7 @@ pub struct ApiAuthed { pub scopes: Option>, pub username_override: Option, pub token_prefix: Option, + pub read_only: bool, } impl ApiAuthed { @@ -103,6 +104,7 @@ impl From for ApiAuthed { scopes: value.scopes, username_override: None, token_prefix: value.token_prefix, + read_only: false, } } } @@ -183,6 +185,10 @@ impl windmill_mcp::server::McpAuth for ApiAuthed { fn scopes(&self) -> Option<&[String]> { self.scopes.as_deref() } + + fn read_only(&self) -> bool { + self.read_only + } } // ------------ Utility functions ------------ @@ -478,6 +484,7 @@ pub async fn fetch_api_authed_from_permissioned_as( scopes: authed.scopes, username_override: None, token_prefix: authed.token_prefix, + read_only: false, }; API_AUTHED_CACHE.insert( @@ -506,6 +513,8 @@ pub struct NewToken { pub impersonate_email: Option, pub scopes: Option>, pub workspace_id: Option, + #[serde(default)] + pub read_only: Option, } impl NewToken { @@ -515,8 +524,9 @@ impl NewToken { impersonate_email: Option, scopes: Option>, workspace_id: Option, + read_only: Option, ) -> Self { - Self { label, expiration, impersonate_email, scopes, workspace_id } + Self { label, expiration, impersonate_email, scopes, workspace_id, read_only } } } @@ -564,8 +574,8 @@ pub async fn create_token_internal( } let rows = sqlx::query!( "INSERT INTO token - (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id) - SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9 + (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id, read_only) + SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 WHERE $9::varchar IS NULL OR NOT EXISTS( SELECT 1 FROM workspace WHERE id = $9 AND deleted = true )", @@ -578,6 +588,7 @@ pub async fn create_token_internal( is_super_admin, token_config.scopes.as_ref().map(|x| x.as_slice()), token_config.workspace_id, + token_config.read_only.unwrap_or(false), ) .execute(&mut *tx) .await?; diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index 6df2f74ae8..87ca3a8862 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -686,6 +686,19 @@ fn scope_grants_access( Ok(true) } +/// Enforces a token's `read_only` flag: only methods classified as `Read` +/// (GET/HEAD/OPTIONS) are allowed. Run actions and mutating methods are +/// rejected. Independent of `scopes`. +pub fn check_read_only_for_route(route_path: &str, http_method: &str) -> Result<()> { + if map_http_method_to_action(http_method, route_path) == ScopeAction::Read { + Ok(()) + } else { + Err(Error::PermissionDenied( + "Token is read-only. Mutating endpoints are not allowed.".to_string(), + )) + } +} + /// Helper function to check if scopes allow access to a route pub fn check_scopes_for_route( token_scopes: Option<&[String]>, @@ -778,6 +791,34 @@ mod tests { assert_eq!(route_suffix, Some("flow_conversations/list".to_string())); } + #[test] + fn test_check_read_only_for_route() { + // Plain GETs pass. + assert!(check_read_only_for_route("/api/w/x/scripts/list", "GET").is_ok()); + assert!(check_read_only_for_route("/api/w/x/scripts/get/foo", "HEAD").is_ok()); + assert!(check_read_only_for_route("/api/w/x/anything", "OPTIONS").is_ok()); + + // Mutating methods are rejected. + assert!(check_read_only_for_route("/api/w/x/scripts/create", "POST").is_err()); + assert!(check_read_only_for_route("/api/w/x/scripts/update", "PUT").is_err()); + assert!(check_read_only_for_route("/api/w/x/scripts/delete", "DELETE").is_err()); + assert!(check_read_only_for_route("/api/w/x/scripts/patch", "PATCH").is_err()); + + // Run paths are rejected even on GET (map_http_method_to_action elevates + // them to Run via RUN_PATH_ACTIONS). + assert!(check_read_only_for_route("/api/w/x/jobs/run/p/f/foo", "GET").is_err()); + assert!(check_read_only_for_route("/api/w/x/jobs/run/p/f/foo", "POST").is_err()); + + // OAuth/registration endpoints under /api/mcp/* must NOT be exempted by + // the auth middleware — they go through this check on the gateway side + // because they can mint non-read-only tokens. The middleware decides + // which paths to exempt; this helper is method-only, so we just assert + // that mutating methods still fail. + assert!( + check_read_only_for_route("/api/mcp/gateway/oauth/server/approve", "POST").is_err() + ); + } + #[test] fn test_specific_scope_access() { let scopes = vec!["jobs:read".to_string()]; diff --git a/backend/windmill-api-integration-tests/tests/native_triggers.rs b/backend/windmill-api-integration-tests/tests/native_triggers.rs index 0d1530a7d9..159236ed4b 100644 --- a/backend/windmill-api-integration-tests/tests/native_triggers.rs +++ b/backend/windmill-api-integration-tests/tests/native_triggers.rs @@ -51,6 +51,7 @@ fn test_authed() -> ApiAuthed { scopes: None, username_override: None, token_prefix: None, + read_only: false, } } diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index e4540ca903..2ae6c838c1 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -297,6 +297,7 @@ pub struct TruncatedToken { pub last_used_at: chrono::DateTime, pub scopes: Option>, pub workspace_id: Option, + pub read_only: bool, } // NewToken is re-exported from windmill-api-auth above @@ -2249,7 +2250,7 @@ async fn list_tokens( sqlx::query_as!( TruncatedToken, "SELECT label, token_prefix, expiration, created_at, \ - last_used_at, scopes, workspace_id FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL) + last_used_at, scopes, workspace_id, read_only FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL) ORDER BY created_at DESC LIMIT $2 OFFSET $3", email, per_page as i64, @@ -2261,7 +2262,7 @@ async fn list_tokens( sqlx::query_as!( TruncatedToken, "SELECT label, token_prefix, expiration, created_at, \ - last_used_at, scopes, workspace_id FROM token WHERE email = $1 + last_used_at, scopes, workspace_id, read_only FROM token WHERE email = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", email, per_page as i64, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 258e268f88..953fe8e278 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -22631,10 +22631,13 @@ components: type: string workspace_id: type: string + read_only: + type: boolean required: - token_prefix - created_at - last_used_at + - read_only ExternalJwtToken: type: object @@ -22683,6 +22686,12 @@ components: 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. NewTokenImpersonate: type: object diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 0fde5cacd1..75b3469c41 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -330,6 +330,7 @@ async fn inject_agent_authed( scopes: None, username_override: None, token_prefix: None, + read_only: false, }, job_id: None, }); diff --git a/backend/windmill-mcp/src/server/backend.rs b/backend/windmill-mcp/src/server/backend.rs index 0b942353b3..7a6e007c4d 100644 --- a/backend/windmill-mcp/src/server/backend.rs +++ b/backend/windmill-mcp/src/server/backend.rs @@ -33,6 +33,13 @@ pub trait McpAuth: Send + Sync + Clone + 'static { /// Get token scopes fn scopes(&self) -> Option<&[String]>; + /// True if the token was created with the `read_only` flag. + /// When set, write-capable tools must be hidden from `list_tools` and + /// rejected by `call_tool`. Defaults to false so existing impls compile. + fn read_only(&self) -> bool { + false + } + /// Check if the user has an MCP scope fn has_mcp_scope(&self) -> bool { self.scopes() diff --git a/backend/windmill-mcp/src/server/endpoints.rs b/backend/windmill-mcp/src/server/endpoints.rs index 2401b10757..373db36eb1 100644 --- a/backend/windmill-mcp/src/server/endpoints.rs +++ b/backend/windmill-mcp/src/server/endpoints.rs @@ -26,6 +26,12 @@ pub struct EndpointTool { pub body_field_renames: Option, } +/// True if this endpoint is safe to expose to a read-only token. Mirrors the +/// `read_only_hint` computed by `create_endpoint_annotations`: only `GET`. +pub fn is_endpoint_read_only(tool: &EndpointTool) -> bool { + tool.method.as_ref() == "GET" +} + /// Convert a single endpoint tool to MCP tool pub fn endpoint_tool_to_mcp_tool(tool: &EndpointTool) -> Tool { let mut combined_properties = serde_json::Map::new(); diff --git a/backend/windmill-mcp/src/server/mod.rs b/backend/windmill-mcp/src/server/mod.rs index d489b3f429..688c12b827 100644 --- a/backend/windmill-mcp/src/server/mod.rs +++ b/backend/windmill-mcp/src/server/mod.rs @@ -12,7 +12,7 @@ pub mod tools; // Re-export main types pub use backend::{BackendResult, McpAuth, McpBackend}; -pub use endpoints::{endpoint_tool_to_mcp_tool, EndpointTool}; +pub use endpoints::{endpoint_tool_to_mcp_tool, is_endpoint_read_only, EndpointTool}; pub use runner::Runner; pub use tools::create_tool_from_item; diff --git a/backend/windmill-mcp/src/server/runner.rs b/backend/windmill-mcp/src/server/runner.rs index 0cb2962c55..6d33573d95 100644 --- a/backend/windmill-mcp/src/server/runner.rs +++ b/backend/windmill-mcp/src/server/runner.rs @@ -145,94 +145,100 @@ impl ServerHandler for Runner { parse_mcp_scopes(scopes).map_err(|e| ErrorData::internal_error(e, None))?; let favorites_only = scope_config.favorites; - - // Fetch all items concurrently - let (scripts, flows, resource_types, hub_scripts) = tokio::try_join!( - self.backend - .list_scripts(&auth, &workspace_id, favorites_only, None), - self.backend - .list_flows(&auth, &workspace_id, favorites_only, None), - self.backend.list_resource_types(&auth, &workspace_id), - async { - if let Some(ref apps) = scope_config.hub_apps { - self.backend.list_hub_scripts(Some(apps)).await - } else { - Ok(vec![]) - } - } - )?; - - // Filter items based on scope - let filtered_scripts: Vec<_> = scripts - .into_iter() - .filter(|s| !scope_config.granular || scope_config.is_allowed("script", &s.path)) - .collect(); - - let filtered_flows: Vec<_> = flows - .into_iter() - .filter(|f| !scope_config.granular || scope_config.is_allowed("flow", &f.path)) - .collect(); - - // Collect all needed resource types from all schemas - let mut needed_resource_types: HashSet = HashSet::new(); - for script in &filtered_scripts { - needed_resource_types.extend(extract_resource_types_from_schema(&script.get_schema())); - } - for flow in &filtered_flows { - needed_resource_types.extend(extract_resource_types_from_schema(&flow.get_schema())); - } - for hub_script in &hub_scripts { - needed_resource_types - .extend(extract_resource_types_from_schema(&hub_script.get_schema())); - } - - // Pre-fetch all resources - let resource_futures: Vec<_> = needed_resource_types - .into_iter() - .map(|rt| { - let backend = self.backend.clone(); - let auth = auth.clone(); - let workspace_id = workspace_id.clone(); - async move { - backend - .list_resources(&auth, &workspace_id, &rt) - .await - .map(|resources| (rt, resources)) - } - }) - .collect(); - - let resource_results = futures::future::try_join_all(resource_futures).await?; - let resources_cache: HashMap> = - resource_results.into_iter().collect(); + let read_only = auth.read_only(); let mut tools = Vec::new(); - for script in &filtered_scripts { - tools.push(create_tool_from_item( - script, - self.backend.as_ref(), - &resources_cache, - &resource_types, - )); - } + // Read-only tokens cannot run scripts/flows/hub-scripts (running is a + // mutating action), so skip the script/flow/hub/resource fetches + // entirely — they would only be discarded below. + if !read_only { + let (scripts, flows, resource_types, hub_scripts) = tokio::try_join!( + self.backend + .list_scripts(&auth, &workspace_id, favorites_only, None), + self.backend + .list_flows(&auth, &workspace_id, favorites_only, None), + self.backend.list_resource_types(&auth, &workspace_id), + async { + if let Some(ref apps) = scope_config.hub_apps { + self.backend.list_hub_scripts(Some(apps)).await + } else { + Ok(vec![]) + } + } + )?; - for flow in &filtered_flows { - tools.push(create_tool_from_item( - flow, - self.backend.as_ref(), - &resources_cache, - &resource_types, - )); - } + let filtered_scripts: Vec<_> = scripts + .into_iter() + .filter(|s| !scope_config.granular || scope_config.is_allowed("script", &s.path)) + .collect(); - for hub_script in &hub_scripts { - tools.push(create_tool_from_item( - hub_script, - self.backend.as_ref(), - &resources_cache, - &resource_types, - )); + let filtered_flows: Vec<_> = flows + .into_iter() + .filter(|f| !scope_config.granular || scope_config.is_allowed("flow", &f.path)) + .collect(); + + // Collect all needed resource types from all schemas + let mut needed_resource_types: HashSet = HashSet::new(); + for script in &filtered_scripts { + needed_resource_types + .extend(extract_resource_types_from_schema(&script.get_schema())); + } + for flow in &filtered_flows { + needed_resource_types + .extend(extract_resource_types_from_schema(&flow.get_schema())); + } + for hub_script in &hub_scripts { + needed_resource_types + .extend(extract_resource_types_from_schema(&hub_script.get_schema())); + } + + // Pre-fetch all resources + let resource_futures: Vec<_> = needed_resource_types + .into_iter() + .map(|rt| { + let backend = self.backend.clone(); + let auth = auth.clone(); + let workspace_id = workspace_id.clone(); + async move { + backend + .list_resources(&auth, &workspace_id, &rt) + .await + .map(|resources| (rt, resources)) + } + }) + .collect(); + + let resource_results = futures::future::try_join_all(resource_futures).await?; + let resources_cache: HashMap> = + resource_results.into_iter().collect(); + + for script in &filtered_scripts { + tools.push(create_tool_from_item( + script, + self.backend.as_ref(), + &resources_cache, + &resource_types, + )); + } + + for flow in &filtered_flows { + tools.push(create_tool_from_item( + flow, + self.backend.as_ref(), + &resources_cache, + &resource_types, + )); + } + + for hub_script in &hub_scripts { + tools.push(create_tool_from_item( + hub_script, + self.backend.as_ref(), + &resources_cache, + &resource_types, + )); + } } // Add endpoint tools from the generated MCP tools, filtered by scope @@ -241,6 +247,9 @@ impl ServerHandler for Runner { if scope_config.granular && !scope_config.is_allowed("endpoint", &endpoint_tool.name) { continue; } + if read_only && !crate::server::is_endpoint_read_only(&endpoint_tool) { + continue; + } tools.push(endpoint_tool_to_mcp_tool(&endpoint_tool)); } @@ -259,6 +268,7 @@ impl ServerHandler for Runner { let scopes = auth.scopes().unwrap_or(&[]); let scope_config = parse_mcp_scopes(scopes).map_err(|e| ErrorData::internal_error(e, None))?; + let read_only = auth.read_only(); let args = request.arguments.map(Value::Object).unwrap_or(Value::Null); @@ -278,6 +288,15 @@ impl ServerHandler for Runner { None, )); } + if read_only && !crate::server::is_endpoint_read_only(endpoint_tool) { + return Err(ErrorData::internal_error( + format!( + "Access denied: endpoint '{}' is not read-only and this token is restricted to read-only operations", + endpoint_tool.name + ), + None, + )); + } // This is an endpoint tool, call via backend let result = self @@ -294,6 +313,18 @@ impl ServerHandler for Runner { } } + // Anything below this point runs a script or flow, which is a mutating + // action and must be denied for read-only tokens. + if read_only { + return Err(ErrorData::internal_error( + format!( + "Access denied: tool '{}' runs a script/flow and this token is restricted to read-only operations", + request.name + ), + None, + )); + } + // Resolve the tool name to (type, path, is_hub) let (type_str, is_hub, is_hashed) = parse_tool_prefix(&request.name).map_err(|e| { ErrorData::internal_error(format!("Failed to parse tool name: {}", e), None) diff --git a/backend/windmill-native-triggers/src/handler.rs b/backend/windmill-native-triggers/src/handler.rs index 1b6aa83b92..fda28af407 100644 --- a/backend/windmill-native-triggers/src/handler.rs +++ b/backend/windmill-native-triggers/src/handler.rs @@ -94,6 +94,7 @@ async fn new_webhook_token( None, Some(scopes), Some(workspace_id.to_owned()), + None, ); let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?; diff --git a/frontend/src/lib/components/mcp/McpScopeSelector.svelte b/frontend/src/lib/components/mcp/McpScopeSelector.svelte index 61ae086515..cf95617790 100644 --- a/frontend/src/lib/components/mcp/McpScopeSelector.svelte +++ b/frontend/src/lib/components/mcp/McpScopeSelector.svelte @@ -15,9 +15,27 @@ workspaceId: string scope: string initialScope?: string + readOnly?: boolean } - let { workspaceId, scope = $bindable(), initialScope }: Props = $props() + let { workspaceId, scope = $bindable(), initialScope, readOnly = false }: Props = $props() + + // Endpoints we can actually advertise to a read-only MCP token. Mirrors the + // runner's filter (only GET endpoints). + const visibleEndpointTools = $derived( + readOnly ? mcpEndpointTools.filter((e) => e.method === 'GET') : mcpEndpointTools + ) + + // When read-only flips on, prune already-selected non-GET endpoints so the + // scope string doesn't keep references to tools the server will reject. + $effect(() => { + if (!readOnly || selectedEndpoints.length === 0) return + const allowed = new Set(visibleEndpointTools.map((e) => e.name)) + const filtered = selectedEndpoints.filter((n) => allowed.has(n)) + if (filtered.length !== selectedEndpoints.length) { + selectedEndpoints = filtered + } + }) const parsedInitial = parseInitialScope(initialScope) @@ -410,7 +428,7 @@ selectedFlows = [] } function selectAllEndpoints() { - selectedEndpoints = [...mcpEndpointTools.map((e) => e.name)] + selectedEndpoints = [...visibleEndpointTools.map((e) => e.name)] } function clearAllEndpoints() { selectedEndpoints = [] @@ -529,7 +547,7 @@
{@render sectionHeader('API Endpoints', selectAllEndpoints, clearAllEndpoints)} e.name))} + items={safeSelectItems(visibleEndpointTools.map((e) => e.name))} placeholder="Select endpoints" bind:value={selectedEndpoints} /> @@ -594,29 +612,35 @@
{:else}
- Scripts & Flows that will be available via MCP -
- {#if includedRunnables.length > 0 && includedRunnables.length <= 5} - {#each includedRunnables as scriptOrFlow (scriptOrFlow)} - {scriptOrFlow} - {/each} - {:else if includedRunnables.length > 0} - {#each includedRunnables.slice(0, 3) as scriptOrFlow (scriptOrFlow)} - {scriptOrFlow} - {/each} - - +{includedRunnables.length - 3} more - - {:else} -

- {warning} -

- {/if} -
+ {#if !readOnly} + Scripts & Flows that will be available via MCP +
+ {#if includedRunnables.length > 0 && includedRunnables.length <= 5} + {#each includedRunnables as scriptOrFlow (scriptOrFlow)} + {scriptOrFlow} + {/each} + {:else if includedRunnables.length > 0} + {#each includedRunnables.slice(0, 3) as scriptOrFlow (scriptOrFlow)} + {scriptOrFlow} + {/each} + + +{includedRunnables.length - 3} more + + {:else} +

+ {warning} +

+ {/if} +
+ {:else} +

+ Scripts and flows are hidden because this token is read-only. +

+ {/if} API endpoint tools that will be available via MCP
- {#each mcpEndpointTools as endpoint (endpoint.name)} + {#each visibleEndpointTools as endpoint (endpoint.name)} {#snippet text()}
diff --git a/frontend/src/lib/components/settings/CreateToken.svelte b/frontend/src/lib/components/settings/CreateToken.svelte index 6ba2461725..80c6cb34ef 100644 --- a/frontend/src/lib/components/settings/CreateToken.svelte +++ b/frontend/src/lib/components/settings/CreateToken.svelte @@ -46,6 +46,7 @@ let mcpLabelAutofilled = $state(false) let pickedScopes = $state(null) + let readOnly = $state(false) function ensureCurrentWorkspaceIncluded( workspacesList: UserWorkspace[], @@ -67,6 +68,7 @@ newTokenWorkspace = defaultNewTokenWorkspace ?? $workspaceStore newToken = undefined newMcpToken = undefined + readOnly = false if (!newTokenLabel) { newTokenLabel = 'MCP token' mcpLabelAutofilled = true @@ -80,6 +82,7 @@ newTokenExpiration = undefined newTokenWorkspace = defaultNewTokenWorkspace newMcpToken = undefined + readOnly = false if (mcpLabelAutofilled) { newTokenLabel = undefined } @@ -100,7 +103,8 @@ label: newTokenLabel, expiration: date?.toISOString(), scopes: tokenScopes, - workspace_id: mcpMode ? newTokenWorkspace || $workspaceStore : newTokenWorkspace + workspace_id: mcpMode ? newTokenWorkspace || $workspaceStore : newTokenWorkspace, + read_only: readOnly } as NewToken }) @@ -184,6 +188,17 @@ {#each scopes as scope (scope)} {/each} +
+ +
{/if} @@ -192,6 +207,7 @@ mode={mcpCreationMode ? 'mcp' : 'standard'} workspaceId={newTokenWorkspace || $workspaceStore || ''} bind:value={pickedScopes} + bind:readOnly /> {/if} diff --git a/frontend/src/lib/components/settings/ScopeSelector.svelte b/frontend/src/lib/components/settings/ScopeSelector.svelte index da9d4949a6..91a62e65f7 100644 --- a/frontend/src/lib/components/settings/ScopeSelector.svelte +++ b/frontend/src/lib/components/settings/ScopeSelector.svelte @@ -7,10 +7,14 @@ import Tooltip from '../Tooltip.svelte' import { twMerge } from 'tailwind-merge' + import type { Snippet } from 'svelte' + interface Props { selectedScopes?: string[] disabled?: boolean class?: string + /** Renders above the scope-list card, below the Selected Scopes summary. */ + topSlot?: Snippet } interface ScopeState { @@ -30,7 +34,12 @@ domains: Record } - let { selectedScopes = $bindable([]), disabled = false, class: className = '' }: Props = $props() + let { + selectedScopes = $bindable([]), + disabled = false, + class: className = '', + topSlot + }: Props = $props() let scopeDomains = $state(null) let loading = $state(false) @@ -535,6 +544,12 @@ {/if}
+ {#if topSlot} +
+ {@render topSlot()} +
+ {/if} +
{#each scopeDomains as domain} {@const domainState = getDomainState(domain.name)} diff --git a/frontend/src/lib/components/settings/ScopesPicker.svelte b/frontend/src/lib/components/settings/ScopesPicker.svelte index 2e96269a00..e8cdccf40c 100644 --- a/frontend/src/lib/components/settings/ScopesPicker.svelte +++ b/frontend/src/lib/components/settings/ScopesPicker.svelte @@ -10,9 +10,28 @@ initialScopes?: string[] /** Final scope value: null = unrestricted/full access, array = explicit list */ value: string[] | null + /** Read-only flag; also forwarded to McpScopeSelector to filter incompatible + * endpoints/runnables. Two-way bound so the inline toggle below the + * "Limit token permissions" switch (and the MCP variant) writes back. */ + readOnly?: boolean } - let { mode, workspaceId = '', initialScopes, value = $bindable() }: Props = $props() + let { + mode, + workspaceId = '', + initialScopes, + value = $bindable(), + readOnly = $bindable(false) + }: Props = $props() + + // In standard mode, only meaningful when the user has turned "Limit token + // permissions" on. Reset when they un-limit so the flag doesn't quietly + // stick if they re-enable later. + $effect(() => { + if (mode === 'standard' && !limited && readOnly) { + readOnly = false + } + }) const initialMcpScope = $derived( (initialScopes ?? []).length > 0 ? (initialScopes ?? []).join(' ') : undefined @@ -51,9 +70,41 @@ size="xs" /> {#if limited} - + + {#snippet topSlot()} +
+ +
+ {/snippet} +
{/if}
{:else} - +
+
+ +
+ +
{/if} diff --git a/frontend/src/lib/components/settings/TokensTable.svelte b/frontend/src/lib/components/settings/TokensTable.svelte index 816d710e3c..5907c808a6 100644 --- a/frontend/src/lib/components/settings/TokensTable.svelte +++ b/frontend/src/lib/components/settings/TokensTable.svelte @@ -170,7 +170,7 @@ {#snippet body()} {#if tokens && tokens.length > 0} - {#each tokens as { token_prefix, expiration, label, scopes, workspace_id } (token_prefix)} + {#each tokens as { token_prefix, expiration, label, scopes, workspace_id, read_only } (token_prefix)} {@const badge = expirationBadge(expiration, label)} {token_prefix}**** @@ -185,8 +185,15 @@ {scopes?.join(', ') ?? ''} +
+ {#if read_only} + Read-only + {/if} + {scopes?.join(', ') ?? ''} +
+
{/each}
+
+ + +

+ Comma-separated host patterns that job HTTP clients should bypass the tracing + proxy for — those hosts will not be traced. Use this for clients that pin their + own CA (kubectl, helm, terraform providers, aws cli for EKS, etc.) which would + otherwise fail with x509: certificate signed by unknown authority. + Independent of the worker's own NO_PROXY env, which governs the proxy's + upstream relay (e.g. through a corporate proxy). +

+
{/if}
{:else if setting.fieldType == 'object_store_config'} From e1819313e15766007c959497a84fae5f5c78a46b Mon Sep 17 00:00:00 2001 From: "windmill-internal-app[bot]" <217088191+windmill-internal-app[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 12:24:29 +0000 Subject: [PATCH 126/313] fix: aggregate wait time should target the true root job, not flow_innermost_root_job (#9177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: aggregate wait time should target the true root job, not flow_innermost_root_job * refactor: reuse get_root_job_id helper for wait-time aggregation Instead of duplicating the root_job → flow_innermost_root_job → parent_job fallback chain inline, call the existing get_root_job_id() helper (the same one used by push_next_flow_job) and filter out the self-id case so standalone scripts still skip aggregate insertion. Behaviorally identical, single source of truth. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: windmill-internal-app[bot] <1429786+windmill-internal-app[bot]@users.noreply.github.com> Co-authored-by: Ruben Fiszel Co-authored-by: Claude Opus 4.7 (1M context) --- backend/windmill-worker/src/worker.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 169c4526f9..f9305961eb 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -132,7 +132,8 @@ use crate::{ bun_executor::handle_bun_job, common::{ build_args_map, cached_result_path, get_cached_resource_value_if_valid, - get_reserved_variables, update_worker_ping_for_failed_init_script, OccupancyMetrics, + get_reserved_variables, get_root_job_id, update_worker_ping_for_failed_init_script, + OccupancyMetrics, }, csharp_executor::handle_csharp_job, deno_executor::handle_deno_job, @@ -1320,8 +1321,6 @@ async fn insert_wait_time( .await?; if let Some(root_id) = root_job_id { - // TODO: queued_job.root_job is not guaranteed to be the true root job (e.g. parallel flow - // subflows). So this is currently incorrect for those cases sqlx::query!( "INSERT INTO outstanding_wait_time(job_id, aggregate_wait_time_ms) VALUES ($1, $2) ON CONFLICT (job_id) DO UPDATE SET aggregate_wait_time_ms = @@ -1353,7 +1352,10 @@ fn add_outstanding_wait_time( } let job_id = queued_job.id; - let root_job_id = queued_job.flow_innermost_root_job; + // Aggregate onto the true top-level root (root_job → flow_innermost_root_job → parent_job). + // `get_root_job_id` falls back to the job's own id when none are set; filter that out so + // standalone scripts (no parent flow) skip the aggregate insertion. + let root_job_id = Some(get_root_job_id(queued_job)).filter(|&id| id != job_id); let conn = conn.clone(); if let Some(db) = conn.as_sql() { From b7bc9b44b4260c044ad08e551e5203fb811e69b4 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 15 May 2026 12:45:00 +0000 Subject: [PATCH 127/313] chore(frontend): update vite to 8.0.13 (#9179) --- frontend/package-lock.json | 311 ++++++++++++++++--------------------- frontend/package.json | 2 +- 2 files changed, 139 insertions(+), 174 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index efb4fd9529..fd6d47790d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -149,7 +149,7 @@ "tar": "^7.5.4", "tslib": "^2.6.1", "typescript": "^5.5.0", - "vite": "^8.0.0", + "vite": "^8.0.13", "vite-plugin-mkcert": "^2.0.0", "vitest": "^4.1.0", "vitest-browser-svelte": "^2.0.1" @@ -841,22 +841,20 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", - "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", - "dev": true, + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", - "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", - "dev": true, + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, "dependencies": { @@ -864,10 +862,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", - "dev": true, + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "license": "MIT", "optional": true, "dependencies": { @@ -1354,20 +1351,21 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", - "dev": true, + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@noble/hashes": { @@ -1421,20 +1419,10 @@ "node": ">= 8" } }, - "node_modules/@oxc-project/runtime": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", - "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@oxc-project/types": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", - "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", "devOptional": true, "license": "MIT", "funding": { @@ -1508,13 +1496,12 @@ "license": "SEE LICENSE IN LICENSE" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1525,13 +1512,12 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1542,13 +1528,12 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1559,13 +1544,12 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1576,13 +1560,12 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", - "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1593,13 +1576,15 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", "cpu": [ "arm64" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1610,13 +1595,15 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", "cpu": [ "arm64" ], - "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1627,13 +1614,15 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", "cpu": [ "ppc64" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1644,13 +1633,15 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", "cpu": [ "s390x" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1661,13 +1652,15 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", "cpu": [ "x64" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1678,13 +1671,15 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", "cpu": [ "x64" ], - "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1695,13 +1690,12 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1712,30 +1706,30 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", - "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1746,13 +1740,12 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1763,9 +1756,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", - "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "devOptional": true, "license": "MIT" }, @@ -2055,10 +2048,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "license": "MIT", "optional": true, "dependencies": { @@ -6834,7 +6826,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -7333,7 +7325,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7354,7 +7345,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7375,7 +7365,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7396,7 +7385,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7417,7 +7405,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7438,7 +7425,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7459,7 +7445,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7480,7 +7465,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7501,7 +7485,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7522,7 +7505,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7543,7 +7525,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9662,9 +9643,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "devOptional": true, "funding": [ { @@ -11052,14 +11033,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", - "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.115.0", - "@rolldown/pluginutils": "1.0.0-rc.9" + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -11068,21 +11049,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-x64": "1.0.0-rc.9", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" } }, "node_modules/run-parallel": { @@ -12112,21 +12093,6 @@ } } }, - "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", @@ -12648,14 +12614,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "devOptional": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -12683,9 +12649,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "devOptional": true, "license": "MIT", "engines": { @@ -12857,7 +12823,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -13131,18 +13097,17 @@ } }, "node_modules/vite": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", - "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", "devOptional": true, "license": "MIT", "dependencies": { - "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.9", - "tinyglobby": "^0.2.15" + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.1", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -13158,8 +13123,8 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.0.0-alpha.31", - "esbuild": "^0.27.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", @@ -13241,9 +13206,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "devOptional": true, "license": "MIT", "engines": { diff --git a/frontend/package.json b/frontend/package.json index 96b2bf4953..5dbb1d4a3c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -70,7 +70,7 @@ "tar": "^7.5.4", "tslib": "^2.6.1", "typescript": "^5.5.0", - "vite": "^8.0.0", + "vite": "^8.0.13", "vite-plugin-mkcert": "^2.0.0", "vitest": "^4.1.0", "vitest-browser-svelte": "^2.0.1" From 69b3141e0370b95f2e13987503480d341608dbdf Mon Sep 17 00:00:00 2001 From: "windmill-internal-app[bot]" <217088191+windmill-internal-app[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 12:46:37 +0000 Subject: [PATCH 128/313] fix: apply pip_local_dependencies filtering to deployed scripts with populated lockfiles (#9178) * fix: apply pip_local_dependencies filtering to deployed scripts with populated lockfiles * refactor: share pip_local_dependencies filtering helper, log ignored deps * test: split pure filter core out for unit testing, cover #-preservation --------- Co-authored-by: windmill-internal-app[bot] <1429786+windmill-internal-app[bot]@users.noreply.github.com> Co-authored-by: Ruben Fiszel --- .../windmill-worker/src/python_executor.rs | 125 ++++++++++++++---- 1 file changed, 98 insertions(+), 27 deletions(-) diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 1f20148841..d3d3de62d6 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -178,6 +178,44 @@ pub fn handle_ephemeral_token(x: String) -> String { x } +/// Removes lockfile/requirements entries matching the worker's `pip_local_dependencies` +/// regexes. Those packages are already provided locally (e.g. via `additional_python_paths`), +/// so installing them again duplicates files and triggers expensive `postinstall` copies on +/// every job. `#`-prefixed comment lines (e.g. the `# py:` lockfile header) are always kept. +/// Returns `(kept_lines, ignored_lines)`. +fn filter_pip_local_dependencies(lines: Vec) -> (Vec, Vec) { + let Some(pip_local_dependencies) = WORKER_CONFIG.load().pip_local_dependencies.clone() else { + return (lines, vec![]); + }; + + let compiled_deps = pip_local_dependencies + .iter() + .filter_map(|dep| match Regex::new(dep) { + Ok(r) => Some(r), + Err(e) => { + tracing::warn!( + "regex compilation failed for Python local dependency: '{}' - it will be ignored", + e + ); + None + } + }) + .collect::>(); + + filter_lines_by_deps(lines, &compiled_deps) +} + +/// Pure core of [`filter_pip_local_dependencies`]: partitions `lines` into +/// `(kept, ignored)`. A line is ignored when it is not a `#` comment and matches any of +/// `compiled_deps`. Kept separate from config/regex loading so it can be unit-tested. +fn filter_lines_by_deps(lines: Vec, compiled_deps: &[Regex]) -> (Vec, Vec) { + let (ignored, kept): (Vec, Vec) = lines + .into_iter() + .partition(|s| !s.starts_with('#') && compiled_deps.iter().any(|dep| dep.is_match(s))); + + (kept, ignored) +} + // This function only invoked during deployment of script or test run. // And never for already deployed scripts, these have their lockfiles in PostgreSQL // thus this function call is skipped. @@ -200,33 +238,13 @@ pub async fn uv_pip_compile( logs.push_str(&format!("\nresolving dependencies...")); logs.push_str(&format!("\ncontent of requirements:\n{}\n", requirements)); - let requirements = if let Some(pip_local_dependencies) = - WORKER_CONFIG.load().pip_local_dependencies.as_ref() - { - let deps = pip_local_dependencies.clone(); - let compiled_deps = deps.iter().map(|dep| { - let compiled_dep = Regex::new(dep); - match compiled_dep { - Ok(compiled_dep) => Some(compiled_dep), - Err(e) => { - tracing::warn!("regex compilation failed for Python local dependency: '{}' - it will be ignored", e); - return None; - } - } - }).filter(|dep_maybe| dep_maybe.is_some()).map(|dep| dep.unwrap()).collect::>(); - requirements - .lines() - .filter(|s| { - if compiled_deps.iter().any(|dep| dep.is_match(s)) { - logs.push_str(&format!("\nignoring local dependency: {}", s)); - return false; - } else { - return true; - } - }) - .join("\n") - } else { - requirements.to_string() + let requirements = { + let (kept, ignored) = + filter_pip_local_dependencies(requirements.lines().map(str::to_owned).collect()); + for line in ignored { + logs.push_str(&format!("\nignoring local dependency: {}", line)); + } + kept.join("\n") }; let uv_index_strategy = UV_INDEX_STRATEGY.read().await.clone(); @@ -1874,6 +1892,24 @@ Returned from server: py_version - {:?}, py_version_v2 - {:?} } }; + // Filter out packages matched by pip_local_dependencies. For preview runs this is also + // handled inside uv_pip_compile, but deployed scripts skip uv_pip_compile entirely and + // would otherwise pass every lockfile entry to handle_python_reqs — causing duplicate + // installs alongside additional_python_paths and triggering expensive postinstall copies. + let resolved_lines = { + let (kept, ignored) = filter_pip_local_dependencies(resolved_lines); + if !ignored.is_empty() { + append_logs( + job_id, + w_id, + format!("\nignoring local dependencies:\n{}\n", ignored.join("\n")), + conn, + ) + .await; + } + kept + }; + if !resolved_lines.is_empty() { let mut venv_path = handle_python_reqs( resolved_lines, @@ -3228,4 +3264,39 @@ mod tests { let pre = cg.pre_spread.as_ref().unwrap(); assert!(pre.contains("pre_args[\"input\"]")); } + + fn lines(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn test_filter_lines_by_deps_no_deps_keeps_everything() { + let (kept, ignored) = filter_lines_by_deps(lines(&["requests==2.0", "numpy==1.0"]), &[]); + assert_eq!(kept, lines(&["requests==2.0", "numpy==1.0"])); + assert!(ignored.is_empty()); + } + + #[test] + fn test_filter_lines_by_deps_matches_and_partitions() { + let deps = vec![Regex::new("^my-local-pkg").unwrap()]; + let (kept, ignored) = filter_lines_by_deps( + lines(&["requests==2.0", "my-local-pkg==1.2.3", "numpy==1.0"]), + &deps, + ); + assert_eq!(kept, lines(&["requests==2.0", "numpy==1.0"])); + assert_eq!(ignored, lines(&["my-local-pkg==1.2.3"])); + } + + #[test] + fn test_filter_lines_by_deps_preserves_comment_lines() { + // `#` lines (e.g. the `# py: 3.11` lockfile header) must survive even when a + // dependency regex would otherwise match them. + let deps = vec![Regex::new("py").unwrap()]; + let (kept, ignored) = filter_lines_by_deps( + lines(&["# py: 3.11", "pyyaml==6.0", "requests==2.0"]), + &deps, + ); + assert_eq!(kept, lines(&["# py: 3.11", "requests==2.0"])); + assert_eq!(ignored, lines(&["pyyaml==6.0"])); + } } From 8f95402850e7f2c2d918bbbcdc94c8da80cad121 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 15 May 2026 13:14:58 +0000 Subject: [PATCH 129/313] use OPENAI_API_KEY for codex workflow --- .github/workflows/codex-pr-review.yml | 24 ++++++++++++++++++------ .github/workflows/pr-review-commands.yml | 1 + 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml index b4916714f2..66cc4b5f10 100644 --- a/.github/workflows/codex-pr-review.yml +++ b/.github/workflows/codex-pr-review.yml @@ -20,6 +20,8 @@ on: type: string default: '' secrets: + OPENAI_API_KEY: + required: false CODEX_AUTH_JSON: required: false WINDMILL_EE_PRIVATE_ACCESS: @@ -60,13 +62,18 @@ jobs: - name: Check Codex configuration id: codex_config env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} run: | - if [ -n "$CODEX_AUTH_JSON" ]; then + if [ -n "$OPENAI_API_KEY" ]; then echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "auth_mode=api_key" >> "$GITHUB_OUTPUT" + elif [ -n "$CODEX_AUTH_JSON" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "auth_mode=oauth_json" >> "$GITHUB_OUTPUT" else echo "enabled=false" >> "$GITHUB_OUTPUT" - echo "CODEX_AUTH_JSON is not configured; skipping Codex review." + echo "Codex auth is not configured; set OPENAI_API_KEY or CODEX_AUTH_JSON to enable Codex review." fi - name: Resolve PR metadata @@ -169,9 +176,10 @@ jobs: if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' run: npm install --global @openai/codex@0.128.0 - - name: Configure file-backed Codex auth + - name: Configure Codex auth if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} run: | CODEX_HOME="$HOME/.codex" @@ -181,9 +189,13 @@ jobs: cat > "$CODEX_HOME/config.toml" <<'EOF' cli_auth_credentials_store = "file" EOF - printf '%s' "$CODEX_AUTH_JSON" > "$CODEX_HOME/auth.json" - chmod 600 "$CODEX_HOME/auth.json" - node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$CODEX_HOME/auth.json" + if [ -n "$OPENAI_API_KEY" ]; then + printf '%s' "$OPENAI_API_KEY" | codex login --with-api-key + else + printf '%s' "$CODEX_AUTH_JSON" > "$CODEX_HOME/auth.json" + chmod 600 "$CODEX_HOME/auth.json" + node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$CODEX_HOME/auth.json" + fi - name: Pre-fetch base and head refs for the PR if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' diff --git a/.github/workflows/pr-review-commands.yml b/.github/workflows/pr-review-commands.yml index 21d5e1decd..ba55bfea2f 100644 --- a/.github/workflows/pr-review-commands.yml +++ b/.github/workflows/pr-review-commands.yml @@ -100,6 +100,7 @@ jobs: extra_prompt: ${{ needs.parse.outputs.extra_prompt }} triggered_by: ${{ github.event.comment.user.login }} secrets: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }} From e3a3dbb89c4e9e1c03a3ed9f761ad54516606c34 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 15 May 2026 13:15:28 +0000 Subject: [PATCH 130/313] chore(main): release 1.703.0 (#9170) * chore(main): release 1.703.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 14 ++ backend/Cargo.lock | 164 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 ++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 72 +++++--- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 184 insertions(+), 142 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3524f552c..780d1e05d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.703.0](https://github.com/windmill-labs/windmill/compare/v1.702.1...v1.703.0) (2026-05-15) + + +### Features + +* **otel-tracing-proxy:** configurable tracing MITM NO_PROXY hosts ([#9169](https://github.com/windmill-labs/windmill/issues/9169)) ([d48d61c](https://github.com/windmill-labs/windmill/commit/d48d61cc79114f0b36736306d4015789be10c1f4)) + + +### Bug Fixes + +* aggregate wait time should target the true root job, not flow_innermost_root_job ([#9177](https://github.com/windmill-labs/windmill/issues/9177)) ([e181931](https://github.com/windmill-labs/windmill/commit/e1819313e15766007c959497a84fae5f5c78a46b)) +* apply pip_local_dependencies filtering to deployed scripts with populated lockfiles ([#9178](https://github.com/windmill-labs/windmill/issues/9178)) ([69b3141](https://github.com/windmill-labs/windmill/commit/69b3141e0370b95f2e13987503480d341608dbdf)) +* never mark failure/trigger/approval scripts as auto_kind=lib ([#9168](https://github.com/windmill-labs/windmill/issues/9168)) ([f414ffc](https://github.com/windmill-labs/windmill/commit/f414ffc4849cf4b92fcd5ca9611ecd246e59a7bd)) + ## [1.702.1](https://github.com/windmill-labs/windmill/compare/v1.702.0...v1.702.1) (2026-05-14) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 185cb538c9..eaa9d9f40f 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -12587,7 +12587,7 @@ dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -12596,7 +12596,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -13788,7 +13788,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-nats", @@ -13869,7 +13869,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.702.1" +version = "1.703.0" dependencies = [ "async-trait", "aws-config", @@ -13899,7 +13899,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.702.1" +version = "1.703.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13912,7 +13912,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "argon2", @@ -14055,7 +14055,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.702.1" +version = "1.703.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14078,7 +14078,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.702.1" +version = "1.703.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14091,7 +14091,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14117,7 +14117,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.702.1" +version = "1.703.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14127,7 +14127,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.702.1" +version = "1.703.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14144,7 +14144,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.702.1" +version = "1.703.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14166,7 +14166,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14189,7 +14189,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.702.1" +version = "1.703.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14205,7 +14205,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.702.1" +version = "1.703.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14226,7 +14226,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.702.1" +version = "1.703.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14247,7 +14247,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.702.1" +version = "1.703.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14261,7 +14261,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-nats", @@ -14293,7 +14293,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14318,7 +14318,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.702.1" +version = "1.703.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14336,7 +14336,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14358,7 +14358,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.702.1" +version = "1.703.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14378,7 +14378,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.702.1" +version = "1.703.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14408,7 +14408,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14436,7 +14436,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.702.1" +version = "1.703.0" dependencies = [ "lazy_static", "serde", @@ -14448,7 +14448,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.702.1" +version = "1.703.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14473,7 +14473,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.702.1" +version = "1.703.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14487,7 +14487,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.702.1" +version = "1.703.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14520,7 +14520,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.702.1" +version = "1.703.0" dependencies = [ "chrono", "lazy_static", @@ -14534,7 +14534,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14553,7 +14553,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.702.1" +version = "1.703.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14654,7 +14654,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.702.1" +version = "1.703.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14673,7 +14673,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.702.1" +version = "1.703.0" dependencies = [ "regex", "serde", @@ -14688,7 +14688,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14712,7 +14712,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "futures", @@ -14729,7 +14729,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.702.1" +version = "1.703.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14745,7 +14745,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-trait", @@ -14766,7 +14766,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-trait", @@ -14797,7 +14797,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "arc-swap", @@ -14822,7 +14822,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-stream", @@ -14856,7 +14856,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "futures", @@ -14874,7 +14874,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.702.1" +version = "1.703.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14883,7 +14883,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "lazy_static", @@ -14895,7 +14895,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "serde_json", @@ -14907,7 +14907,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "gosyn", @@ -14919,7 +14919,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "lazy_static", @@ -14931,7 +14931,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "serde_json", @@ -14943,7 +14943,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "nu-parser", @@ -14954,7 +14954,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14965,7 +14965,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14977,7 +14977,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14988,7 +14988,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-recursion", @@ -15010,7 +15010,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "serde_json", @@ -15022,7 +15022,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "lazy_static", @@ -15036,7 +15036,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15053,7 +15053,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "lazy_static", @@ -15066,7 +15066,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "serde", @@ -15078,7 +15078,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "lazy_static", @@ -15096,7 +15096,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15112,7 +15112,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15128,7 +15128,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "serde", @@ -15139,7 +15139,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-recursion", @@ -15176,7 +15176,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "const_format", @@ -15214,7 +15214,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.702.1" +version = "1.703.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15225,7 +15225,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-recursion", @@ -15255,7 +15255,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-trait", @@ -15279,7 +15279,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-trait", @@ -15312,7 +15312,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-trait", @@ -15345,7 +15345,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-trait", @@ -15365,7 +15365,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-trait", @@ -15399,7 +15399,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-trait", @@ -15435,7 +15435,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-trait", @@ -15458,7 +15458,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-trait", @@ -15482,7 +15482,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-nats", @@ -15506,7 +15506,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-trait", @@ -15541,7 +15541,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-trait", @@ -15569,7 +15569,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-trait", @@ -15592,7 +15592,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -15611,7 +15611,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-once-cell", @@ -15720,7 +15720,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.702.1" +version = "1.703.0" dependencies = [ "bytes", "futures", @@ -16320,9 +16320,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ccc6467e67..d2241b76b8 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.702.1" +version = "1.703.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.702.1" +version = "1.703.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index b595cfeca1..7590f3d6b8 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.702.1" +version = "1.703.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.702.1" +version = "1.703.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.702.1" +version = "1.703.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.702.1" +version = "1.703.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 3be5965444..f7cfe81360 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.702.1" +version = "1.703.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 3d18bddf60..2270b0f683 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.702.1 + version: 1.703.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 4df4d4ca7d..f5a7776ae2 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.702.1"; +export const VERSION = "v1.703.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index 24772ad250..cf90e54816 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -79,7 +79,7 @@ export { token, }; -export const VERSION = "1.702.1"; +export const VERSION = "1.703.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fd6d47790d..f77e1c38a1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.702.1", + "version": "1.703.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.702.1", + "version": "1.703.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -844,6 +844,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -855,6 +856,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -865,6 +867,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1354,6 +1357,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1502,6 +1506,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1518,6 +1523,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1534,6 +1540,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1550,6 +1557,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1566,6 +1574,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1582,9 +1591,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1601,9 +1608,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1620,9 +1625,7 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1639,9 +1642,7 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1658,9 +1659,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1677,9 +1676,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1696,6 +1693,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1712,6 +1710,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1730,6 +1729,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1746,6 +1746,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2051,6 +2052,7 @@ "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6826,7 +6828,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -7325,6 +7327,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7345,6 +7348,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7365,6 +7369,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7385,6 +7390,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7405,6 +7411,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7425,6 +7432,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7445,6 +7453,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7465,6 +7474,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7485,6 +7495,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7505,6 +7516,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7525,6 +7537,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12093,6 +12106,21 @@ } } }, + "node_modules/svelte-check/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", @@ -12823,7 +12851,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/frontend/package.json b/frontend/package.json index 5dbb1d4a3c..ffb90bcf11 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.702.1", + "version": "1.703.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 131a52700e..e1d1fac799 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.702.1" +wmill = ">=1.703.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index bd9543130e..2552c212d2 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.702.1 + version: 1.703.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index fcae29637a..6365105fbe 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.702.1' + ModuleVersion = '1.703.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 42acf4e884..ccf46bdb34 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.702.1" +version = "1.703.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/typescript-client/jsr.json b/typescript-client/jsr.json index e297eb78e0..93f6685488 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.702.1", + "version": "1.703.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 adf6d83bf6..fae7c6b76e 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.702.1", + "version": "1.703.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index c3e939e87b..e6654fc203 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.702.1 +1.703.0 From 85555542bf5efb90fcedffd8d36934251e05190b Mon Sep 17 00:00:00 2001 From: Blossom Date: Fri, 15 May 2026 21:20:36 +0800 Subject: [PATCH 131/313] replace sync deprecation placeholder link (#9180) --- cli/src/commands/sync/pull.ts | 2 +- cli/src/commands/sync/push.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/sync/pull.ts b/cli/src/commands/sync/pull.ts index adfd486294..90a31f4919 100644 --- a/cli/src/commands/sync/pull.ts +++ b/cli/src/commands/sync/pull.ts @@ -156,7 +156,7 @@ export async function downloadZip( function stub(_opts: GlobalOptions & { override: boolean }, _dir: string) { console.log( colors.red.underline( - 'Pull is deprecated. Use "sync pull --raw" instead. See for more information.' + 'Pull is deprecated. Use "sync pull --raw" instead. See https://www.windmill.dev/docs/advanced/cli/sync for more information.' ) ); } diff --git a/cli/src/commands/sync/push.ts b/cli/src/commands/sync/push.ts index e95fa048be..798b0db871 100644 --- a/cli/src/commands/sync/push.ts +++ b/cli/src/commands/sync/push.ts @@ -6,7 +6,7 @@ import { GlobalOptions } from "../../types.ts"; function stub(_opts: GlobalOptions, _dir?: string) { log.info( colors.red.underline( - 'Push is deprecated. Use "sync push --raw" instead. See for more information.' + 'Push is deprecated. Use "sync push --raw" instead. See https://www.windmill.dev/docs/advanced/cli/sync for more information.' ) ); } From 6a334e9a07a7d0cffabde48be75263b0844d586c Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 15 May 2026 15:19:55 +0000 Subject: [PATCH 132/313] fix: detect S3 assets passed as SDK object arg in ts parser (#9181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit windmill-parser-ts-asset only recognized writeS3File/loadS3File when the first arg was a bare 's3://...' string literal. The actual SDK signature takes an S3Object ({ s3, storage? }) or 's3://bucket/key' string, which every real script uses, so object-form writes/reads were never detected as assets. Resolve the S3Object arg the same way the runtime parseS3Object does, mapping { s3, storage } to s3:/// and feeding it through parse_asset_syntax so the path matches the // on s3:///… trigger form. Adds regression tests. Co-authored-by: Claude Opus 4.7 (1M context) --- .../windmill-parser-ts-asset/src/lib.rs | 208 ++++++++++++++++-- 1 file changed, 194 insertions(+), 14 deletions(-) diff --git a/backend/parsers/windmill-parser-ts-asset/src/lib.rs b/backend/parsers/windmill-parser-ts-asset/src/lib.rs index 3c04ef7447..39302fb6fa 100644 --- a/backend/parsers/windmill-parser-ts-asset/src/lib.rs +++ b/backend/parsers/windmill-parser-ts-asset/src/lib.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use swc_common::{sync::Lrc, FileName, SourceMap, Spanned}; -use swc_ecma_ast::{CallExpr, Expr, Lit, MemberExpr, MemberProp, Str}; +use swc_ecma_ast::{CallExpr, Expr, Lit, MemberExpr, MemberProp, ObjectLit, Prop, PropName, Str}; use swc_ecma_parser::{lexer::Lexer, Parser, StringInput, Syntax, TsSyntax}; use swc_ecma_visit::{Visit, VisitWith}; use windmill_parser::asset_parser::{ @@ -309,6 +309,56 @@ impl Visit for AssetsFinder { } } +/// Extract a string-literal property value from an object literal. +/// Returns `Some(value)` for `{ name: "value" }`, ignoring computed, +/// shorthand, spread, and non-string-literal properties. +fn object_str_prop(obj: &ObjectLit, name: &str) -> Option { + for prop in &obj.props { + let swc_ecma_ast::PropOrSpread::Prop(p) = prop else { + continue; + }; + let Prop::KeyValue(kv) = p.as_ref() else { + continue; + }; + let key = match &kv.key { + PropName::Ident(i) => i.sym.as_str(), + PropName::Str(s) => s.value.as_str(), + _ => continue, + }; + if key != name { + continue; + } + if let Expr::Lit(Lit::Str(s)) = kv.value.as_ref() { + return Some(s.value.to_string()); + } + } + None +} + +/// Resolve the SDK `S3Object` argument of `loadS3File`/`loadS3FileStream`/ +/// `writeS3File` to a canonical asset path, mirroring the runtime +/// `parseS3Object`: an object `{ s3: "", storage?: "" }` maps to +/// the URI `s3:///` (empty bucket for default storage, i.e. +/// `s3:///`), and a bare `"s3://bucket/key"` string is passed through. +/// The resulting URI is fed through `parse_asset_syntax` so the stored path +/// matches the `// on s3:///…` trigger form exactly. +fn s3_object_arg_path(arg: &Expr) -> Option { + let uri = match arg { + Expr::Lit(Lit::Str(s)) => s.value.to_string(), + Expr::Object(obj) => { + let key = object_str_prop(obj, "s3")?; + let storage = object_str_prop(obj, "storage").unwrap_or_default(); + format!("s3://{storage}/{key}") + } + _ => return None, + }; + Some( + parse_asset_syntax(&uri, false) + .map(|(_, p)| p.to_string()) + .unwrap_or(uri), + ) +} + impl AssetsFinder { fn visit_call_expr_inner(&mut self, node: &swc_ecma_ast::CallExpr) -> Result<(), ()> { let ident = match node.callee.as_expr().map(AsRef::as_ref) { @@ -331,20 +381,20 @@ impl AssetsFinder { let arg_value = node.args.get(arg_pos); - match arg_value.map(|e| e.expr.as_ref()) { - Some(Expr::Lit(Lit::Str(Str { value, .. }))) => { - let path = parse_asset_syntax(&value, false) - .map(|(_, p)| p) - .unwrap_or(&value); - self.assets.push(ParseAssetsResult { - kind, - path: path.to_string(), - access_type, - columns: None, - }); - } + // S3 helpers take an `S3Object` (`{ s3, storage? }`) or an + // `s3://bucket/key` string — the form every real script uses. Other + // helpers take a bare resource-path string literal. + let is_s3_helper = matches!(kind, AssetKind::S3Object); + + let path = match arg_value.map(|e| e.expr.as_ref()) { + Some(arg) if is_s3_helper => s3_object_arg_path(arg).ok_or(())?, + Some(Expr::Lit(Lit::Str(Str { value, .. }))) => parse_asset_syntax(&value, false) + .map(|(_, p)| p.to_string()) + .unwrap_or_else(|| value.to_string()), _ => return Err(()), - } + }; + self.assets + .push(ParseAssetsResult { kind, path, access_type, columns: None }); Ok(()) } } @@ -375,6 +425,136 @@ mod tests { ); } + #[test] + fn test_ts_asset_parser_write_s3_object_arg() { + // The SDK signature is `writeS3File(s3object: S3Object, ...)` and every + // real script passes the object form with a bare key. It must resolve + // to the same canonical path as a `// on s3:///` trigger. + let input = r#" + import * as wmill from "windmill-client" + export async function main() { + await wmill.writeS3File( + { s3: "pipelines/km_real/raw_events.json" }, + JSON.stringify([]), + undefined, + "application/json" + ) + } + "#; + let s = parse_assets(input); + assert_eq!( + s.map(|r| r.assets).map_err(|e| e.to_string()), + Ok(vec![ParseAssetsResult { + kind: AssetKind::S3Object, + path: "/pipelines/km_real/raw_events.json".to_string(), + access_type: Some(W), + columns: None, + },]) + ); + } + + #[test] + fn test_ts_asset_parser_s3_object_with_storage() { + // `{ s3, storage }` maps to `s3:///`, matching the + // `s3://bucket/key` string form and `parseS3Object`. + let input = r#" + import * as wmill from "windmill-client" + export async function main() { + await wmill.loadS3File({ s3: "dir/in.csv", storage: "mybucket" }) + } + "#; + let s = parse_assets(input); + assert_eq!( + s.map(|r| r.assets).map_err(|e| e.to_string()), + Ok(vec![ParseAssetsResult { + kind: AssetKind::S3Object, + path: "mybucket/dir/in.csv".to_string(), + access_type: Some(R), + columns: None, + },]) + ); + } + + #[test] + fn test_ts_asset_parser_multiple_s3_object_writes() { + // Mirrors the f/km/r_seed shape: several direct object-form writes in + // main() — all four outputs must be detected. + let input = r#" + import * as wmill from "windmill-client" + export async function main() { + await wmill.writeS3File({ s3: "pipelines/km_real/raw_events.json" }, "[]") + await wmill.writeS3File({ s3: "pipelines/km_real/enriched.json" }, "[]") + await wmill.writeS3File({ s3: "pipelines/km_real/summary.json" }, "[]") + await wmill.writeS3File({ s3: "pipelines/km_real/report.json" }, "{}") + } + "#; + // merge_assets returns a deterministic (path-sorted) order. + let s = parse_assets(input); + assert_eq!( + s.map(|r| r.assets).map_err(|e| e.to_string()), + Ok(vec![ + ParseAssetsResult { + kind: AssetKind::S3Object, + path: "/pipelines/km_real/enriched.json".to_string(), + access_type: Some(W), + columns: None, + }, + ParseAssetsResult { + kind: AssetKind::S3Object, + path: "/pipelines/km_real/raw_events.json".to_string(), + access_type: Some(W), + columns: None, + }, + ParseAssetsResult { + kind: AssetKind::S3Object, + path: "/pipelines/km_real/report.json".to_string(), + access_type: Some(W), + columns: None, + }, + ParseAssetsResult { + kind: AssetKind::S3Object, + path: "/pipelines/km_real/summary.json".to_string(), + access_type: Some(W), + columns: None, + }, + ]) + ); + } + + #[test] + fn test_ts_asset_parser_s3_object_quoted_key() { + let input = r#" + import * as wmill from "windmill-client" + export async function main() { + await wmill.writeS3File({ "s3": "out.json" }, "{}") + } + "#; + let s = parse_assets(input); + assert_eq!( + s.map(|r| r.assets).map_err(|e| e.to_string()), + Ok(vec![ParseAssetsResult { + kind: AssetKind::S3Object, + path: "/out.json".to_string(), + access_type: Some(W), + columns: None, + },]) + ); + } + + #[test] + fn test_ts_asset_parser_s3_object_dynamic_key_no_false_positive() { + // A computed key can't be resolved statically — must yield nothing + // rather than a bogus path. + let input = r#" + import * as wmill from "windmill-client" + export async function main(name: string) { + await wmill.writeS3File({ s3: `pipelines/${name}.json` }, "{}") + } + "#; + let s = parse_assets(input); + assert_eq!(s.map(|r| r.assets).map_err(|e| e.to_string()), Ok(vec![])); + } + #[test] fn test_ts_asset_parser_unused_sql() { let input = r#" From 81b573610692b386e4861ef989fa7698b53fc861 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 15 May 2026 21:04:16 +0000 Subject: [PATCH 133/313] fix: atomic bundle cache writes to prevent parallel cold-load race (#9186) * fix: atomic bundle cache writes to prevent parallel cold-load race Co-Authored-By: Claude Opus 4.7 (1M context) * fix: trust-but-replace in atomic_publish_dir to never trust a stale partial cache dir Co-Authored-By: Claude Opus 4.7 (1M context) * fix: simplify atomic_publish_dir and add content-addressed rename-failure fallback Revert the destroy-then-recreate dir swap (introduced concurrent-publisher edge cases: spurious Err under a real herd, EACCES masking a stale partial) back to a single atomic rename. Add the content-addressed exists-fallback to atomic_write_file_bytes/atomic_copy_file so the loser of a publish race (and Windows, where rename cannot replace an open/existing destination) treats the already-published identical cache as success instead of failing. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- backend/windmill-common/src/worker.rs | 310 +++++++++++++++++++- backend/windmill-worker/src/bun_executor.rs | 6 +- backend/windmill-worker/src/global_cache.rs | 28 +- 3 files changed, 325 insertions(+), 19 deletions(-) diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index 98e22455c1..c03856f8d5 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -970,6 +970,106 @@ pub fn copy_dir_recursively(src: &Path, dst: &Path) -> error::Result<()> { Ok(()) } +/// Write `bytes` to `final_path` atomically: write to a sibling temp file then +/// `rename` into place. A concurrent reader that gates on `metadata(final_path)` +/// therefore only ever observes a fully-written file (`rename` is atomic on a +/// POSIX same-filesystem path). Safe under a thundering herd: concurrent renames +/// to the same path are last-writer-wins and every writer produces identical +/// bytes. This prevents the cold-load race where a parallel for-loop spawns N +/// `//native` sandboxes that each observe a partially-written bundle. +pub fn atomic_write_file_bytes( + final_path: &str, + bytes: &[u8], + executable: bool, +) -> error::Result<()> { + #[cfg(not(unix))] + let _ = executable; + let tmp_path = format!("{}.tmp.{}", final_path, Uuid::new_v4()); + let write = || -> error::Result<()> { + let mut file = File::create(&tmp_path)?; + file.write_all(bytes)?; + #[cfg(unix)] + if executable { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o755))?; + } + file.flush()?; + Ok(()) + }; + if let Err(e) = write() { + let _ = fs::remove_file(&tmp_path); + return Err(e); + } + if let Err(e) = fs::rename(&tmp_path, final_path) { + let _ = fs::remove_file(&tmp_path); + // Content-addressed cache: if the destination now exists, a concurrent + // publisher already wrote byte-identical content via its own + // tmp+rename, so the cache is correct. This also covers Windows, where + // `rename` can refuse to replace a destination that an in-flight reader + // has open even though that destination is already valid. + if Path::new(final_path).exists() { + return Ok(()); + } + return Err(e.into()); + } + Ok(()) +} + +/// Copy `origin` to `final_path` atomically via a sibling temp file + `rename`, +/// preserving `std::fs::copy` permission semantics. Same atomicity guarantee as +/// [`atomic_write_file_bytes`]. +pub fn atomic_copy_file(origin: &str, final_path: &str) -> error::Result<()> { + let tmp_path = format!("{}.tmp.{}", final_path, Uuid::new_v4()); + if let Err(e) = fs::copy(origin, &tmp_path) { + let _ = fs::remove_file(&tmp_path); + return Err(e.into()); + } + if let Err(e) = fs::rename(&tmp_path, final_path) { + let _ = fs::remove_file(&tmp_path); + // See `atomic_write_file_bytes`: a content-addressed destination that + // now exists is already correct (peer publish / Windows open-file). + if Path::new(final_path).exists() { + return Ok(()); + } + return Err(e.into()); + } + Ok(()) +} + +/// Publish a fully-populated `tmp_dir` to `final_dir` via a single atomic +/// `rename`, so a concurrent reader gating on `metadata(final_dir)` never +/// observes a half-populated directory: `final_dir` is only ever absent or +/// complete (we never extract/copy in place). +/// +/// These dir caches are content-addressed (PHP `vendor/{hash}`, Java deps — +/// the path embeds a hash of the inputs), so every concurrent publisher builds +/// byte-identical content. If `rename` fails and `final_dir` already exists, a +/// peer published the identical content (or, on Windows, `rename` refused to +/// replace an existing directory that is nonetheless already valid): treat the +/// existing dir as the correct cache. +/// +/// Deliberately simple — no destroy-then-recreate swap. The accepted residual +/// is that a *stale partial* `final_dir` left by a populate hard-killed on a +/// pre-atomic binary is trusted rather than rebuilt. That is a finite, +/// self-draining migration hazard (post-fix code only ever renames a complete +/// `tmp_dir` into place, so it cannot create that state), it is confined to the +/// PHP/Java dependency caches (never the `//native` path this PR targets), and +/// it clears on cache eviction. A swap that rebuilds it introduces +/// concurrent-publisher edge cases that are a worse trade on a critical path. +pub fn atomic_publish_dir(tmp_dir: &str, final_dir: &str) -> error::Result<()> { + match fs::rename(tmp_dir, final_dir) { + Ok(()) => Ok(()), + Err(e) => { + let _ = fs::remove_dir_all(tmp_dir); + if Path::new(final_dir).exists() { + Ok(()) + } else { + Err(e.into()) + } + } + } +} + #[cfg(all(feature = "enterprise", feature = "parquet"))] pub async fn extract_tar(tar: bytes::Bytes, folder: &str) -> error::Result<()> { use std::time::Instant; @@ -997,19 +1097,7 @@ pub async fn extract_tar(tar: bytes::Bytes, folder: &str) -> error::Result<()> { } #[cfg(all(feature = "enterprise", feature = "parquet"))] pub fn write_binary_file(main_path: &str, byts: &mut bytes::Bytes) -> error::Result<()> { - use std::fs::File; - use std::io::Write; - - let mut file = File::create(main_path)?; - file.write_all(byts)?; - #[cfg(unix)] - { - use std::fs::Permissions; - use std::os::unix::fs::PermissionsExt; - file.set_permissions(Permissions::from_mode(0o755))?; - } - file.flush()?; - Ok(()) + atomic_write_file_bytes(main_path, byts, true) } #[cfg(not(windows))] @@ -2290,4 +2378,200 @@ mod tests { let annotations = TypeScriptAnnotations::parse(content); assert!(annotations.sandbox); } + + // Regression: a parallel for-loop cold-loading a //native bundle spawns + // many sandboxes that each gate on `metadata(bundle).is_ok()` then read it. + // The pre-fix non-atomic `File::create + write_all` made the path visible + // while still empty/truncated, so concurrent readers saw a stub bundle + // (`module.main is not a function`) or a truncated one (`Unexpected end of + // input`). With atomic temp+rename publish, a visible path is always a + // complete file. This test fails against the old non-atomic implementation. + #[test] + fn test_atomic_write_file_bytes_concurrent_cold_load() { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::{Arc, Barrier}; + + let dir = + std::env::temp_dir().join(format!("wm_atomic_write_test_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let final_path = dir.join("bundle.js"); + let final_path_str = final_path.to_str().unwrap().to_string(); + + // Wide payload so a hypothetical non-atomic writer has a large + // partial-read window for the reader to catch. + let payload = vec![b'x'; 4 * 1024 * 1024]; + let expected_len = payload.len(); + + for _ in 0..12 { + let _ = std::fs::remove_file(&final_path); + + let stop = Arc::new(AtomicBool::new(false)); + let partial_reads = Arc::new(AtomicUsize::new(0)); + let barrier = Arc::new(Barrier::new(2)); + + let reader = { + let stop = stop.clone(); + let partial_reads = partial_reads.clone(); + let barrier = barrier.clone(); + let path = final_path_str.clone(); + std::thread::spawn(move || { + barrier.wait(); + while !stop.load(Ordering::Relaxed) { + if std::fs::metadata(&path).is_ok() { + if let Ok(content) = std::fs::read(&path) { + if content.len() != expected_len { + partial_reads.fetch_add(1, Ordering::Relaxed); + } + } + } + std::thread::yield_now(); + } + }) + }; + + barrier.wait(); + atomic_write_file_bytes(&final_path_str, &payload, false).unwrap(); + stop.store(true, Ordering::Relaxed); + reader.join().unwrap(); + + assert_eq!( + partial_reads.load(Ordering::Relaxed), + 0, + "a concurrent reader observed a partially-written bundle" + ); + assert_eq!(std::fs::read(&final_path).unwrap().len(), expected_len); + + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().contains(".tmp.")) + .map(|e| e.path()) + .collect(); + assert!(leftovers.is_empty(), "temp files leaked: {:?}", leftovers); + } + + let _ = std::fs::remove_dir_all(&dir); + } + + // Under a thundering herd of cold-loads, N populators each build their own + // temp dir and publish to the same final dir. Every publish must succeed + // (loser-of-the-race discards its byte-identical copy), the final dir must + // be complete, and no temp dirs may leak. + #[test] + fn test_atomic_publish_dir_thundering_herd() { + use std::sync::{Arc, Barrier}; + + let base = + std::env::temp_dir().join(format!("wm_atomic_dir_test_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&base).unwrap(); + let final_dir = base.join("cache_dir"); + let final_dir_str = final_dir.to_str().unwrap().to_string(); + + let n = 8; + let barrier = Arc::new(Barrier::new(n)); + let mut handles = vec![]; + for _ in 0..n { + let barrier = barrier.clone(); + let final_dir_str = final_dir_str.clone(); + let base = base.clone(); + handles.push(std::thread::spawn(move || { + let tmp = base.join(format!("cache_dir.tmp.{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::write(tmp.join("main.js"), b"export function main() {}").unwrap(); + std::fs::write(tmp.join("meta.txt"), b"v1").unwrap(); + barrier.wait(); + atomic_publish_dir(tmp.to_str().unwrap(), &final_dir_str).unwrap(); + })); + } + for h in handles { + h.join().unwrap(); + } + + assert!(final_dir.join("main.js").is_file()); + assert!(final_dir.join("meta.txt").is_file()); + let leftovers: Vec<_> = std::fs::read_dir(&base) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| { + let n = e.file_name(); + let n = n.to_string_lossy(); + n.contains(".tmp.") || n.contains(".bak.") + }) + .map(|e| e.path()) + .collect(); + assert!( + leftovers.is_empty(), + "temp/bak dirs leaked: {:?}", + leftovers + ); + + let _ = std::fs::remove_dir_all(&base); + } + + // Contract guard: when `final_dir` ALREADY exists (a complete prior/peer + // publish — content-addressed, so identical bytes), concurrent publishers + // must all return Ok via the exists-fallback and must never corrupt or + // partially-overwrite the existing dir. Covers the preexisting+concurrent + // case; documents the accepted simple-form behavior (an existing dir is + // trusted, not rebuilt). + #[test] + fn test_atomic_publish_dir_existing_is_trusted_not_corrupted() { + use std::sync::{Arc, Barrier}; + + let base = + std::env::temp_dir().join(format!("wm_atomic_exist_test_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&base).unwrap(); + let final_dir = base.join("cache_dir"); + let final_dir_str = final_dir.to_str().unwrap().to_string(); + + // A complete dir already published at the final path. + std::fs::create_dir_all(&final_dir).unwrap(); + std::fs::write(final_dir.join("main.js"), b"export function main() {}").unwrap(); + std::fs::write(final_dir.join("meta.txt"), b"v1").unwrap(); + + let n = 8; + let barrier = Arc::new(Barrier::new(n)); + let mut handles = vec![]; + for _ in 0..n { + let barrier = barrier.clone(); + let final_dir_str = final_dir_str.clone(); + let base = base.clone(); + handles.push(std::thread::spawn(move || { + let tmp = base.join(format!("cache_dir.tmp.{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::write(tmp.join("main.js"), b"export function main() {}").unwrap(); + std::fs::write(tmp.join("meta.txt"), b"v1").unwrap(); + barrier.wait(); + // Every publisher must succeed (exists-fallback), none error. + atomic_publish_dir(tmp.to_str().unwrap(), &final_dir_str).unwrap(); + })); + } + for h in handles { + h.join().unwrap(); + } + + // Existing dir intact and complete — never partially overwritten. + assert_eq!( + std::fs::read(final_dir.join("main.js")).unwrap(), + b"export function main() {}" + ); + assert_eq!(std::fs::read(final_dir.join("meta.txt")).unwrap(), b"v1"); + let leftovers: Vec<_> = std::fs::read_dir(&base) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| { + let n = e.file_name(); + let n = n.to_string_lossy(); + n.contains(".tmp.") || n.contains(".bak.") + }) + .map(|e| e.path()) + .collect(); + assert!( + leftovers.is_empty(), + "temp/bak dirs leaked: {:?}", + leftovers + ); + + let _ = std::fs::remove_dir_all(&base); + } } diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 97ff28cae1..163de9f7b3 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -1109,7 +1109,11 @@ async fn pull_codebase(w_id: &str, id: &str, job_dir: &str) -> Result (bo if let Ok(mut x) = windmill_object_store::attempt_fetch_bytes(os, _remote_path).await { if is_dir { - if let Err(e) = windmill_common::worker::extract_tar(x, bin_path).await { + // Extract into a sibling temp dir then atomically publish it, + // so a concurrent cold-load gating on metadata(bin_path) never + // observes a half-extracted cache directory. + let tmp_dir = format!("{}.tmp.{}", bin_path, uuid::Uuid::new_v4()); + let res = match windmill_common::worker::extract_tar(x, &tmp_dir).await { + Ok(()) => windmill_common::worker::atomic_publish_dir(&tmp_dir, bin_path), + Err(e) => Err(e), + }; + if let Err(e) = res { + let _ = tokio::fs::remove_dir_all(&tmp_dir).await; tracing::error!("could not write tar archive locally: {e:?}"); return ( false, @@ -268,12 +277,21 @@ pub async fn save_cache( if true { if is_dir { - windmill_common::worker::copy_dir_recursively( + // Populate a sibling temp dir then atomically publish it, so a + // concurrent `load_cache`/`exists_in_cache` metadata() check never + // observes a half-copied cache directory. + let tmp_dir = format!("{}.tmp.{}", local_cache_path, uuid::Uuid::new_v4()); + if let Err(e) = windmill_common::worker::copy_dir_recursively( &PathBuf::from(origin), - &PathBuf::from(local_cache_path), - )?; + &PathBuf::from(&tmp_dir), + ) + .and_then(|_| windmill_common::worker::atomic_publish_dir(&tmp_dir, local_cache_path)) + { + let _ = std::fs::remove_dir_all(&tmp_dir); + return Err(e); + } } else { - std::fs::copy(origin, local_cache_path)?; + windmill_common::worker::atomic_copy_file(origin, local_cache_path)?; } Ok(format!( "\nwrote cached binary: {} (backed by EE distributed object store: {_cached_to_s3})\n", From 4e259547225e13e5b51a166a84cdbbbfa35c3264 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 16 May 2026 07:39:47 +0000 Subject: [PATCH 134/313] fix: don't show ALLOW_PRIVATE_AI_BASE_URLS hint for malformed AI base URLs (#9188) * fix: don't show ALLOW_PRIVATE_AI_BASE_URLS hint for malformed AI base URLs Co-Authored-By: Claude Opus 4.7 * fix: impl std::error::Error for SsrfValidationError for anyhow callers Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- backend/windmill-ai/src/ai_providers.rs | 16 ++-- backend/windmill-common/src/ssrf.rs | 115 ++++++++++++++++++++---- 2 files changed, 108 insertions(+), 23 deletions(-) diff --git a/backend/windmill-ai/src/ai_providers.rs b/backend/windmill-ai/src/ai_providers.rs index 2cf68273dc..b568c0accb 100644 --- a/backend/windmill-ai/src/ai_providers.rs +++ b/backend/windmill-ai/src/ai_providers.rs @@ -2,9 +2,9 @@ * This file contains shared AI provider utilities used by both the API and worker. */ +use serde::{Deserialize, Deserializer, Serialize}; use windmill_common::db::DB; use windmill_common::error::{Error, Result}; -use serde::{Deserialize, Deserializer, Serialize}; /// Deserializes an Option where empty strings become None. /// Use with `#[serde(default, deserialize_with = "empty_string_as_none")]` @@ -65,13 +65,19 @@ impl AIProvider { pub async fn get_base_url(&self, resource_base_url: Option, db: &DB) -> Result { if let Some(base_url) = resource_base_url { if !*ALLOW_PRIVATE_AI_BASE_URLS { + use windmill_common::ssrf::SsrfValidationError; windmill_common::ssrf::validate_url_for_ssrf(&base_url) .await - .map_err(|e| { - Error::BadRequest(format!( + .map_err(|e| match e { + // The env-var hint is only actionable when the URL is + // well-formed but blocked for targeting a private + // address. For a malformed URL or bad scheme, surface + // the real error so users fix the URL (issue #9171). + e @ SsrfValidationError::Private { .. } => Error::BadRequest(format!( "{e}. If you need to use private/internal AI endpoints, \ - set the ALLOW_PRIVATE_AI_BASE_URLS=true environment variable" - )) + set the ALLOW_PRIVATE_AI_BASE_URLS=true environment variable" + )), + e => Error::from(e), })?; } return Ok(base_url); diff --git a/backend/windmill-common/src/ssrf.rs b/backend/windmill-common/src/ssrf.rs index 0c99d2fafe..507a773431 100644 --- a/backend/windmill-common/src/ssrf.rs +++ b/backend/windmill-common/src/ssrf.rs @@ -2,37 +2,89 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use crate::error::Error; +/// Why a URL failed SSRF validation. +/// +/// The distinction matters for callers that gate private endpoints behind a +/// flag (e.g. `ALLOW_PRIVATE_AI_BASE_URLS`): a "set this env var" hint is only +/// actionable for [`SsrfValidationError::Private`]. Surfacing that hint for a +/// malformed URL or bad scheme sends users down the wrong path (see #9171). +#[derive(Debug)] +pub enum SsrfValidationError { + /// The URL could not be parsed (e.g. missing `http://` scheme). + InvalidUrl(String), + /// Scheme is not `http`/`https`. + DisallowedScheme(String), + /// No host in the URL. + MissingHost, + /// DNS resolution failed for the host. + ResolutionFailed { host: String, source: String }, + /// Host did not resolve to any address. + NoAddresses(String), + /// The URL targets (or resolves to) a private/internal address. `resolved` + /// is true when the host was a DNS name that resolved to a private IP. + Private { resolved: bool }, +} + +impl std::fmt::Display for SsrfValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SsrfValidationError::InvalidUrl(e) => write!(f, "Invalid URL: {e}"), + SsrfValidationError::DisallowedScheme(s) => write!( + f, + "URL scheme '{s}' is not allowed, only http and https are permitted" + ), + SsrfValidationError::MissingHost => write!(f, "URL must have a host"), + SsrfValidationError::ResolutionFailed { host, source } => { + write!(f, "Failed to resolve host '{host}': {source}") + } + SsrfValidationError::NoAddresses(host) => { + write!(f, "Host '{host}' did not resolve to any addresses") + } + SsrfValidationError::Private { resolved: false } => { + write!(f, "URL targets a private/internal IP address") + } + SsrfValidationError::Private { resolved: true } => { + write!(f, "URL resolves to a private/internal IP address") + } + } + } +} + +// Enables `?` from `validate_url_for_ssrf` in functions returning +// `anyhow::Result` (e.g. the EE SAML metadata loader). +impl std::error::Error for SsrfValidationError {} + +impl From for Error { + fn from(e: SsrfValidationError) -> Self { + Error::BadRequest(e.to_string()) + } +} + /// Validates that a URL is safe to fetch server-side (not targeting private/internal networks). /// /// Checks: /// 1. Scheme must be http or https /// 2. Host must be present and not a private/loopback/link-local IP /// 3. DNS resolution is checked to prevent DNS rebinding to internal IPs -pub async fn validate_url_for_ssrf(url: &str) -> Result<(), Error> { +pub async fn validate_url_for_ssrf(url: &str) -> Result<(), SsrfValidationError> { let parsed = - url::Url::parse(url).map_err(|e| Error::BadRequest(format!("Invalid URL: {e}")))?; + url::Url::parse(url).map_err(|e| SsrfValidationError::InvalidUrl(e.to_string()))?; // 1. Scheme check match parsed.scheme() { "http" | "https" => {} scheme => { - return Err(Error::BadRequest(format!( - "URL scheme '{scheme}' is not allowed, only http and https are permitted" - ))); + return Err(SsrfValidationError::DisallowedScheme(scheme.to_string())); } } // 2. Host check - let host = parsed - .host_str() - .ok_or_else(|| Error::BadRequest("URL must have a host".to_string()))?; + let host = parsed.host_str().ok_or(SsrfValidationError::MissingHost)?; // 3. If the host is an IP literal, check it directly if let Ok(ip) = host.parse::() { if is_private_ip(&ip) { - return Err(Error::BadRequest( - "URL targets a private/internal IP address".to_string(), - )); + return Err(SsrfValidationError::Private { resolved: false }); } return Ok(()); } @@ -45,20 +97,19 @@ pub async fn validate_url_for_ssrf(url: &str) -> Result<(), Error> { let resolve_target = format!("{host}:{port}"); let addrs: Vec = tokio::net::lookup_host(&resolve_target) .await - .map_err(|e| Error::BadRequest(format!("Failed to resolve host '{host}': {e}")))? + .map_err(|e| SsrfValidationError::ResolutionFailed { + host: host.to_string(), + source: e.to_string(), + })? .collect(); if addrs.is_empty() { - return Err(Error::BadRequest(format!( - "Host '{host}' did not resolve to any addresses" - ))); + return Err(SsrfValidationError::NoAddresses(host.to_string())); } for addr in &addrs { if is_private_ip(&addr.ip()) { - return Err(Error::BadRequest( - "URL resolves to a private/internal IP address".to_string(), - )); + return Err(SsrfValidationError::Private { resolved: true }); } } @@ -148,4 +199,32 @@ mod tests { // This resolves to a public IP assert!(validate_url_for_ssrf("https://google.com").await.is_ok()); } + + /// Regression for #9171: a malformed base URL (missing scheme) must report + /// `InvalidUrl`/`DisallowedScheme`, not `Private` — only `Private` gets the + /// "set ALLOW_PRIVATE_AI_BASE_URLS" hint, which is misleading for a typo'd + /// URL and sent the issue reporter down the wrong path. + #[tokio::test] + async fn test_error_variants_are_discriminated() { + // No scheme and no colon → the exact "relative URL without a base" + // error from the issue. + assert!(matches!( + validate_url_for_ssrf("api.example.com/v1").await, + Err(SsrfValidationError::InvalidUrl(_)) + )); + // `localhost:11434/v1` parses with `localhost` as the scheme — a very + // common Ollama misconfiguration. + assert!(matches!( + validate_url_for_ssrf("localhost:11434/v1").await, + Err(SsrfValidationError::DisallowedScheme(s)) if s == "localhost" + )); + assert!(matches!( + validate_url_for_ssrf("ftp://example.com/foo").await, + Err(SsrfValidationError::DisallowedScheme(_)) + )); + assert!(matches!( + validate_url_for_ssrf("http://127.0.0.1/foo").await, + Err(SsrfValidationError::Private { resolved: false }) + )); + } } From 302ce58e98adcff3860e8f690fcbd237831e4e9f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 16 May 2026 07:40:29 +0000 Subject: [PATCH 135/313] harden UI builder artifact bootstrap with verified pinned metadata (#9189) * feat: harden UI builder artifact bootstrap with verified pinned metadata * fix: emit tab-indented artifact json to match prettier config * refactor: rewrite artifact json with node instead of python * refactor: simplify bootstrap to flat script, drop test scaffolding --- frontend/scripts/ui_builder_artifact.json | 5 ++ frontend/scripts/untar_ui_builder.js | 73 +++++++++++------------ frontend/use_latest_ui_builder.sh | 30 ++++++---- 3 files changed, 59 insertions(+), 49 deletions(-) create mode 100644 frontend/scripts/ui_builder_artifact.json diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json new file mode 100644 index 0000000000..1cfc1aef8c --- /dev/null +++ b/frontend/scripts/ui_builder_artifact.json @@ -0,0 +1,5 @@ +{ + "baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev", + "version": "6715153", + "sha256": "1485930ea5f5309e4bdc09a55aae72eae8230eb74f0928715a0e6fe610703d9b" +} diff --git a/frontend/scripts/untar_ui_builder.js b/frontend/scripts/untar_ui_builder.js index ef31ddbc18..b7f02127ef 100644 --- a/frontend/scripts/untar_ui_builder.js +++ b/frontend/scripts/untar_ui_builder.js @@ -1,55 +1,50 @@ -import path from 'path' -import fs from 'fs' +import { createHash } from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import artifact from './ui_builder_artifact.json' with { type: 'json' } -// Check if we're in node_modules (installed as dependency) +// Skip when installed as a dependency or outside the root project if (process.cwd().includes('node_modules')) { - console.log('Skipping postinstall - running as dependency'); - process.exit(0); + console.log('Skipping postinstall - running as dependency') + process.exit(0) } - -// Check if we're in the root project if (process.env.INIT_CWD && process.env.INIT_CWD !== process.cwd()) { - console.log('Skipping postinstall - not root project'); - process.exit(0); + console.log('Skipping postinstall - not root project') + process.exit(0) } -// Your actual postinstall logic here -console.log('Running postinstall for root project'); +console.log('Running postinstall for root project') +const tarUrl = `${artifact.baseUrl}/ui_builder-${artifact.version}.tar.gz` +const response = await fetch(tarUrl) +if (!response.ok) { + throw new Error(`Failed to download ${tarUrl}: ${response.status} ${response.statusText}`) +} -import { x } from 'tar' +const buffer = Buffer.from(await response.arrayBuffer()) +const sha256 = createHash('sha256').update(buffer).digest('hex') +if (sha256 !== artifact.sha256) { + throw new Error( + `UI builder artifact checksum mismatch: expected ${artifact.sha256}, got ${sha256}` + ) +} -const tarUrl = 'https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev/ui_builder-6715153.tar.gz' const outputTarPath = path.join(process.cwd(), 'ui_builder.tar.gz') const extractTo = path.join(process.cwd(), 'static/ui_builder/') -import { fileURLToPath } from 'url' -import { dirname } from 'path' +await fs.promises.mkdir(extractTo, { recursive: true }) +await fs.promises.writeFile(outputTarPath, buffer) -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) - -// Download the tar file -const response = await fetch(tarUrl) -const buffer = await response.arrayBuffer() -await fs.promises.writeFile(outputTarPath, Buffer.from(buffer)) - - -// Create extract directory if it doesn't exist +const { x } = await import('tar') try { - await fs.promises.mkdir(extractTo, { recursive: true }) -} catch (err) { - if (err.code !== 'EEXIST') { - throw err - } + await x({ + file: outputTarPath, + cwd: extractTo, + sync: false, + gzip: true, + preservePaths: false + }) +} finally { + await fs.promises.rm(outputTarPath, { force: true }) } - -await x({ - file: outputTarPath, - cwd: extractTo, - sync: false, - gzip: true -}) - -await fs.promises.unlink(outputTarPath) diff --git a/frontend/use_latest_ui_builder.sh b/frontend/use_latest_ui_builder.sh index c64566b51b..25d9b417ab 100755 --- a/frontend/use_latest_ui_builder.sh +++ b/frontend/use_latest_ui_builder.sh @@ -1,20 +1,30 @@ #!/bin/bash - -# Auto-detect operating system -if [[ "$OSTYPE" == "darwin"* ]]; then - IS_MAC=true -else - IS_MAC=false -fi +set -euo pipefail cd ~/windmill-code-ui-builder HASH=$(git rev-parse --short HEAD) HASH=${HASH::-1} +ARTIFACT_URL="https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev/ui_builder-${HASH}.tar.gz" + +TMP_FILE=$(mktemp) +trap 'rm -f "$TMP_FILE"' EXIT echo "Using UI Builder hash: ${HASH}" +curl -fsSL "$ARTIFACT_URL" -o "$TMP_FILE" -if [ "$IS_MAC" = true ]; then - sed -i '' "s/ui_builder-[^.]*\.tar\.gz/ui_builder-${HASH}.tar.gz/" ../windmill/frontend/scripts/untar_ui_builder.js +if command -v sha256sum >/dev/null 2>&1; then + SHA256=$(sha256sum "$TMP_FILE" | awk '{print $1}') else - sed -i "s/ui_builder-[^.]*\.tar\.gz/ui_builder-${HASH}.tar.gz/" ../windmill/frontend/scripts/untar_ui_builder.js + SHA256=$(shasum -a 256 "$TMP_FILE" | awk '{print $1}') fi +echo "Using UI Builder sha256: ${SHA256}" + +node -e ' +const fs = require("fs") +const [version, sha256] = process.argv.slice(1) +const artifactPath = "../windmill/frontend/scripts/ui_builder_artifact.json" +const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8")) +artifact.version = version +artifact.sha256 = sha256 +fs.writeFileSync(artifactPath, JSON.stringify(artifact, null, "\t") + "\n") +' "$HASH" "$SHA256" From 52960ca30ab9c019186a28b3ab054a1dfe72f451 Mon Sep 17 00:00:00 2001 From: "windmill-internal-app[bot]" <217088191+windmill-internal-app[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 07:43:35 +0000 Subject: [PATCH 136/313] fix: reset parent_hash in auto_parent when all versions at path are archived (#9172) * fix: reset parent_hash in auto_parent when all versions at path are archived * test: regression test for auto_parent with all versions archived --------- Co-authored-by: windmill-internal-app[bot] <1429786+windmill-internal-app[bot]@users.noreply.github.com> Co-authored-by: Ruben Fiszel --- backend/tests/script_auto_parent_archived.rs | 148 +++++++++++++++++++ backend/windmill-api-scripts/src/scripts.rs | 2 + 2 files changed, 150 insertions(+) create mode 100644 backend/tests/script_auto_parent_archived.rs diff --git a/backend/tests/script_auto_parent_archived.rs b/backend/tests/script_auto_parent_archived.rs new file mode 100644 index 0000000000..5115c516c2 --- /dev/null +++ b/backend/tests/script_auto_parent_archived.rs @@ -0,0 +1,148 @@ +//! Regression test for `auto_parent` when all versions at a script path are +//! archived (e.g. after a rename). +//! +//! The CLI's `wmill sync push` sends `parent_hash` together with +//! `auto_parent: true`, delegating parent resolution to the backend. When every +//! version at the target path is archived, there is no active head, so the +//! stale `parent_hash` (an archived ancestor) used to leak into the lineage +//! check and produce a spurious +//! `lineage must be linear: no 2 scripts can have the same parent` error +//! whenever that archived hash already had a child from the prior rename. +//! +//! The fix clears `parent_hash` to `None` in that case so the push starts a +//! fresh lineage instead of failing. + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {}", token)) +} + +fn new_script(path: &str, content: &str) -> serde_json::Value { + json!({ + "path": path, + "summary": "", + "description": "", + "content": content, + "language": "deno", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "required": [] + } + }) +} + +#[sqlx::test(fixtures("base"))] +async fn test_auto_parent_starts_fresh_lineage_when_all_versions_archived( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace"); + + let original_path = "u/test-user/script_archived_parent"; + let renamed_path = "u/test-user/script_renamed"; + + // 1. Create the initial version at the original path. + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&new_script( + original_path, + "export async function main() { return 1; }", + )) + .send() + .await?; + assert_eq!(resp.status(), 201); + let original_hash: String = resp.text().await?; + + // 2. Rename the script (new path, parent_hash pointing at v1). This + // archives the original hash and gives the new version a + // `parent_hashes[1]` equal to `original_hash`, so the original path now + // has only archived versions. + let mut rename = new_script(renamed_path, "export async function main() { return 2; }"); + rename["parent_hash"] = json!(original_hash); + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&rename) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "rename should succeed: {}", + resp.text().await? + ); + + // Sanity: the original path has no active (non-archived) version. + let active_at_original: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND archived = false AND workspace_id = $2)", + ) + .bind(original_path) + .bind("test-workspace") + .fetch_one(&db) + .await?; + assert!( + !active_at_original, + "all versions at the original path should be archived after rename" + ); + + // 3. Reproduce `wmill sync push`: push back to the original path with the + // stale archived `parent_hash` AND `auto_parent: true`. Before the fix + // this returned 400 "lineage must be linear" because the archived hash + // already had a child (the renamed version) sharing the same parent. + let mut push = new_script(original_path, "export async function main() { return 3; }"); + push["parent_hash"] = json!(original_hash); + push["auto_parent"] = json!(true); + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&push) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!( + status, 201, + "auto_parent push to a path with only archived versions should start a \ + fresh lineage, got {status}: {body}" + ); + + // 4. There is now exactly one active version at the original path and it is + // a fresh lineage with no parent (rather than attaching to the archived + // ancestor). + let active: Vec>> = sqlx::query_scalar( + "SELECT parent_hashes FROM script \ + WHERE path = $1 AND archived = false AND workspace_id = $2", + ) + .bind(original_path) + .bind("test-workspace") + .fetch_all(&db) + .await?; + assert_eq!( + active.len(), + 1, + "exactly one active version expected at the original path" + ); + assert!( + active[0].is_none(), + "fresh lineage should have no parent_hashes, got {:?}", + active[0] + ); + + Ok(()) +} diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 2ca655ef32..c99880bbd8 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -997,6 +997,8 @@ async fn create_script_internal<'c>( if ns.auto_parent.unwrap_or(false) { if let Some(ref cs) = clashing_script { ns.parent_hash = Some(cs.hash.clone()); + } else { + ns.parent_hash = None; } } From dfeed9c5c2e39bf3e10eea4f69ea140ee9e7832f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 16 May 2026 08:29:53 +0000 Subject: [PATCH 137/313] fix: actionable error when a custom_path is taken by an app in another workspace (#9190) Co-authored-by: Claude Opus 4.7 (1M context) --- ...757b244b280e1262dcbccd2fc5189c7b3d25b.json | 31 ++++ ...d9ae0ab27c8a76302a2570473046e28c91fdd.json | 29 ++++ ...a1d606d9725e20e9c2d76a0887fadfd87f8df.json | 25 --- .../tests/app_custom_path_cross_workspace.rs | 120 ++++++++++++++ .../app_custom_path_cross_workspace.sql | 153 ++++++++++++++++++ backend/windmill-api/src/apps.rs | 82 +++++++--- 6 files changed, 392 insertions(+), 48 deletions(-) create mode 100644 backend/.sqlx/query-5347ec9ab6de69a99e8823d2199757b244b280e1262dcbccd2fc5189c7b3d25b.json create mode 100644 backend/.sqlx/query-debd7470025cf64cd1191e604fed9ae0ab27c8a76302a2570473046e28c91fdd.json delete mode 100644 backend/.sqlx/query-fb1399c1dc171ec6bb24fee3477a1d606d9725e20e9c2d76a0887fadfd87f8df.json create mode 100644 backend/tests/app_custom_path_cross_workspace.rs create mode 100644 backend/tests/fixtures/app_custom_path_cross_workspace.sql diff --git a/backend/.sqlx/query-5347ec9ab6de69a99e8823d2199757b244b280e1262dcbccd2fc5189c7b3d25b.json b/backend/.sqlx/query-5347ec9ab6de69a99e8823d2199757b244b280e1262dcbccd2fc5189c7b3d25b.json new file mode 100644 index 0000000000..871cbd144e --- /dev/null +++ b/backend/.sqlx/query-5347ec9ab6de69a99e8823d2199757b244b280e1262dcbccd2fc5189c7b3d25b.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id, path FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2) AND NOT (path = $3 AND workspace_id = $4) LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "5347ec9ab6de69a99e8823d2199757b244b280e1262dcbccd2fc5189c7b3d25b" +} diff --git a/backend/.sqlx/query-debd7470025cf64cd1191e604fed9ae0ab27c8a76302a2570473046e28c91fdd.json b/backend/.sqlx/query-debd7470025cf64cd1191e604fed9ae0ab27c8a76302a2570473046e28c91fdd.json new file mode 100644 index 0000000000..f907d4e959 --- /dev/null +++ b/backend/.sqlx/query-debd7470025cf64cd1191e604fed9ae0ab27c8a76302a2570473046e28c91fdd.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id, path FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2) LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "debd7470025cf64cd1191e604fed9ae0ab27c8a76302a2570473046e28c91fdd" +} diff --git a/backend/.sqlx/query-fb1399c1dc171ec6bb24fee3477a1d606d9725e20e9c2d76a0887fadfd87f8df.json b/backend/.sqlx/query-fb1399c1dc171ec6bb24fee3477a1d606d9725e20e9c2d76a0887fadfd87f8df.json deleted file mode 100644 index a2362be620..0000000000 --- a/backend/.sqlx/query-fb1399c1dc171ec6bb24fee3477a1d606d9725e20e9c2d76a0887fadfd87f8df.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT EXISTS(SELECT 1 FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2) AND NOT (path = $3 AND workspace_id = $4))", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "exists", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "fb1399c1dc171ec6bb24fee3477a1d606d9725e20e9c2d76a0887fadfd87f8df" -} diff --git a/backend/tests/app_custom_path_cross_workspace.rs b/backend/tests/app_custom_path_cross_workspace.rs new file mode 100644 index 0000000000..5b0002bb05 --- /dev/null +++ b/backend/tests/app_custom_path_cross_workspace.rs @@ -0,0 +1,120 @@ +//! Regression test for the cross-workspace custom_path conflict. +//! +//! When custom paths are instance-global (CLOUD_HOSTED unset and +//! `app_workspaced_route` off — the default for dedicated instances), a +//! custom_path is a single global route slot. The uniqueness check correctly +//! blocks two apps from claiming it, including the same logical app deployed +//! to two workspaces (staging/prod, git-sync). The bug was that the error +//! ("App with custom path already exists") gave the operator no idea +//! where the conflicting copy lived. This test pins down: +//! - a single-workspace edit keeping its own custom_path still succeeds +//! (the app's own row is excluded), +//! - a real conflict is still rejected, and +//! - the error now names the conflicting app's path and workspace. + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {}", token)) +} + +fn new_app(path: &str, custom_path: &str) -> serde_json::Value { + json!({ + "path": path, + "summary": "Test app", + "value": { "type": "rawapp", "inline_script": null }, + "policy": { "execution_mode": "anonymous", "triggerables": {} }, + "custom_path": custom_path + }) +} + +#[sqlx::test(fixtures("app_custom_path_cross_workspace"))] +async fn test_custom_path_cross_workspace_deploy(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let ws_a = format!("http://localhost:{port}/api/w/test-workspace"); + let ws_b = format!("http://localhost:{port}/api/w/test-workspace-2"); + + let app_path = "f/Newsletter/newsletter_composer"; + let custom_path = "newsletter"; + + // 1. Create the app with a custom path in workspace A. + let resp = authed(client().post(format!("{ws_a}/apps/create")), "SECRET_TOKEN") + .json(&new_app(app_path, custom_path)) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "create app in ws A should succeed: {}", + resp.text().await? + ); + + // 2. Editing the app in its own workspace, keeping the same custom path, + // must still succeed — the app's own row is excluded from the check. + // (This is the common single-workspace deploy; it must not regress.) + let resp = authed( + client().post(format!("{ws_a}/apps/update/{app_path}")), + "SECRET_TOKEN", + ) + .json(&json!({ + "summary": "Test app (edited)", + "value": { "type": "rawapp", "inline_script": null }, + "policy": { "execution_mode": "anonymous", "triggerables": {} }, + "custom_path": custom_path + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "editing an app in its own workspace keeping its custom path must succeed: {}", + resp.text().await? + ); + + // 3. Deploying the same app (same path) to a second workspace is a real + // conflict in global mode (one global route slot). It must be rejected, + // and the error must name the conflicting workspace + app so the + // operator knows what to resolve. + let resp = authed(client().post(format!("{ws_b}/apps/create")), "SECRET_TOKEN") + .json(&new_app(app_path, custom_path)) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!( + status, 400, + "same custom path in another workspace is a global conflict: {body}" + ); + assert!( + body.contains("test-workspace") && body.contains(app_path), + "error must name the conflicting workspace and app, got: {body}" + ); + + // 4. A genuinely different app claiming the in-use custom path is still + // rejected, with the same actionable message. + let resp = authed(client().post(format!("{ws_a}/apps/create")), "SECRET_TOKEN") + .json(&new_app("f/Other/other_app", custom_path)) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!( + status, 400, + "a different app must not steal an in-use custom path: {body}" + ); + assert!( + body.contains(app_path), + "error must name the app already using the custom path, got: {body}" + ); + + Ok(()) +} diff --git a/backend/tests/fixtures/app_custom_path_cross_workspace.sql b/backend/tests/fixtures/app_custom_path_cross_workspace.sql new file mode 100644 index 0000000000..9cb68d54d9 --- /dev/null +++ b/backend/tests/fixtures/app_custom_path_cross_workspace.sql @@ -0,0 +1,153 @@ +-- Fixture for app_custom_path_cross_workspace regression test. +-- Two workspaces sharing the same admin user, so the same logical app +-- (same `path`) can be deployed to both — exercising the instance-global +-- custom_path uniqueness behavior (CLOUD_HOSTED unset and +-- app_workspaced_route off, the default for dedicated instances). + +INSERT INTO workspace + (id, name, owner) + VALUES ('test-workspace', 'test-workspace', 'test-user'); + +INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES + ('test-workspace', 'test@windmill.dev', 'test-user', true, 'Admin'); + +INSERT INTO workspace_key(workspace_id, kind, key) VALUES + ('test-workspace', 'cloud', 'test-key'); + +INSERT INTO workspace_settings (workspace_id) VALUES + ('test-workspace'); + +INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES + ('test-workspace', 'all', 'All users', '{}'); + +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username) + VALUES ('test@windmill.dev', 'not-a-real-hash', 'password', true, true, 'Test User', 'test-user'); + +-- Second workspace, same admin user. Lets us deploy the same app path to +-- two workspaces, which is what triggered the custom_path conflict. +INSERT INTO workspace (id, name, owner) VALUES + ('test-workspace-2', 'test-workspace-2', 'test-user'); + +INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES + ('test-workspace-2', 'test@windmill.dev', 'test-user', true, 'Admin'); + +INSERT INTO workspace_key(workspace_id, kind, key) VALUES + ('test-workspace-2', 'cloud', 'test-key-2'); + +INSERT INTO workspace_settings (workspace_id) VALUES + ('test-workspace-2'); + +INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES + ('test-workspace-2', 'all', 'All users', '{}'); + +-- super_admin token so custom_path edits pass require_admin in both workspaces. +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('SECRET_TOKEN'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN', 'test@windmill.dev', 'test token', true); + +GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin; +GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user; + +CREATE FUNCTION "notify_insert_on_completed_job" () +RETURNS TRIGGER AS $$ +BEGIN + PERFORM pg_notify('completed', NEW.id::text); + RETURN NEW; +END; +$$ LANGUAGE PLPGSQL; + + CREATE TRIGGER "notify_insert_on_completed_job" + AFTER INSERT ON "v2_job_completed" + FOR EACH ROW +EXECUTE FUNCTION "notify_insert_on_completed_job" (); + + +CREATE FUNCTION "notify_queue" () +RETURNS TRIGGER AS $$ +BEGIN + PERFORM pg_notify('queued', NEW.id::text); + RETURN NEW; +END; +$$ LANGUAGE PLPGSQL; + + CREATE TRIGGER "notify_queue_after_insert" + AFTER INSERT ON "v2_job_queue" + FOR EACH ROW +EXECUTE FUNCTION "notify_queue" (); + + CREATE TRIGGER "notify_queue_after_flow_status_update" + AFTER UPDATE ON "v2_job_status" + FOR EACH ROW + WHEN (NEW.flow_status IS DISTINCT FROM OLD.flow_status) +EXECUTE FUNCTION "notify_queue" (); + +-- Apply phase 4: +DROP FUNCTION IF EXISTS v2_job_after_update CASCADE; +DROP FUNCTION IF EXISTS v2_job_completed_before_insert CASCADE; +DROP FUNCTION IF EXISTS v2_job_completed_before_update CASCADE; +DROP FUNCTION IF EXISTS v2_job_queue_after_insert CASCADE; +DROP FUNCTION IF EXISTS v2_job_queue_before_insert CASCADE; +DROP FUNCTION IF EXISTS v2_job_queue_before_update CASCADE; +DROP FUNCTION IF EXISTS v2_job_runtime_before_insert CASCADE; +DROP FUNCTION IF EXISTS v2_job_runtime_before_update CASCADE; +DROP FUNCTION IF EXISTS v2_job_status_before_insert CASCADE; +DROP FUNCTION IF EXISTS v2_job_status_before_update CASCADE; + +DROP VIEW IF EXISTS completed_job, completed_job_view, job, queue, queue_view CASCADE; + +ALTER TABLE v2_job_queue + DROP COLUMN IF EXISTS __parent_job CASCADE, + DROP COLUMN IF EXISTS __created_by CASCADE, + DROP COLUMN IF EXISTS __script_hash CASCADE, + DROP COLUMN IF EXISTS __script_path CASCADE, + DROP COLUMN IF EXISTS __args CASCADE, + DROP COLUMN IF EXISTS __logs CASCADE, + DROP COLUMN IF EXISTS __raw_code CASCADE, + DROP COLUMN IF EXISTS __canceled CASCADE, + DROP COLUMN IF EXISTS __last_ping CASCADE, + DROP COLUMN IF EXISTS __job_kind CASCADE, + DROP COLUMN IF EXISTS __env_id CASCADE, + DROP COLUMN IF EXISTS __schedule_path CASCADE, + DROP COLUMN IF EXISTS __permissioned_as CASCADE, + DROP COLUMN IF EXISTS __flow_status CASCADE, + DROP COLUMN IF EXISTS __raw_flow CASCADE, + DROP COLUMN IF EXISTS __is_flow_step CASCADE, + DROP COLUMN IF EXISTS __language CASCADE, + DROP COLUMN IF EXISTS __same_worker CASCADE, + DROP COLUMN IF EXISTS __raw_lock CASCADE, + DROP COLUMN IF EXISTS __pre_run_error CASCADE, + DROP COLUMN IF EXISTS __email CASCADE, + DROP COLUMN IF EXISTS __visible_to_owner CASCADE, + DROP COLUMN IF EXISTS __mem_peak CASCADE, + DROP COLUMN IF EXISTS __root_job CASCADE, + DROP COLUMN IF EXISTS __leaf_jobs CASCADE, + DROP COLUMN IF EXISTS __concurrent_limit CASCADE, + DROP COLUMN IF EXISTS __concurrency_time_window_s CASCADE, + DROP COLUMN IF EXISTS __timeout CASCADE, + DROP COLUMN IF EXISTS __flow_step_id CASCADE, + DROP COLUMN IF EXISTS __cache_ttl CASCADE; + +LOCK TABLE v2_job_queue IN ACCESS EXCLUSIVE MODE; +ALTER TABLE v2_job_completed + DROP COLUMN IF EXISTS __parent_job CASCADE, + DROP COLUMN IF EXISTS __created_by CASCADE, + DROP COLUMN IF EXISTS __created_at CASCADE, + DROP COLUMN IF EXISTS __success CASCADE, + DROP COLUMN IF EXISTS __script_hash CASCADE, + DROP COLUMN IF EXISTS __script_path CASCADE, + DROP COLUMN IF EXISTS __args CASCADE, + DROP COLUMN IF EXISTS __logs CASCADE, + DROP COLUMN IF EXISTS __raw_code CASCADE, + DROP COLUMN IF EXISTS __canceled CASCADE, + DROP COLUMN IF EXISTS __job_kind CASCADE, + DROP COLUMN IF EXISTS __env_id CASCADE, + DROP COLUMN IF EXISTS __schedule_path CASCADE, + DROP COLUMN IF EXISTS __permissioned_as CASCADE, + DROP COLUMN IF EXISTS __raw_flow CASCADE, + DROP COLUMN IF EXISTS __is_flow_step CASCADE, + DROP COLUMN IF EXISTS __language CASCADE, + DROP COLUMN IF EXISTS __is_skipped CASCADE, + DROP COLUMN IF EXISTS __raw_lock CASCADE, + DROP COLUMN IF EXISTS __email CASCADE, + DROP COLUMN IF EXISTS __visible_to_owner CASCADE, + DROP COLUMN IF EXISTS __tag CASCADE, + DROP COLUMN IF EXISTS __priority CASCADE; diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index a5e9146ddb..ad455ac46a 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -1213,6 +1213,32 @@ async fn create_app( Ok((StatusCode::CREATED, path)) } +/// Actionable error when a custom path is already taken. In global mode (not +/// CLOUD_HOSTED and `app_workspaced_route` off) custom paths are unique across +/// the whole instance, so the conflicting copy may live in another workspace +/// (e.g. the same app deployed/git-synced to staging and prod) — name it so +/// the operator knows exactly what to remove. +fn custom_path_conflict_error( + custom_path: &str, + conflict_path: &str, + conflict_workspace: &str, + scoped: bool, +) -> Error { + if scoped { + Error::BadRequest(format!( + "Custom path '{}' is already used by app '{}' in this workspace", + custom_path, conflict_path + )) + } else { + Error::BadRequest(format!( + "Custom path '{}' is already used by app '{}' in workspace '{}'. \ + Custom paths must be unique across the whole instance unless the \ + 'app_workspaced_route' instance setting is enabled.", + custom_path, conflict_path, conflict_workspace + )) + } +} + async fn create_app_internal<'a>( authed: ApiAuthed, db: sqlx::Pool, @@ -1284,21 +1310,24 @@ async fn create_app_internal<'a>( } if let Some(custom_path) = &app.custom_path { require_admin(authed.is_admin, &authed.username)?; - let as_workspaced_route = APP_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed); + let scoped = + *CLOUD_HOSTED || APP_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed); - let exists = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2))", + let conflict = sqlx::query!( + "SELECT workspace_id, path FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2) LIMIT 1", custom_path, - if *CLOUD_HOSTED || as_workspaced_route { Some(w_id) } else { None } + if scoped { Some(w_id) } else { None } ) - .fetch_one(&mut *tx) - .await?.unwrap_or(false); + .fetch_optional(&mut *tx) + .await?; - if exists { - return Err(Error::BadRequest(format!( - "App with custom path {} already exists", - custom_path - ))); + if let Some(conflict) = conflict { + return Err(custom_path_conflict_error( + custom_path, + &conflict.path, + &conflict.workspace_id, + scoped, + )); } } sqlx::query!( @@ -1781,27 +1810,34 @@ async fn update_app_internal<'a>( if let Some(ncustom_path) = &ns.custom_path { require_admin(authed.is_admin, &authed.username)?; - let as_workspaced_route = - APP_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed); + let scoped = + *CLOUD_HOSTED || APP_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed); if ncustom_path.is_empty() { sqlb.set("custom_path", "NULL"); } else { - let exists = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2) AND NOT (path = $3 AND workspace_id = $4))", + // Same predicate as before (the check is correct): the app's + // own row in this workspace is excluded, so a single-workspace + // edit still works. In global mode a copy of this app in + // another workspace is a genuine conflict (one global route) — + // surface which workspace so it can be resolved. + let conflict = sqlx::query!( + "SELECT workspace_id, path FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2) AND NOT (path = $3 AND workspace_id = $4) LIMIT 1", ncustom_path, - if *CLOUD_HOSTED || as_workspaced_route { Some(w_id) } else { None }, + if scoped { Some(w_id) } else { None }, path, w_id ) - .fetch_one(&mut *tx) - .await?.unwrap_or(false); + .fetch_optional(&mut *tx) + .await?; - if exists { - return Err(Error::BadRequest(format!( - "App with custom path {} already exists", - ncustom_path - ))); + if let Some(conflict) = conflict { + return Err(custom_path_conflict_error( + ncustom_path, + &conflict.path, + &conflict.workspace_id, + scoped, + )); } sqlb.set_str("custom_path", ncustom_path); } From fa090f3081b4fec945f25dd9393a7f495eef8642 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 16 May 2026 08:42:33 +0000 Subject: [PATCH 138/313] chore(main): release 1.703.1 (#9182) * chore(main): release 1.703.1 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 11 ++ backend/Cargo.lock | 156 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 129 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 780d1e05d5..e69cc9b1dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.703.1](https://github.com/windmill-labs/windmill/compare/v1.703.0...v1.703.1) (2026-05-16) + + +### Bug Fixes + +* actionable error when a custom_path is taken by an app in another workspace ([#9190](https://github.com/windmill-labs/windmill/issues/9190)) ([dfeed9c](https://github.com/windmill-labs/windmill/commit/dfeed9c5c2e39bf3e10eea4f69ea140ee9e7832f)) +* atomic bundle cache writes to prevent parallel cold-load race ([#9186](https://github.com/windmill-labs/windmill/issues/9186)) ([81b5736](https://github.com/windmill-labs/windmill/commit/81b573610692b386e4861ef989fa7698b53fc861)) +* detect S3 assets passed as SDK object arg in ts parser ([#9181](https://github.com/windmill-labs/windmill/issues/9181)) ([6a334e9](https://github.com/windmill-labs/windmill/commit/6a334e9a07a7d0cffabde48be75263b0844d586c)) +* don't show ALLOW_PRIVATE_AI_BASE_URLS hint for malformed AI base URLs ([#9188](https://github.com/windmill-labs/windmill/issues/9188)) ([4e25954](https://github.com/windmill-labs/windmill/commit/4e259547225e13e5b51a166a84cdbbbfa35c3264)) +* reset parent_hash in auto_parent when all versions at path are archived ([#9172](https://github.com/windmill-labs/windmill/issues/9172)) ([52960ca](https://github.com/windmill-labs/windmill/commit/52960ca30ab9c019186a28b3ab054a1dfe72f451)) + ## [1.703.0](https://github.com/windmill-labs/windmill/compare/v1.702.1...v1.703.0) (2026-05-15) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index eaa9d9f40f..336cf3687a 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -13788,7 +13788,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-nats", @@ -13869,7 +13869,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.703.0" +version = "1.703.1" dependencies = [ "async-trait", "aws-config", @@ -13899,7 +13899,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.703.0" +version = "1.703.1" dependencies = [ "axum 0.8.9", "chrono", @@ -13912,7 +13912,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "argon2", @@ -14055,7 +14055,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.703.0" +version = "1.703.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14078,7 +14078,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.703.0" +version = "1.703.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14091,7 +14091,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14117,7 +14117,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.703.0" +version = "1.703.1" dependencies = [ "reqwest 0.12.28", "serde", @@ -14127,7 +14127,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.703.0" +version = "1.703.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14144,7 +14144,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.703.0" +version = "1.703.1" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14166,7 +14166,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14189,7 +14189,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.703.0" +version = "1.703.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14205,7 +14205,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.703.0" +version = "1.703.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14226,7 +14226,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.703.0" +version = "1.703.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14247,7 +14247,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.703.0" +version = "1.703.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14261,7 +14261,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-nats", @@ -14293,7 +14293,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14318,7 +14318,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.703.0" +version = "1.703.1" dependencies = [ "axum 0.8.9", "flate2", @@ -14336,7 +14336,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14358,7 +14358,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.703.0" +version = "1.703.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14378,7 +14378,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.703.0" +version = "1.703.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14408,7 +14408,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14436,7 +14436,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.703.0" +version = "1.703.1" dependencies = [ "lazy_static", "serde", @@ -14448,7 +14448,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.703.0" +version = "1.703.1" dependencies = [ "argon2", "axum 0.8.9", @@ -14473,7 +14473,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.703.0" +version = "1.703.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14487,7 +14487,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.703.0" +version = "1.703.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14520,7 +14520,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.703.0" +version = "1.703.1" dependencies = [ "chrono", "lazy_static", @@ -14534,7 +14534,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14553,7 +14553,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.703.0" +version = "1.703.1" dependencies = [ "aes-gcm", "aho-corasick", @@ -14654,7 +14654,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.703.0" +version = "1.703.1" dependencies = [ "chrono", "itertools 0.14.0", @@ -14673,7 +14673,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.703.0" +version = "1.703.1" dependencies = [ "regex", "serde", @@ -14688,7 +14688,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14712,7 +14712,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "futures", @@ -14729,7 +14729,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.703.0" +version = "1.703.1" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14745,7 +14745,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-trait", @@ -14766,7 +14766,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-trait", @@ -14797,7 +14797,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "arc-swap", @@ -14822,7 +14822,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-stream", @@ -14856,7 +14856,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "futures", @@ -14874,7 +14874,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.703.0" +version = "1.703.1" dependencies = [ "convert_case 0.6.0", "serde", @@ -14883,7 +14883,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "lazy_static", @@ -14895,7 +14895,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "serde_json", @@ -14907,7 +14907,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "gosyn", @@ -14919,7 +14919,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "lazy_static", @@ -14931,7 +14931,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "serde_json", @@ -14943,7 +14943,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "nu-parser", @@ -14954,7 +14954,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14965,7 +14965,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14977,7 +14977,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "rustpython-ast", @@ -14988,7 +14988,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-recursion", @@ -15010,7 +15010,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "serde_json", @@ -15022,7 +15022,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "lazy_static", @@ -15036,7 +15036,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15053,7 +15053,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "lazy_static", @@ -15066,7 +15066,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "serde", @@ -15078,7 +15078,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "lazy_static", @@ -15096,7 +15096,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15112,7 +15112,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "rustpython-ast", @@ -15128,7 +15128,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "serde", @@ -15139,7 +15139,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-recursion", @@ -15176,7 +15176,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "const_format", @@ -15214,7 +15214,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.703.0" +version = "1.703.1" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15225,7 +15225,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-recursion", @@ -15255,7 +15255,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-trait", @@ -15279,7 +15279,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-trait", @@ -15312,7 +15312,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-trait", @@ -15345,7 +15345,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-trait", @@ -15365,7 +15365,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-trait", @@ -15399,7 +15399,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-trait", @@ -15435,7 +15435,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-trait", @@ -15458,7 +15458,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-trait", @@ -15482,7 +15482,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-nats", @@ -15506,7 +15506,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-trait", @@ -15541,7 +15541,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-trait", @@ -15569,7 +15569,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-trait", @@ -15592,7 +15592,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -15611,7 +15611,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-once-cell", @@ -15720,7 +15720,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.703.0" +version = "1.703.1" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d2241b76b8..96791bd924 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.703.0" +version = "1.703.1" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.703.0" +version = "1.703.1" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 7590f3d6b8..4026a4f503 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.703.0" +version = "1.703.1" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.703.0" +version = "1.703.1" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.703.0" +version = "1.703.1" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.703.0" +version = "1.703.1" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index f7cfe81360..b335ad61cf 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.703.0" +version = "1.703.1" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 2270b0f683..f767b93f93 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.703.0 + version: 1.703.1 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index f5a7776ae2..9396cfb9da 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.703.0"; +export const VERSION = "v1.703.1"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index cf90e54816..5df28c32a9 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -79,7 +79,7 @@ export { token, }; -export const VERSION = "1.703.0"; +export const VERSION = "1.703.1"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f77e1c38a1..c7bda003ec 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.703.0", + "version": "1.703.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.703.0", + "version": "1.703.1", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index ffb90bcf11..3c13c316e7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.703.0", + "version": "1.703.1", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index e1d1fac799..3e00c57200 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.703.0" +wmill = ">=1.703.1" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 2552c212d2..7b66bf0278 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.703.0 + version: 1.703.1 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 6365105fbe..3e27047479 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.703.0' + ModuleVersion = '1.703.1' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index ccf46bdb34..91ec09d738 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.703.0" +version = "1.703.1" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 93f6685488..8ac35eea0e 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.703.0", + "version": "1.703.1", "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 fae7c6b76e..4cf7d30da9 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.703.0", + "version": "1.703.1", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index e6654fc203..a14cc5ef92 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.703.0 +1.703.1 From 25172bdc28349e810783c7c0c3012389d8c6115f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 16 May 2026 12:13:15 +0000 Subject: [PATCH 139/313] sidebar rendering expanded-but-empty at sub-pixel widths near 768px (#9191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: sidebar menu rendering expanded-but-empty near 768px width The desktop sidebar branch is gated by JS (`innerWidth < 768`), but its width was set only via Tailwind `md:` classes (`@media (min-width:768px)`). `window.innerWidth` rounds fractional viewport widths, so at e.g. 767.8px JS rounds to 768 and renders the desktop sidebar, while the CSS media query does not match and no width class applies — leaving the sidebar shell expanded with no width/content. Drop the now-redundant `md:` prefix so width tracks the JS branch decision. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: align content offset breakpoint with sidebar JS gate The sidebar width now follows the JS innerWidth gate, but the main content left-offset in AiChatLayout still used the `md:` CSS media query, leaving the two breakpoints out of sync in the same sub-pixel band. Pass an `isMobile` flag from the layout (mirroring the sidebar's `innerWidth < 768` condition) and gate the content padding on it with unprefixed classes so sidebar width and content offset always flip together. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- frontend/src/lib/components/copilot/chat/AiChatLayout.svelte | 4 +++- frontend/src/routes/(root)/(logged)/+layout.svelte | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte index aa14cf1aa9..27adb56ef3 100644 --- a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte +++ b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte @@ -15,6 +15,7 @@ interface Props { noPadding?: boolean isCollapsed?: boolean + isMobile?: boolean children: any onMenuOpen?: () => void disableAi?: boolean @@ -22,6 +23,7 @@ let { noPadding: noBorder = false, isCollapsed = false, + isMobile = false, children, onMenuOpen, disableAi @@ -56,7 +58,7 @@ id="content" class={classNames( 'w-full flex-1 flex flex-col overflow-y-auto min-h-0', - noBorder || $userStore?.operator ? '!pl-0' : isCollapsed ? 'md:pl-12' : 'md:pl-40', + noBorder || $userStore?.operator || isMobile ? '!pl-0' : isCollapsed ? 'pl-12' : 'pl-40', 'transition-all ease-in-out duration-200' )} > diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index abab3fe20d..4b044508d3 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -598,7 +598,7 @@ id="sidebar" class={classNames( 'flex flex-col fixed inset-y-0 transition-all ease-in-out duration-200 z-40 ', - isCollapsed ? 'md:w-12' : 'md:w-40', + isCollapsed ? 'w-12' : 'w-40', devOnly ? '!hidden' : '' )} > @@ -824,6 +824,7 @@ {children} noPadding={devOnly} {isCollapsed} + isMobile={innerWidth < 768} onMenuOpen={() => { menuOpen = true }} From f8467f38c8a053117ce62f96684cfb15ef792f08 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 17 May 2026 12:54:38 +0000 Subject: [PATCH 140/313] fix: prevent cross-tenant DNS poisoning via writable /etc in nsjail (#9194) * fix: bind /etc resolver files read-only in nsjail sandboxes * docs(nsjail): explain why per-file /etc resolver binds are load-bearing The explicit /etc/hosts, /etc/resolv.conf and /etc/hostname binds look like removable duplication of the read-only /etc bind above them. They are not: on Kubernetes those files are separate kubelet bind-mounts on top of /etc and nsjail's read-only remount is non-recursive, so without these shadow binds they stay writable and a job can persist cross-tenant DNS poisoning for the pod lifetime. Comment guards against a future "dedup cleanup" silently reintroducing the vulnerability. Co-Authored-By: Claude Opus 4.7 (1M context) * docs(nsjail): shorten the load-bearing-bind comment to 3 lines Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude --- .../nsjail/download.py.config.proto | 24 +++++++++++++++++++ .../nsjail/download.ruby.config.proto | 24 +++++++++++++++++++ .../nsjail/download.rust.config.proto | 24 +++++++++++++++++++ .../nsjail/install.r.config.proto | 24 +++++++++++++++++++ .../nsjail/lock.ruby.config.proto | 24 +++++++++++++++++++ .../nsjail/run.ansible.config.proto | 24 +++++++++++++++++++ .../nsjail/run.bash.config.proto | 24 +++++++++++++++++++ .../nsjail/run.bun.config.proto | 24 +++++++++++++++++++ .../nsjail/run.csharp.config.proto | 24 +++++++++++++++++++ .../nsjail/run.go.config.proto | 24 +++++++++++++++++++ .../nsjail/run.java.config.proto | 24 +++++++++++++++++++ .../nsjail/run.nu.config.proto | 24 +++++++++++++++++++ .../nsjail/run.php.config.proto | 24 +++++++++++++++++++ .../nsjail/run.powershell.config.proto | 24 +++++++++++++++++++ .../nsjail/run.python3.config.proto | 24 +++++++++++++++++++ .../windmill-worker/nsjail/run.r.config.proto | 24 +++++++++++++++++++ .../nsjail/run.ruby.config.proto | 24 +++++++++++++++++++ .../nsjail/run.rust.config.proto | 24 +++++++++++++++++++ 18 files changed, 432 insertions(+) diff --git a/backend/windmill-worker/nsjail/download.py.config.proto b/backend/windmill-worker/nsjail/download.py.config.proto index 15fa807f66..ee5b3cc402 100644 --- a/backend/windmill-worker/nsjail/download.py.config.proto +++ b/backend/windmill-worker/nsjail/download.py.config.proto @@ -55,6 +55,30 @@ mount { is_bind: true } +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + mount { src: "/dev/null" dst: "/dev/null" diff --git a/backend/windmill-worker/nsjail/download.ruby.config.proto b/backend/windmill-worker/nsjail/download.ruby.config.proto index e967623893..12a6012c59 100644 --- a/backend/windmill-worker/nsjail/download.ruby.config.proto +++ b/backend/windmill-worker/nsjail/download.ruby.config.proto @@ -55,6 +55,30 @@ mount { is_bind: true } +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + mount { src: "/dev/null" dst: "/dev/null" diff --git a/backend/windmill-worker/nsjail/download.rust.config.proto b/backend/windmill-worker/nsjail/download.rust.config.proto index c172a3752c..d86fdedd36 100644 --- a/backend/windmill-worker/nsjail/download.rust.config.proto +++ b/backend/windmill-worker/nsjail/download.rust.config.proto @@ -62,6 +62,30 @@ mount { is_bind: true } +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + mount { src: "/dev/random" dst: "/dev/random" diff --git a/backend/windmill-worker/nsjail/install.r.config.proto b/backend/windmill-worker/nsjail/install.r.config.proto index 8163be8bbb..dff9acde15 100644 --- a/backend/windmill-worker/nsjail/install.r.config.proto +++ b/backend/windmill-worker/nsjail/install.r.config.proto @@ -55,6 +55,30 @@ mount { is_bind: true } +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + mount { src: "/dev/null" dst: "/dev/null" diff --git a/backend/windmill-worker/nsjail/lock.ruby.config.proto b/backend/windmill-worker/nsjail/lock.ruby.config.proto index 86d46b8303..6f82f3b748 100644 --- a/backend/windmill-worker/nsjail/lock.ruby.config.proto +++ b/backend/windmill-worker/nsjail/lock.ruby.config.proto @@ -55,6 +55,30 @@ mount { is_bind: true } +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + mount { src: "/dev/null" dst: "/dev/null" diff --git a/backend/windmill-worker/nsjail/run.ansible.config.proto b/backend/windmill-worker/nsjail/run.ansible.config.proto index 4e63927a66..cba55d2892 100644 --- a/backend/windmill-worker/nsjail/run.ansible.config.proto +++ b/backend/windmill-worker/nsjail/run.ansible.config.proto @@ -123,6 +123,30 @@ mount { is_bind: true } +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + mount { src: "/dev/random" dst: "/dev/random" diff --git a/backend/windmill-worker/nsjail/run.bash.config.proto b/backend/windmill-worker/nsjail/run.bash.config.proto index 5091882e37..430437a3d7 100644 --- a/backend/windmill-worker/nsjail/run.bash.config.proto +++ b/backend/windmill-worker/nsjail/run.bash.config.proto @@ -95,6 +95,30 @@ mount { is_bind: true } +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + mount { src: "/dev/random" dst: "/dev/random" diff --git a/backend/windmill-worker/nsjail/run.bun.config.proto b/backend/windmill-worker/nsjail/run.bun.config.proto index 45afd80108..ef4f054097 100644 --- a/backend/windmill-worker/nsjail/run.bun.config.proto +++ b/backend/windmill-worker/nsjail/run.bun.config.proto @@ -153,6 +153,30 @@ mount { is_bind: true } +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + mount { src: "/dev/random" dst: "/dev/random" diff --git a/backend/windmill-worker/nsjail/run.csharp.config.proto b/backend/windmill-worker/nsjail/run.csharp.config.proto index 4bc0684193..1a2bb97897 100644 --- a/backend/windmill-worker/nsjail/run.csharp.config.proto +++ b/backend/windmill-worker/nsjail/run.csharp.config.proto @@ -91,6 +91,30 @@ mount { is_bind: true } +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + mount { src: "/dev/random" dst: "/dev/random" diff --git a/backend/windmill-worker/nsjail/run.go.config.proto b/backend/windmill-worker/nsjail/run.go.config.proto index fabc4054a4..d5b7cef099 100644 --- a/backend/windmill-worker/nsjail/run.go.config.proto +++ b/backend/windmill-worker/nsjail/run.go.config.proto @@ -84,6 +84,30 @@ mount { is_bind: true } +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + mount { src: "/dev/random" dst: "/dev/random" diff --git a/backend/windmill-worker/nsjail/run.java.config.proto b/backend/windmill-worker/nsjail/run.java.config.proto index 7c3f15f7e5..be42e98682 100644 --- a/backend/windmill-worker/nsjail/run.java.config.proto +++ b/backend/windmill-worker/nsjail/run.java.config.proto @@ -92,6 +92,30 @@ mount { is_bind: true } +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + mount { src: "/dev/random" dst: "/dev/random" diff --git a/backend/windmill-worker/nsjail/run.nu.config.proto b/backend/windmill-worker/nsjail/run.nu.config.proto index 4cbfdece91..c2c663c613 100644 --- a/backend/windmill-worker/nsjail/run.nu.config.proto +++ b/backend/windmill-worker/nsjail/run.nu.config.proto @@ -89,6 +89,30 @@ mount { is_bind: true } +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + mount { src: "/dev/random" dst: "/dev/random" diff --git a/backend/windmill-worker/nsjail/run.php.config.proto b/backend/windmill-worker/nsjail/run.php.config.proto index 46b03ce872..959ac56f51 100644 --- a/backend/windmill-worker/nsjail/run.php.config.proto +++ b/backend/windmill-worker/nsjail/run.php.config.proto @@ -78,6 +78,30 @@ mount { is_bind: true } +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + mount { src: "/dev/random" dst: "/dev/random" diff --git a/backend/windmill-worker/nsjail/run.powershell.config.proto b/backend/windmill-worker/nsjail/run.powershell.config.proto index ebbcb26ed4..3c9dc8dc57 100644 --- a/backend/windmill-worker/nsjail/run.powershell.config.proto +++ b/backend/windmill-worker/nsjail/run.powershell.config.proto @@ -91,6 +91,30 @@ mount { is_bind: true } +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + mount { src: "/dev/random" dst: "/dev/random" diff --git a/backend/windmill-worker/nsjail/run.python3.config.proto b/backend/windmill-worker/nsjail/run.python3.config.proto index 0b308b1933..3c272e6d2b 100644 --- a/backend/windmill-worker/nsjail/run.python3.config.proto +++ b/backend/windmill-worker/nsjail/run.python3.config.proto @@ -107,6 +107,30 @@ mount { is_bind: true } +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + mount { dst: "/dev/shm" fstype: "tmpfs" diff --git a/backend/windmill-worker/nsjail/run.r.config.proto b/backend/windmill-worker/nsjail/run.r.config.proto index 72c30f489b..828e7528e0 100644 --- a/backend/windmill-worker/nsjail/run.r.config.proto +++ b/backend/windmill-worker/nsjail/run.r.config.proto @@ -92,6 +92,30 @@ mount { is_bind: true } +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + mount { src: "/sys/devices/system/cpu" dst: "/sys/devices/system/cpu" diff --git a/backend/windmill-worker/nsjail/run.ruby.config.proto b/backend/windmill-worker/nsjail/run.ruby.config.proto index 1b43ee8b2f..09d62f5a0f 100644 --- a/backend/windmill-worker/nsjail/run.ruby.config.proto +++ b/backend/windmill-worker/nsjail/run.ruby.config.proto @@ -92,6 +92,30 @@ mount { is_bind: true } +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + mount { src: "/dev/random" dst: "/dev/random" diff --git a/backend/windmill-worker/nsjail/run.rust.config.proto b/backend/windmill-worker/nsjail/run.rust.config.proto index cb5c099a03..86ef63bd26 100644 --- a/backend/windmill-worker/nsjail/run.rust.config.proto +++ b/backend/windmill-worker/nsjail/run.rust.config.proto @@ -84,6 +84,30 @@ mount { is_bind: true } +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + mount { src: "/dev/random" dst: "/dev/random" From 20719b47311327824c79421b70fe2ae6a806d4db Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 17 May 2026 12:59:58 +0000 Subject: [PATCH 141/313] chore(main): release 1.703.2 (#9195) * chore(main): release 1.703.2 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 7 + backend/Cargo.lock | 164 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 ++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 129 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e69cc9b1dc..62117ba484 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.703.2](https://github.com/windmill-labs/windmill/compare/v1.703.1...v1.703.2) (2026-05-17) + + +### Bug Fixes + +* prevent cross-tenant DNS poisoning via writable /etc in nsjail ([#9194](https://github.com/windmill-labs/windmill/issues/9194)) ([f8467f3](https://github.com/windmill-labs/windmill/commit/f8467f38c8a053117ce62f96684cfb15ef792f08)) + ## [1.703.1](https://github.com/windmill-labs/windmill/compare/v1.703.0...v1.703.1) (2026-05-16) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 336cf3687a..5791ce7acf 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -7831,9 +7831,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.79" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ "bitflags 2.11.1", "cfg-if", @@ -7877,9 +7877,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.115" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -13788,7 +13788,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-nats", @@ -13869,7 +13869,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.703.1" +version = "1.703.2" dependencies = [ "async-trait", "aws-config", @@ -13899,7 +13899,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.703.1" +version = "1.703.2" dependencies = [ "axum 0.8.9", "chrono", @@ -13912,7 +13912,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "argon2", @@ -14055,7 +14055,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.703.1" +version = "1.703.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14078,7 +14078,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.703.1" +version = "1.703.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14091,7 +14091,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "axum 0.8.9", @@ -14117,7 +14117,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.703.1" +version = "1.703.2" dependencies = [ "reqwest 0.12.28", "serde", @@ -14127,7 +14127,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.703.1" +version = "1.703.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14144,7 +14144,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.703.1" +version = "1.703.2" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14166,7 +14166,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "axum 0.8.9", @@ -14189,7 +14189,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.703.1" +version = "1.703.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14205,7 +14205,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.703.1" +version = "1.703.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14226,7 +14226,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.703.1" +version = "1.703.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14247,7 +14247,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.703.1" +version = "1.703.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14261,7 +14261,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-nats", @@ -14293,7 +14293,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "axum 0.8.9", @@ -14318,7 +14318,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.703.1" +version = "1.703.2" dependencies = [ "axum 0.8.9", "flate2", @@ -14336,7 +14336,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "axum 0.8.9", @@ -14358,7 +14358,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.703.1" +version = "1.703.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14378,7 +14378,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.703.1" +version = "1.703.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14408,7 +14408,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "axum 0.8.9", @@ -14436,7 +14436,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.703.1" +version = "1.703.2" dependencies = [ "lazy_static", "serde", @@ -14448,7 +14448,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.703.1" +version = "1.703.2" dependencies = [ "argon2", "axum 0.8.9", @@ -14473,7 +14473,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.703.1" +version = "1.703.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14487,7 +14487,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.703.1" +version = "1.703.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14520,7 +14520,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.703.1" +version = "1.703.2" dependencies = [ "chrono", "lazy_static", @@ -14534,7 +14534,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "axum 0.8.9", @@ -14553,7 +14553,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.703.1" +version = "1.703.2" dependencies = [ "aes-gcm", "aho-corasick", @@ -14654,7 +14654,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.703.1" +version = "1.703.2" dependencies = [ "chrono", "itertools 0.14.0", @@ -14673,7 +14673,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.703.1" +version = "1.703.2" dependencies = [ "regex", "serde", @@ -14688,7 +14688,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14712,7 +14712,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "futures", @@ -14729,7 +14729,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.703.1" +version = "1.703.2" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14745,7 +14745,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-trait", @@ -14766,7 +14766,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-trait", @@ -14797,7 +14797,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "arc-swap", @@ -14822,7 +14822,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-stream", @@ -14856,7 +14856,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "futures", @@ -14874,7 +14874,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.703.1" +version = "1.703.2" dependencies = [ "convert_case 0.6.0", "serde", @@ -14883,7 +14883,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "lazy_static", @@ -14895,7 +14895,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "serde_json", @@ -14907,7 +14907,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "gosyn", @@ -14919,7 +14919,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "lazy_static", @@ -14931,7 +14931,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "serde_json", @@ -14943,7 +14943,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "nu-parser", @@ -14954,7 +14954,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14965,7 +14965,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14977,7 +14977,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "rustpython-ast", @@ -14988,7 +14988,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-recursion", @@ -15010,7 +15010,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "serde_json", @@ -15022,7 +15022,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "lazy_static", @@ -15036,7 +15036,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15053,7 +15053,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "lazy_static", @@ -15066,7 +15066,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "serde", @@ -15078,7 +15078,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "lazy_static", @@ -15096,7 +15096,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15112,7 +15112,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "rustpython-ast", @@ -15128,7 +15128,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "serde", @@ -15139,7 +15139,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-recursion", @@ -15176,7 +15176,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "const_format", @@ -15214,7 +15214,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.703.1" +version = "1.703.2" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15225,7 +15225,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-recursion", @@ -15255,7 +15255,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-trait", @@ -15279,7 +15279,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-trait", @@ -15312,7 +15312,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-trait", @@ -15345,7 +15345,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-trait", @@ -15365,7 +15365,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-trait", @@ -15399,7 +15399,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-trait", @@ -15435,7 +15435,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-trait", @@ -15458,7 +15458,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-trait", @@ -15482,7 +15482,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-nats", @@ -15506,7 +15506,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-trait", @@ -15541,7 +15541,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-trait", @@ -15569,7 +15569,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-trait", @@ -15592,7 +15592,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -15611,7 +15611,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-once-cell", @@ -15720,7 +15720,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.703.1" +version = "1.703.2" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 96791bd924..d25b5e499e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.703.1" +version = "1.703.2" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.703.1" +version = "1.703.2" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 4026a4f503..51b4711603 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.703.1" +version = "1.703.2" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.703.1" +version = "1.703.2" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.703.1" +version = "1.703.2" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.703.1" +version = "1.703.2" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index b335ad61cf..0d3ee9616f 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.703.1" +version = "1.703.2" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index f767b93f93..3e229abd35 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.703.1 + version: 1.703.2 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 9396cfb9da..91586f7338 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.703.1"; +export const VERSION = "v1.703.2"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index 5df28c32a9..243b7cc5b4 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -79,7 +79,7 @@ export { token, }; -export const VERSION = "1.703.1"; +export const VERSION = "1.703.2"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c7bda003ec..2a177b6bed 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.703.1", + "version": "1.703.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.703.1", + "version": "1.703.2", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 3c13c316e7..2af859027f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.703.1", + "version": "1.703.2", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 3e00c57200..a8888aecd3 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.703.1" +wmill = ">=1.703.2" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 7b66bf0278..95100b7dd4 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.703.1 + version: 1.703.2 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 3e27047479..e774824d59 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.703.1' + ModuleVersion = '1.703.2' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 91ec09d738..559813a186 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.703.1" +version = "1.703.2" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 8ac35eea0e..45a77b379b 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.703.1", + "version": "1.703.2", "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 4cf7d30da9..bc4d704f5f 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.703.1", + "version": "1.703.2", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index a14cc5ef92..1001cd0cf3 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.703.1 +1.703.2 From 8bc2295b94df159a7c8630cdbe02953b8b7c13a1 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 17 May 2026 14:00:06 +0000 Subject: [PATCH 142/313] fix(mcp): validate oauth dynamic client registration redirect_uris (#9197) Co-authored-by: Claude Opus 4.7 (1M context) --- backend/windmill-api/src/mcp/oauth_server.rs | 75 +++++++++++++++++++ .../(logged)/oauth/mcp_authorize/+page.svelte | 20 ++++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/backend/windmill-api/src/mcp/oauth_server.rs b/backend/windmill-api/src/mcp/oauth_server.rs index 48c4f73c44..7cfabd2319 100644 --- a/backend/windmill-api/src/mcp/oauth_server.rs +++ b/backend/windmill-api/src/mcp/oauth_server.rs @@ -285,6 +285,34 @@ pub async fn protected_resource_metadata_by_path( )) } +/// Schemes that a browser executes as script or uses to read local content. The +/// consent page navigates the user's browser to the registered redirect_uri +/// (`window.location` / anchor `href`), so accepting any of these turns dynamic +/// client registration into a stored-XSS / token-exfiltration primitive. +const DISALLOWED_REDIRECT_URI_SCHEMES: &[&str] = + &["javascript", "data", "vbscript", "blob", "file"]; + +/// Validate a dynamically-registered redirect_uri. +/// +/// RFC 7591 dynamic client registration is intentionally unauthenticated for MCP +/// interoperability, so the redirect_uri is the security boundary: it must be an +/// absolute URI (RFC 6749 §3.1.2) and must not use a browser-executable scheme. +fn validate_redirect_uri(uri: &str) -> Result<()> { + let parsed = url::Url::parse(uri).map_err(|_| { + Error::BadRequest(format!( + "Invalid redirect_uri (must be an absolute URI): {uri}" + )) + })?; + // `url` normalizes the scheme to lowercase ASCII. + if DISALLOWED_REDIRECT_URI_SCHEMES.contains(&parsed.scheme()) { + return Err(Error::BadRequest(format!( + "redirect_uri scheme '{}' is not allowed", + parsed.scheme() + ))); + } + Ok(()) +} + /// POST /api/mcp/oauth/server/register - dynamic client registration pub async fn oauth_register( Extension(db): Extension, @@ -296,6 +324,10 @@ pub async fn oauth_register( )); } + for uri in &req.redirect_uris { + validate_redirect_uri(uri)?; + } + let client_id = format!("mcp-client-{}", rd_string(16)); sqlx::query!( @@ -992,3 +1024,46 @@ pub fn gateway_unauthed_service() -> Router { pub fn gateway_authed_service() -> Router { Router::new().route("/approve", post(gateway_oauth_approve)) } + +#[cfg(test)] +mod tests { + use super::validate_redirect_uri; + + #[test] + fn accepts_legitimate_redirect_uris() { + for uri in [ + "https://app.example.com/oauth/callback", + "http://localhost:9876/callback", + "http://127.0.0.1:33418/oauth/callback", + // Native/editor MCP clients use private-use URI schemes (RFC 8252). + "vscode://anthropic.claude/oauth/callback", + "cursor://anysphere.cursor/callback", + ] { + assert!( + validate_redirect_uri(uri).is_ok(), + "expected {uri} to be accepted" + ); + } + } + + #[test] + fn rejects_browser_executable_and_relative_redirect_uris() { + for uri in [ + // GHSA-q9xg-f2v2-695g: stored-XSS / token exfiltration via consent page. + "javascript:fetch('/api/w/admins/tokens/create',{method:'POST'})//", + "JavaScript:alert(document.domain)", + "data:text/html,", + "vbscript:msgbox(1)", + "blob:https://example.com/uuid", + "file:///etc/passwd", + // Not an absolute URI (RFC 6749 §3.1.2). + "/relative/callback", + "not a uri", + ] { + assert!( + validate_redirect_uri(uri).is_err(), + "expected {uri} to be rejected" + ); + } + } +} diff --git a/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page.svelte b/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page.svelte index 1c9753bc9b..3750c56d97 100644 --- a/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page.svelte @@ -19,6 +19,20 @@ let codeChallenge = page.url.searchParams.get('code_challenge') || '' let codeChallengeMethod = page.url.searchParams.get('code_challenge_method') || '' + // Defense-in-depth (GHSA-q9xg-f2v2-695g): the backend validates redirect_uris at + // registration, but this page navigates the browser to `redirectUri`, so reject + // browser-executable schemes here too — this also neutralizes any client rows + // registered before the backend fix. + const DISALLOWED_REDIRECT_SCHEMES = ['javascript:', 'data:', 'vbscript:', 'blob:', 'file:'] + function isSafeRedirectUri(uri: string): boolean { + try { + return !DISALLOWED_REDIRECT_SCHEMES.includes(new URL(uri).protocol.toLowerCase()) + } catch { + return false + } + } + let redirectUriValid = isSafeRedirectUri(redirectUri) + let loading = $state(false) let success = $state(false) let successRedirectUrl = $state('') @@ -51,6 +65,7 @@ } function onDeny() { + if (!redirectUriValid) return // Redirect to client with error const params = new URLSearchParams({ error: 'access_denied', @@ -63,6 +78,7 @@ } async function onApprove() { + if (!redirectUriValid) return if (!workspaceId) { sendUserToast('Please select a workspace', true) return @@ -121,7 +137,9 @@ } -{#if !isGateway && !workspaceId} +{#if !redirectUriValid} +

Error: invalid or unsafe redirect_uri

+{:else if !isGateway && !workspaceId}

Error: missing workspace_id

{:else} From ab11c7747a9076e8121fcea6eafb8e88079ac987 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 17 May 2026 14:36:24 +0000 Subject: [PATCH 143/313] fix: enforce folder ACL on flow run-by-version routes (#9202) * fix: enforce folder ACL on flow run-by-version routes (GHSA-8mv7-hmrg-96xv) Co-Authored-By: Claude Opus 4.7 (1M context) * fix: don't echo resolved flow path in version-route NotAuthorized (cubic P2) Co-Authored-By: Claude Opus 4.7 (1M context) * chore: remove GHSA-8mv7-hmrg-96xv regression test (verified locally pre-removal) Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- ...e32cc151e29b177cc85caa088430b16336373.json | 23 ++++++++ ...adc104e87126c57230c9ed8eb3987e54b53a1.json | 23 ++++++++ backend/windmill-api/src/jobs.rs | 49 ++++------------- backend/windmill-common/src/lib.rs | 53 +++++++++++++++++++ 4 files changed, 108 insertions(+), 40 deletions(-) create mode 100644 backend/.sqlx/query-0476ae2245aa678a50c5fd04cdee32cc151e29b177cc85caa088430b16336373.json create mode 100644 backend/.sqlx/query-e70cbc2a48bfc5c7d2018b9367eadc104e87126c57230c9ed8eb3987e54b53a1.json diff --git a/backend/.sqlx/query-0476ae2245aa678a50c5fd04cdee32cc151e29b177cc85caa088430b16336373.json b/backend/.sqlx/query-0476ae2245aa678a50c5fd04cdee32cc151e29b177cc85caa088430b16336373.json new file mode 100644 index 0000000000..5ae11837c7 --- /dev/null +++ b/backend/.sqlx/query-0476ae2245aa678a50c5fd04cdee32cc151e29b177cc85caa088430b16336373.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT flow_version.path FROM flow_version\n INNER JOIN flow\n ON flow.path = flow_version.path AND\n flow.workspace_id = flow_version.workspace_id\n WHERE flow_version.id = $1 AND flow_version.workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0476ae2245aa678a50c5fd04cdee32cc151e29b177cc85caa088430b16336373" +} diff --git a/backend/.sqlx/query-e70cbc2a48bfc5c7d2018b9367eadc104e87126c57230c9ed8eb3987e54b53a1.json b/backend/.sqlx/query-e70cbc2a48bfc5c7d2018b9367eadc104e87126c57230c9ed8eb3987e54b53a1.json new file mode 100644 index 0000000000..48981ce580 --- /dev/null +++ b/backend/.sqlx/query-e70cbc2a48bfc5c7d2018b9367eadc104e87126c57230c9ed8eb3987e54b53a1.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM flow_version WHERE id = $1 AND workspace_id = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "e70cbc2a48bfc5c7d2018b9367eadc104e87126c57230c9ed8eb3987e54b53a1" +} diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index ff72ddce70..dcec542cb8 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -112,9 +112,9 @@ use windmill_common::{ }; use windmill_common::{ - get_flow_version_info_from_version, get_latest_deployed_hash_for_path, - get_latest_flow_version_info_for_path, get_script_info_for_hash, utils::empty_as_none, - ScriptHashInfo, BASE_URL, + get_flow_path_for_version_authed, get_flow_version_info_from_version, + get_latest_deployed_hash_for_path, get_latest_flow_version_info_for_path, + get_script_info_for_hash, utils::empty_as_none, ScriptHashInfo, BASE_URL, }; use windmill_queue::{ get_result_and_success_by_id_from_flow, job_is_complete, push, PushArgs, PushArgsOwned, @@ -4048,21 +4048,8 @@ pub async fn run_flow_by_version_inner( #[cfg(feature = "enterprise")] check_license_key_valid().await?; - let flow_path = sqlx::query_scalar!( - r#" - SELECT - path - FROM - flow_version - WHERE - id = $1 AND - workspace_id = $2 - "#, - version, - &w_id - ) - .fetch_one(&db) - .await?; + let userdb_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() }; + let flow_path = get_flow_path_for_version_authed(&userdb_authed, &db, version, &w_id).await?; check_scopes(&authed, || format!("jobs:run:flows:{flow_path}"))?; @@ -5550,13 +5537,8 @@ pub async fn run_wait_result_flow_by_version_get( #[cfg(feature = "enterprise")] check_license_key_valid().await?; - let flow_path = sqlx::query_scalar!( - "SELECT path FROM flow_version WHERE id = $1 AND workspace_id = $2", - version, - &w_id - ) - .fetch_one(&db) - .await?; + let userdb_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() }; + let flow_path = get_flow_path_for_version_authed(&userdb_authed, &db, version, &w_id).await?; check_scopes(&authed, || format!("jobs:run:flows:{flow_path}"))?; @@ -5606,21 +5588,8 @@ pub async fn run_wait_result_flow_by_version( #[cfg(feature = "enterprise")] check_license_key_valid().await?; - let flow_path = sqlx::query_scalar!( - r#" - SELECT - path - FROM - flow_version - WHERE - id = $1 AND - workspace_id = $2 - "#, - version, - &w_id - ) - .fetch_one(&db) - .await?; + let userdb_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() }; + let flow_path = get_flow_path_for_version_authed(&userdb_authed, &db, version, &w_id).await?; check_scopes(&authed, || format!("jobs:run:flows:{flow_path}"))?; diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 39884e3d35..637b32860b 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -1543,6 +1543,59 @@ pub fn get_flow_version_info_from_version< } } +/// Resolve a `flow_version.id` to its flow path while enforcing the caller's +/// folder-level ACL. The `flow_version` table has no row-level security, so the +/// authorization gate is an RLS-filtered lookup against the `flow` table through +/// `user_db`. Mirrors the "exists but not authorized -> NotAuthorized" semantics +/// of [`get_latest_flow_version_id_for_path`] so version-keyed run routes are +/// gated identically to their path-keyed siblings. +pub async fn get_flow_path_for_version_authed( + db_authed: &UserDbWithAuthed<'_, AuthedRef<'_>>, + db: &DB, + version: i64, + w_id: &str, +) -> error::Result { + let mut conn = db_authed.acquire().await?; + let authed_path = sqlx::query_scalar!( + "SELECT flow_version.path FROM flow_version + INNER JOIN flow + ON flow.path = flow_version.path AND + flow.workspace_id = flow_version.workspace_id + WHERE flow_version.id = $1 AND flow_version.workspace_id = $2", + version, + w_id, + ) + .fetch_optional(&mut *conn) + .await?; + + if let Some(path) = authed_path { + return Ok(path); + } + + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM flow_version WHERE id = $1 AND workspace_id = $2)", + version, + w_id, + ) + .fetch_one(db) + .await? + .unwrap_or(false); + + if exists { + // Unlike the path-keyed sibling (where the caller already supplied the + // path), here the caller only supplied an opaque version id. Echoing + // back the resolved path would disclose an id->path mapping for a flow + // they cannot access, so the message is intentionally generic. + return Err(Error::NotAuthorized( + "You are not authorized to run this flow version".to_string(), + )); + } + + Err(Error::NotFound(format!( + "flow_version not found at id {version}" + ))) +} + pub async fn get_latest_flow_version_info_for_path<'e>( db_authed: Option>>, db: &DB, From 24eedef918376d9d401335b6fada577916f8cc0e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 17 May 2026 14:36:51 +0000 Subject: [PATCH 144/313] fix: constrain unauthenticated get_public_resource to app_theme resources (#9203) * fix: constrain unauthenticated get_public_resource to app_theme resources Co-Authored-By: Claude Opus 4.7 (1M context) * test: remove get_public_resource regression test Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- ...0216da2c1b8a4ce7ee1482ff5f347c3de145e.json | 23 +++++++++++++++++++ backend/windmill-api/src/apps.rs | 6 ++++- 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 backend/.sqlx/query-b845cbe97b10c6194fd89dbf2510216da2c1b8a4ce7ee1482ff5f347c3de145e.json diff --git a/backend/.sqlx/query-b845cbe97b10c6194fd89dbf2510216da2c1b8a4ce7ee1482ff5f347c3de145e.json b/backend/.sqlx/query-b845cbe97b10c6194fd89dbf2510216da2c1b8a4ce7ee1482ff5f347c3de145e.json new file mode 100644 index 0000000000..2101f60f5f --- /dev/null +++ b/backend/.sqlx/query-b845cbe97b10c6194fd89dbf2510216da2c1b8a4ce7ee1482ff5f347c3de145e.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value from resource WHERE path = $1 AND workspace_id = $2 AND resource_type = 'app_theme'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "b845cbe97b10c6194fd89dbf2510216da2c1b8a4ce7ee1482ff5f347c3de145e" +} diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index ad455ac46a..9c64bb0da4 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -899,9 +899,13 @@ async fn get_public_resource( ) -> JsonResult> { let path = path.to_path(); + // This endpoint is unauthenticated (anonymous public apps must fetch their + // theme and form schemas). Both branches MUST stay tightly constrained to + // the non-sensitive resource types they serve, otherwise an unauthenticated + // caller could read the raw value of any resource at the given path. let res = if path.starts_with("f/app_themes/") { sqlx::query_scalar!( - "SELECT value from resource WHERE path = $1 AND workspace_id = $2", + "SELECT value from resource WHERE path = $1 AND workspace_id = $2 AND resource_type = 'app_theme'", path.to_owned(), &w_id ) From e1df6b45e9fe879125481a66c82bcd51753b2d62 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 17 May 2026 14:41:10 +0000 Subject: [PATCH 145/313] chore: remove alpha/beta warnings from tested frontend features (#9196) --- .../components/AutoscalingConfigEditor.svelte | 4 -- frontend/src/lib/components/Dev.svelte | 2 +- .../src/lib/components/ScriptBuilder.svelte | 48 ------------------- .../src/lib/components/ScriptEditor.svelte | 46 +----------------- .../components/flows/CreateActionsFlow.svelte | 7 --- .../flows/content/FlowModuleComponent.svelte | 48 ++----------------- .../SecretBackendConfig.svelte | 18 ++----- .../raw_apps/RawAppInlineScriptEditor.svelte | 42 ++-------------- 8 files changed, 15 insertions(+), 200 deletions(-) diff --git a/frontend/src/lib/components/AutoscalingConfigEditor.svelte b/frontend/src/lib/components/AutoscalingConfigEditor.svelte index 247c6a63c8..3ebbb5d41c 100644 --- a/frontend/src/lib/components/AutoscalingConfigEditor.svelte +++ b/frontend/src/lib/components/AutoscalingConfigEditor.svelte @@ -14,7 +14,6 @@ import { ConfigService } from '$lib/gen' import Select from './select/Select.svelte' import ScriptPicker from './ScriptPicker.svelte' - import Badge from './common/badge/Badge.svelte' import { sendUserToast } from '$lib/toast' interface Props { @@ -66,9 +65,6 @@ description="Autoscaling automatically adjusts the number of workers based on your workload demands." {eeOnly} > - {#snippet labelExtra()} - Beta - {/snippet} {#snippet header()}
{:else if currentScript?.language == 'python3'} diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index c25f121ce7..4df73ce7a2 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -96,24 +96,6 @@ import { buildForkEditUrl } from '$lib/utils/editInFork' import OnBehalfOfSelector, { type OnBehalfOfChoice } from './OnBehalfOfSelector.svelte' import WacExportDrawer from './scripts/WacExportDrawer.svelte' - import Modal from './common/modal/Modal.svelte' - - const WAC_ALPHA_ACK_KEY = 'windmill_wac_alpha_ack' - let wacAlphaModalOpen = $state(false) - - function showWacAlphaModalIfNeeded() { - if ( - typeof sessionStorage !== 'undefined' && - sessionStorage.getItem(WAC_ALPHA_ACK_KEY) !== 'true' - ) { - wacAlphaModalOpen = true - } - } - - function acknowledgeWacAlpha() { - sessionStorage.setItem(WAC_ALPHA_ACK_KEY, 'true') - wacAlphaModalOpen = false - } let { script = $bindable(), @@ -378,7 +360,6 @@ language: 'python3' } } - showWacAlphaModalIfNeeded() } else if (template === 'wac_typescript') { script.modules = { 'helper.ts': { @@ -386,7 +367,6 @@ language: 'bun' } } - showWacAlphaModalIfNeeded() } initContent(script.language, script.kind, template) } @@ -1268,9 +1248,6 @@ } as ButtonType.Icon} > {label} - {#if lang === 'rlang'} - BETA - {/if} {#snippet text()} {label} is only available with an enterprise license @@ -1318,7 +1295,6 @@ } } initContent('bun', script.kind, template) - showWacAlphaModalIfNeeded() }} > WAC TypeScript @@ -1343,7 +1319,6 @@ } } initContent('python3', script.kind, template) - showWacAlphaModalIfNeeded() }} > WAC Python @@ -2085,26 +2060,3 @@ {/if} - - -
-

- Workflow-as-Code is in alpha — use in production at your own risk. It is an - alternative to the Flow editor for advanced users. Feedback welcome on - GitHub - or - Discord. -

-
- -
-
-
diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 646cbc0477..d00f5cc9f1 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -11,13 +11,7 @@ type ScriptModule } from '$lib/gen' import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores' - import { - copyToClipboard, - emptySchema, - getLocalSetting, - sendUserToast, - storeLocalSetting - } from '$lib/utils' + import { copyToClipboard, emptySchema, sendUserToast } from '$lib/utils' import Editor from './Editor.svelte' import { inferArgs, inferAssets, inferAnsibleExecutionMode } from '$lib/infer' import { isWorkflowAsCode } from '$lib/components/graph/wacToFlow' @@ -41,7 +35,6 @@ import Popover from './meltComponents/Popover.svelte' import DiffEditor from './DiffEditor.svelte' import { - AlertTriangle, Bug, Copy, CornerDownLeft, @@ -582,8 +575,6 @@ let ansibleGitSshIdentity = $state([]) // Debug mode state - const DEBUG_BETA_WARNING_KEY = 'debug_beta_warning_confirmed' - let showDebugBetaWarning = $state(false) let debugMode = $state(false) let debugBreakpoints = new SvelteSet() let breakpointDecorations: string[] = $state([]) @@ -1028,21 +1019,10 @@ clearAllBreakpoints() updateCurrentLineDecoration(undefined) } else { - // Entering debug mode - check if beta warning was confirmed - if (getLocalSetting(DEBUG_BETA_WARNING_KEY) !== 'true') { - showDebugBetaWarning = true - } else { - debugMode = true - } + debugMode = true } } - function confirmDebugBetaWarning(): void { - storeLocalSetting(DEBUG_BETA_WARNING_KEY, 'true') - showDebugBetaWarning = false - debugMode = true - } - // Subscribe to debug state changes for current line highlighting $effect(() => { const currentLine = $debugState.currentLine @@ -1389,28 +1369,6 @@
- -
-
-
- -
-
-
-

The Debug feature is currently in beta. You may encounter unexpected - behavior or limitations.

-

By continuing, you acknowledge that this feature is experimental.

-
-
- {#snippet actions()} - - {/snippet} -
-
{#if args} diff --git a/frontend/src/lib/components/flows/CreateActionsFlow.svelte b/frontend/src/lib/components/flows/CreateActionsFlow.svelte index 73f8cfedba..fca04512de 100644 --- a/frontend/src/lib/components/flows/CreateActionsFlow.svelte +++ b/frontend/src/lib/components/flows/CreateActionsFlow.svelte @@ -142,13 +142,6 @@ onmouseenter={() => (wacHovered = true)} onmouseleave={() => (wacHovered = false)} > - -
- Alpha -
-
() let breakpointDecorations: string[] = $state([]) @@ -621,25 +618,12 @@ clearAllBreakpoints() updateCurrentLineDecoration(undefined) } else { - // Entering debug mode - check if beta warning was confirmed - if (getLocalSetting(DEBUG_BETA_WARNING_KEY) !== 'true') { - showDebugBetaWarning = true - } else { - debugMode = true - // Switch to test tab when entering debug mode - selected = 'test' - } + debugMode = true + // Switch to test tab when entering debug mode + selected = 'test' } } - function confirmDebugBetaWarning(): void { - storeLocalSetting(DEBUG_BETA_WARNING_KEY, 'true') - showDebugBetaWarning = false - debugMode = true - // Switch to test tab when entering debug mode - selected = 'test' - } - // Subscribe to debug state changes for current line highlighting $effect(() => { const currentLine = $debugState.currentLine @@ -1540,25 +1524,3 @@ {:else} Incorrect flow module type {/if} - - -
-
-
- -
-
-
-

The Debug feature is currently in beta. You may encounter unexpected - behavior or limitations.

-

By continuing, you acknowledge that this feature is experimental.

-
-
- {#snippet actions()} - - {/snippet} -
diff --git a/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte b/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte index a313a0fbc4..b14dec9846 100644 --- a/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte +++ b/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte @@ -372,7 +372,7 @@ />
-

HashiCorp Vault Configuration Beta

+

HashiCorp Vault Configuration

Store secrets in an external HashiCorp Vault instance.

@@ -766,12 +761,7 @@ vault write auth/{jwtMount}/role/{$values['secret_backend']?.jwt_role || 'windmi
-

AWS Secrets Manager Configuration Beta

+

AWS Secrets Manager Configuration

Store secrets in AWS Secrets Manager.

diff --git a/frontend/src/lib/components/raw_apps/RawAppInlineScriptEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppInlineScriptEditor.svelte index dff2ac0dc9..506bfdae00 100644 --- a/frontend/src/lib/components/raw_apps/RawAppInlineScriptEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppInlineScriptEditor.svelte @@ -5,12 +5,11 @@ import Button from '$lib/components/common/button/Button.svelte' import type { Preview, ScriptLang } from '$lib/gen' import { createEventDispatcher, onDestroy, onMount, untrack } from 'svelte' - import { AlertTriangle, Trash2, Bug, Terminal } from 'lucide-svelte' - import Modal from '$lib/components/common/modal/Modal.svelte' + import { Trash2, Bug, Terminal } from 'lucide-svelte' import { inferArgs, inferAssets } from '$lib/infer' import type { Schema } from '$lib/common' import Editor from '$lib/components/Editor.svelte' - import { emptySchema, getLocalSetting, sendUserToast, storeLocalSetting } from '$lib/utils' + import { emptySchema, sendUserToast } from '$lib/utils' import { scriptLangToEditorLang } from '$lib/scripts' import DiffEditor from '$lib/components/DiffEditor.svelte' @@ -156,8 +155,6 @@ }) // Debug mode state - const DEBUG_BETA_WARNING_KEY = 'debug_beta_warning_confirmed' - let showDebugBetaWarning = $state(false) let debugMode = $state(false) let debugBreakpoints = new SvelteSet() let breakpointDecorations: string[] = $state([]) @@ -396,21 +393,10 @@ clearAllBreakpoints() updateCurrentLineDecoration(undefined) } else { - // Entering debug mode - check if beta warning was confirmed - if (getLocalSetting(DEBUG_BETA_WARNING_KEY) !== 'true') { - showDebugBetaWarning = true - } else { - debugMode = true - } + debugMode = true } } - function confirmDebugBetaWarning(): void { - storeLocalSetting(DEBUG_BETA_WARNING_KEY, 'true') - showDebugBetaWarning = false - debugMode = true - } - // Subscribe to debug state changes for current line highlighting $effect(() => { const currentLine = $debugState.currentLine @@ -801,25 +787,3 @@
{/if} - - -
-
-
- -
-
-
-

The Debug feature is currently in beta. You may encounter unexpected - behavior or limitations.

-

By continuing, you acknowledge that this feature is experimental.

-
-
- {#snippet actions()} - - {/snippet} -
From 9dbce4a8c4ea6c62a7bc8be893a3384539f0d556 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 17 May 2026 14:43:35 +0000 Subject: [PATCH 146/313] ci: disable PDB generation in Windows backend tests (#9201) The dev profile's split-debuginfo = "unpacked" is coerced to "packed" on windows-msvc, so each test-binary link spawns the shared mspdbsrv.exe PDB type server. With 12 parallel link jobs this races the type-server cap (LNK1318 "LIMIT (12)") and exhausts the runner disk (LNK1180), recurringly failing the Windows release CI. CI needs no debug info, so disable PDB generation for the dev/test profiles in this job only. Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/backend-test-windows.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/backend-test-windows.yml b/.github/workflows/backend-test-windows.yml index 2b76eda11f..96b2719737 100644 --- a/.github/workflows/backend-test-windows.yml +++ b/.github/workflows/backend-test-windows.yml @@ -145,6 +145,14 @@ jobs: RUST_LOG_STYLE: never CARGO_NET_GIT_FETCH_WITH_CLI: true CARGO_BUILD_JOBS: 12 + # backend/Cargo.toml sets split-debuginfo = "unpacked", which on + # windows-msvc is coerced to "packed": every test-binary link spawns + # the mspdbsrv.exe PDB type server and writes a large .pdb. With 12 + # parallel link jobs this races the type-server cap (LNK1318 "LIMIT + # (12)") and exhausts the runner disk (LNK1180). CI needs no debug + # info, so disable PDB generation for the dev/test profiles here. + CARGO_PROFILE_DEV_SPLIT_DEBUGINFO: "off" + CARGO_PROFILE_TEST_SPLIT_DEBUGINFO: "off" # Tests' poll-time stack frames (deep nested async fn chains in # debug builds) reach ~1.8MB. 4MB gives ~2x headroom against flaky # overflows under parallel-test contention. From 664edcdfb746f6c8513e2b487383b5d9ab9f5434 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 17 May 2026 14:57:24 +0000 Subject: [PATCH 147/313] fix: enforce jobs:run scope on job preview and inline endpoints (#9198) * fix: enforce jobs:run scope on job preview and inline endpoints Preview/inline endpoints (run/preview, run/preview_bundle, run/preview_flow, run/dynamic_select inline) execute arbitrary request-supplied code but only checked folder/namespace read access, which is a no-op when path is null. A token scoped to a specific script/flow could escape its scope and run any code. Add a jobs:run scope check, matching other arbitrary-execution endpoints. Advisory GHSA-vxc5-w28p-m9xw. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: scope-check dynamic_select flow branch and inline preview Address CI review: the dynamic_select Deployed{Flow} branch ran a deployed flow's dynamic-select code without any scope check (only the Script branch delegated to a scope-checked handler), and run_inline_preview_script executed request-supplied code with no in-handler scope check. Add jobs:run:flows:{path} to the flow branch and jobs:run to inline preview; correct the misleading comment. Expand regression tests (preview_flow case, assert success for the broad-token case). Advisory GHSA-vxc5-w28p-m9xw. Co-Authored-By: Claude Opus 4.7 (1M context) * test: remove preview scope enforcement test after local validation The regression test passed locally (3/3) and validated the fix end-to-end; removed from the PR per maintainer preference. Advisory GHSA-vxc5-w28p-m9xw. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- backend/windmill-api/src/jobs.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index dcec542cb8..231b62e234 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -5635,6 +5635,11 @@ async fn run_preview_script( "Operators cannot run preview jobs for security reasons".to_string(), )); } + // Preview runs arbitrary, request-supplied code. require_path_read_access_for_preview + // only checks folder/namespace *read* access (and is a no-op when path is null), so a + // token scoped to a specific script/flow could otherwise escape its scope and run any + // code. Require the broad jobs:run scope, like other arbitrary-execution endpoints. + check_scopes(&authed, || format!("jobs:run"))?; require_path_read_access_for_preview(&authed, &preview.path)?; let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(preview.tag.clone()); @@ -5716,6 +5721,9 @@ async fn run_inline_preview_script( Path(w_id): Path, Json(preview): Json, ) -> error::Result { + // Same arbitrary-code class as run_preview_script: a narrowly-scoped token + // must not be able to run request-supplied code through inline preview. + check_scopes(&authed, || format!("jobs:run"))?; if let Some(job_id) = job_id { register_potential_assets_on_inline_execution(job_id, &w_id, &preview); } @@ -5965,6 +5973,9 @@ async fn run_bundle_preview_script( "Operators cannot run preview jobs for security reasons".to_string(), )); } + // Bundle preview runs arbitrary, request-supplied code; require the broad jobs:run + // scope so a narrowly-scoped token cannot escape its scope. See run_preview_script. + check_scopes(&authed, || format!("jobs:run"))?; let mut job_id = None; let mut tx = None; @@ -6632,6 +6643,9 @@ async fn run_preview_flow_job( "Operators cannot run preview jobs for security reasons".to_string(), )); } + // Flow preview runs an arbitrary, request-supplied flow definition; require the broad + // jobs:run scope so a narrowly-scoped token cannot escape its scope. See run_preview_script. + check_scopes(&authed, || format!("jobs:run"))?; require_path_read_access_for_preview(&authed, &raw_flow.path)?; let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(raw_flow.tag.clone()); @@ -6779,6 +6793,11 @@ async fn run_dynamic_select( return Ok((StatusCode::CREATED, uuid.to_string()).into_response()); } RunnableKind::Flow => { + // Runs the deployed flow's dynamic-select code. Enforce the same + // path-scoped check the script branch gets via + // push_script_job_by_path_into_queue, so a token not scoped to this + // flow cannot trigger its code through dynamic select. + check_scopes(&authed, || format!("jobs:run:flows:{path}"))?; let mut conn = user_db.clone().begin(&authed).await?; let dynamic_input_res = match DYNAMIC_INPUT_CACHE.get(&format!("{}:{}", w_id, path)) @@ -6826,6 +6845,11 @@ async fn run_dynamic_select( } }, DynamicSelectRunnableRef::Inline { code, lang: language } => { + // Inline dynamic select runs arbitrary, request-supplied code; require the broad + // jobs:run scope so a narrowly-scoped token cannot escape its scope. The Deployed + // branches are path-scoped instead (scripts via push_script_job_by_path_into_queue, + // flows via the check_scopes above). + check_scopes(&authed, || format!("jobs:run"))?; dynamic_input = DynamicInput { x_windmill_dyn_select_code: code, x_windmill_dyn_select_lang: language.unwrap_or_default(), From bd05bcadde06b65fc4b732f576d89aae908b5a3f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 18 May 2026 09:01:05 +0000 Subject: [PATCH 148/313] fix: validate entrypoint override to prevent worker code injection (GHSA-wxjq-w5pj-jqhx) (#9204) Co-authored-by: Claude Opus 4.7 (1M context) --- backend/windmill-api/src/jobs.rs | 18 ++++++- backend/windmill-types/src/jobs.rs | 75 +++++++++++++++++++++++++++ backend/windmill-worker/src/worker.rs | 15 ++++++ 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 231b62e234..4d61df606e 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -33,7 +33,8 @@ use windmill_common::db::UserDbWithAuthed; use windmill_common::error::JsonResult; use windmill_common::flow_status::{JobResult, RestartedFrom}; use windmill_common::jobs::{ - format_completed_job_result, format_result, DynamicInput, ENTRYPOINT_OVERRIDE, + format_completed_job_result, format_result, is_valid_entrypoint_name, DynamicInput, + ENTRYPOINT_OVERRIDE, }; #[cfg(feature = "run_inline")] use windmill_common::jobs::{ @@ -4514,6 +4515,13 @@ pub async fn run_workflow_as_code( check_tag_available_for_workspace(&db, &w_id, &run_query.tag, &authed).await?; check_scopes(&authed, || format!("jobs:run"))?; + if !is_valid_entrypoint_name(&entrypoint) { + return Err(error::Error::BadRequest(format!( + "Invalid entrypoint {entrypoint:?}: must match ^[A-Za-z_][A-Za-z0-9_]*$ \ + (letters, digits and underscores, not starting with a digit)" + ))); + } + let mut i = 1; if *CLOUD_HOSTED { @@ -6769,6 +6777,14 @@ async fn run_dynamic_select( match request.runnable_ref { DynamicSelectRunnableRef::Deployed { path, runnable_kind } => match runnable_kind { RunnableKind::Script => { + if !is_valid_entrypoint_name(&request.entrypoint_function) { + return Err(error::Error::BadRequest(format!( + "Invalid entrypoint_function {:?}: must match \ + ^[A-Za-z_][A-Za-z0-9_]*$ (letters, digits and underscores, \ + not starting with a digit)", + request.entrypoint_function + ))); + } let mut script_args = request.args.unwrap_or_default(); script_args.insert( "_ENTRYPOINT_OVERRIDE".to_string(), diff --git a/backend/windmill-types/src/jobs.rs b/backend/windmill-types/src/jobs.rs index 03e38d9db3..308a326b64 100644 --- a/backend/windmill-types/src/jobs.rs +++ b/backend/windmill-types/src/jobs.rs @@ -556,6 +556,32 @@ pub struct OnBehalfOf { } pub const ENTRYPOINT_OVERRIDE: &str = "_ENTRYPOINT_OVERRIDE"; + +/// The entrypoint override (`_ENTRYPOINT_OVERRIDE` job arg -> +/// `v2_job.script_entrypoint_override`) is interpolated verbatim into +/// generated worker wrappers in a code position (e.g. the NativeTS +/// `import(...).then(m => m.(...))` glue, the bun `Main.(...)` +/// call, the deno `import { } from "./main.ts"` line, the PHP +/// `(...)` call and the Python `inner_script.(**args)` call). +/// A caller only needs `jobs:run` to set it, so it MUST be restricted to a +/// conventional identifier or an attacker who can merely run a deployed +/// script could break out of the call expression into arbitrary +/// worker-process code. This ASCII subset is a valid function name in every +/// language Windmill wraps this way (JS/TS, Python, PHP). +pub fn is_valid_entrypoint_name(name: &str) -> bool { + if name.is_empty() || name.len() > 255 { + return false; + } + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first.is_ascii_alphabetic() || first == '_') { + return false; + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + pub const LARGE_LOG_THRESHOLD_SIZE: usize = 9000; pub const EMAIL_ERROR_HANDLER_USER_EMAIL: &str = "email_error_handler@windmill.dev"; @@ -563,3 +589,52 @@ pub const EMAIL_ERROR_HANDLER_USER_EMAIL: &str = "email_error_handler@windmill.d pub fn generate_dynamic_input_key(workspace_id: &str, path: &str) -> String { format!("{workspace_id}:{path}") } + +#[cfg(test)] +mod tests { + use super::is_valid_entrypoint_name; + + #[test] + fn valid_entrypoint_names_are_accepted() { + for name in [ + "main", + "preprocessor", + "my_helper", + "_private", + "fn2", + "a", + "MixedCase", + ] { + assert!(is_valid_entrypoint_name(name), "expected {name:?} valid"); + } + } + + #[test] + fn malicious_entrypoint_names_are_rejected() { + // Regression for GHSA-wxjq-w5pj-jqhx: the entrypoint override is + // interpolated verbatim into a code position of generated worker + // wrappers (e.g. bun `Main.(...)`, nativets + // `m.(...)`, python `inner_script.(**args)`). Any value + // that is not a strict identifier could break out of the call + // expression into attacker-controlled worker code. + for name in [ + "main(); globalThis.x = 1; //", // breaks out of `Main.(...)` + "x); require('child_process').execSync('id'); (", + "1main", // starts with a digit + "my-fn", // hyphen + "my fn", // space + "my.fn", // member access + "$fn", // dollar + "fn\nother", // newline + "fn;other", + "", + ] { + assert!( + !is_valid_entrypoint_name(name), + "expected {name:?} to be rejected" + ); + } + // Over-long names are rejected. + assert!(!is_valid_entrypoint_name(&"a".repeat(256))); + } +} diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index f9305961eb..7ff9823072 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -4436,6 +4436,21 @@ pub async fn run_language_executor( modules: &Option>, run_inline: bool, ) -> error::Result> { + // Defense-in-depth (GHSA-wxjq-w5pj-jqhx): the entrypoint override is + // interpolated verbatim into a code position of the generated language + // wrappers below. It originates from the `_ENTRYPOINT_OVERRIDE` job arg, + // which any caller with `jobs:run` can set on a deployed script, so reject + // anything that is not a strict identifier before it reaches any wrapper. + if let Some(entrypoint) = job.script_entrypoint_override.as_deref() { + if !windmill_common::jobs::is_valid_entrypoint_name(entrypoint) { + return Err(Error::BadRequest(format!( + "Invalid entrypoint override {entrypoint:?}: must match \ + ^[A-Za-z_][A-Za-z0-9_]*$ (letters, digits and underscores, \ + not starting with a digit)" + ))); + } + } + // Expand WM_INTERNAL_DB markers into real SQL before dispatching let expanded_code: String; let mut language = language; From 4e91f83b8f7b8d92946980a7af6468615afc9f3c Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 18 May 2026 09:09:50 +0000 Subject: [PATCH 149/313] chore(main): release 1.703.3 (#9200) * chore(main): release 1.703.3 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 11 ++ backend/Cargo.lock | 164 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 ++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 133 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62117ba484..e9189f9e64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.703.3](https://github.com/windmill-labs/windmill/compare/v1.703.2...v1.703.3) (2026-05-18) + + +### Bug Fixes + +* constrain unauthenticated get_public_resource to app_theme resources ([#9203](https://github.com/windmill-labs/windmill/issues/9203)) ([24eedef](https://github.com/windmill-labs/windmill/commit/24eedef918376d9d401335b6fada577916f8cc0e)) +* enforce folder ACL on flow run-by-version routes ([#9202](https://github.com/windmill-labs/windmill/issues/9202)) ([ab11c77](https://github.com/windmill-labs/windmill/commit/ab11c7747a9076e8121fcea6eafb8e88079ac987)) +* enforce jobs:run scope on job preview and inline endpoints ([#9198](https://github.com/windmill-labs/windmill/issues/9198)) ([664edcd](https://github.com/windmill-labs/windmill/commit/664edcdfb746f6c8513e2b487383b5d9ab9f5434)) +* **mcp:** validate oauth dynamic client registration redirect_uris ([#9197](https://github.com/windmill-labs/windmill/issues/9197)) ([8bc2295](https://github.com/windmill-labs/windmill/commit/8bc2295b94df159a7c8630cdbe02953b8b7c13a1)) +* validate entrypoint override to prevent worker code injection (GHSA-wxjq-w5pj-jqhx) ([#9204](https://github.com/windmill-labs/windmill/issues/9204)) ([bd05bca](https://github.com/windmill-labs/windmill/commit/bd05bcadde06b65fc4b732f576d89aae908b5a3f)) + ## [1.703.2](https://github.com/windmill-labs/windmill/compare/v1.703.1...v1.703.2) (2026-05-17) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 5791ce7acf..6e1197f88c 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2782,9 +2782,9 @@ dependencies = [ [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -8942,9 +8942,9 @@ dependencies = [ [[package]] name = "quick_cache" -version = "0.6.21" +version = "0.6.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a70b1b8b47e31d0498ecbc3c5470bb931399a8bfed1fd79d1717a61ce7f96e3" +checksum = "d1c821816e9b928e20e92ed59bb3ac4aab321d16ca2316871c9fe7ca739cd477" dependencies = [ "ahash 0.8.12", "equivalent", @@ -13788,7 +13788,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-nats", @@ -13869,7 +13869,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.703.2" +version = "1.703.3" dependencies = [ "async-trait", "aws-config", @@ -13899,7 +13899,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.703.2" +version = "1.703.3" dependencies = [ "axum 0.8.9", "chrono", @@ -13912,7 +13912,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "argon2", @@ -14055,7 +14055,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.703.2" +version = "1.703.3" dependencies = [ "axum 0.8.9", "chrono", @@ -14078,7 +14078,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.703.2" +version = "1.703.3" dependencies = [ "axum 0.8.9", "chrono", @@ -14091,7 +14091,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "axum 0.8.9", @@ -14117,7 +14117,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.703.2" +version = "1.703.3" dependencies = [ "reqwest 0.12.28", "serde", @@ -14127,7 +14127,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.703.2" +version = "1.703.3" dependencies = [ "axum 0.8.9", "chrono", @@ -14144,7 +14144,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.703.2" +version = "1.703.3" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14166,7 +14166,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "axum 0.8.9", @@ -14189,7 +14189,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.703.2" +version = "1.703.3" dependencies = [ "axum 0.8.9", "chrono", @@ -14205,7 +14205,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.703.2" +version = "1.703.3" dependencies = [ "axum 0.8.9", "chrono", @@ -14226,7 +14226,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.703.2" +version = "1.703.3" dependencies = [ "axum 0.8.9", "chrono", @@ -14247,7 +14247,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.703.2" +version = "1.703.3" dependencies = [ "axum 0.8.9", "chrono", @@ -14261,7 +14261,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-nats", @@ -14293,7 +14293,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "axum 0.8.9", @@ -14318,7 +14318,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.703.2" +version = "1.703.3" dependencies = [ "axum 0.8.9", "flate2", @@ -14336,7 +14336,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "axum 0.8.9", @@ -14358,7 +14358,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.703.2" +version = "1.703.3" dependencies = [ "axum 0.8.9", "chrono", @@ -14378,7 +14378,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.703.2" +version = "1.703.3" dependencies = [ "axum 0.8.9", "chrono", @@ -14408,7 +14408,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "axum 0.8.9", @@ -14436,7 +14436,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.703.2" +version = "1.703.3" dependencies = [ "lazy_static", "serde", @@ -14448,7 +14448,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.703.2" +version = "1.703.3" dependencies = [ "argon2", "axum 0.8.9", @@ -14473,7 +14473,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.703.2" +version = "1.703.3" dependencies = [ "axum 0.8.9", "chrono", @@ -14487,7 +14487,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.703.2" +version = "1.703.3" dependencies = [ "axum 0.8.9", "chrono", @@ -14520,7 +14520,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.703.2" +version = "1.703.3" dependencies = [ "chrono", "lazy_static", @@ -14534,7 +14534,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "axum 0.8.9", @@ -14553,7 +14553,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.703.2" +version = "1.703.3" dependencies = [ "aes-gcm", "aho-corasick", @@ -14654,7 +14654,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.703.2" +version = "1.703.3" dependencies = [ "chrono", "itertools 0.14.0", @@ -14673,7 +14673,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.703.2" +version = "1.703.3" dependencies = [ "regex", "serde", @@ -14688,7 +14688,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14712,7 +14712,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "futures", @@ -14729,7 +14729,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.703.2" +version = "1.703.3" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14745,7 +14745,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-trait", @@ -14766,7 +14766,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-trait", @@ -14797,7 +14797,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "arc-swap", @@ -14822,7 +14822,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-stream", @@ -14856,7 +14856,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "futures", @@ -14874,7 +14874,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.703.2" +version = "1.703.3" dependencies = [ "convert_case 0.6.0", "serde", @@ -14883,7 +14883,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "lazy_static", @@ -14895,7 +14895,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "serde_json", @@ -14907,7 +14907,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "gosyn", @@ -14919,7 +14919,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "lazy_static", @@ -14931,7 +14931,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "serde_json", @@ -14943,7 +14943,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "nu-parser", @@ -14954,7 +14954,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14965,7 +14965,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14977,7 +14977,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "rustpython-ast", @@ -14988,7 +14988,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-recursion", @@ -15010,7 +15010,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "serde_json", @@ -15022,7 +15022,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "lazy_static", @@ -15036,7 +15036,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15053,7 +15053,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "lazy_static", @@ -15066,7 +15066,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "serde", @@ -15078,7 +15078,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "lazy_static", @@ -15096,7 +15096,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15112,7 +15112,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "rustpython-ast", @@ -15128,7 +15128,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "serde", @@ -15139,7 +15139,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-recursion", @@ -15176,7 +15176,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "const_format", @@ -15214,7 +15214,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.703.2" +version = "1.703.3" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15225,7 +15225,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-recursion", @@ -15255,7 +15255,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-trait", @@ -15279,7 +15279,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-trait", @@ -15312,7 +15312,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-trait", @@ -15345,7 +15345,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-trait", @@ -15365,7 +15365,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-trait", @@ -15399,7 +15399,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-trait", @@ -15435,7 +15435,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-trait", @@ -15458,7 +15458,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-trait", @@ -15482,7 +15482,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-nats", @@ -15506,7 +15506,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-trait", @@ -15541,7 +15541,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-trait", @@ -15569,7 +15569,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-trait", @@ -15592,7 +15592,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -15611,7 +15611,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-once-cell", @@ -15720,7 +15720,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.703.2" +version = "1.703.3" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d25b5e499e..74e8c90b34 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.703.2" +version = "1.703.3" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.703.2" +version = "1.703.3" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 51b4711603..3771fbb32a 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.703.2" +version = "1.703.3" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.703.2" +version = "1.703.3" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.703.2" +version = "1.703.3" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.703.2" +version = "1.703.3" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 0d3ee9616f..1ab8d0813f 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.703.2" +version = "1.703.3" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 3e229abd35..da0f9d32dd 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.703.2 + version: 1.703.3 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 91586f7338..f9e99283bf 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.703.2"; +export const VERSION = "v1.703.3"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index 243b7cc5b4..c4ef7d40a9 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -79,7 +79,7 @@ export { token, }; -export const VERSION = "1.703.2"; +export const VERSION = "1.703.3"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2a177b6bed..13a1efdf8b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.703.2", + "version": "1.703.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.703.2", + "version": "1.703.3", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 2af859027f..d81fd2d378 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.703.2", + "version": "1.703.3", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index a8888aecd3..c547a10f28 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.703.2" +wmill = ">=1.703.3" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 95100b7dd4..9a8b92f461 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.703.2 + version: 1.703.3 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index e774824d59..e19e637e0a 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.703.2' + ModuleVersion = '1.703.3' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 559813a186..541fc0f082 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.703.2" +version = "1.703.3" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 45a77b379b..10e44ae8ec 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.703.2", + "version": "1.703.3", "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 bc4d704f5f..f93a7270e9 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.703.2", + "version": "1.703.3", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 1001cd0cf3..10978dd13d 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.703.2 +1.703.3 From bd32c5f951edc38ac8110a8c676b2be66797d064 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Mon, 18 May 2026 12:24:16 +0200 Subject: [PATCH 150/313] refactor: move openai-compatible proxy building (#9133) * refactor: introduce ai proxy request types * refactor: move openai-compatible proxy building --- backend/Cargo.lock | 1 + backend/windmill-ai/Cargo.toml | 1 + backend/windmill-ai/src/lib.rs | 1 + backend/windmill-ai/src/providers/mod.rs | 20 +- backend/windmill-ai/src/providers/openai.rs | 5 + .../windmill-ai/src/providers/openrouter.rs | 5 + backend/windmill-ai/src/providers/other.rs | 5 + backend/windmill-ai/src/proxy.rs | 224 ++++++++++++++++++ backend/windmill-ai/src/query_builder.rs | 9 + backend/windmill-api/src/ai.rs | 196 ++++++++++++--- docs/windmill-ai-refactor-plan.md | 91 ++++--- 11 files changed, 472 insertions(+), 86 deletions(-) create mode 100644 backend/windmill-ai/src/proxy.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 6e1197f88c..6f97d4370a 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -13880,6 +13880,7 @@ dependencies = [ "bytes", "eventsource-stream", "futures", + "http 1.4.0", "lazy_static", "mime_guess", "reqwest 0.13.1", diff --git a/backend/windmill-ai/Cargo.toml b/backend/windmill-ai/Cargo.toml index 542c390cc0..b419101f25 100644 --- a/backend/windmill-ai/Cargo.toml +++ b/backend/windmill-ai/Cargo.toml @@ -24,6 +24,7 @@ base64.workspace = true bytes.workspace = true eventsource-stream.workspace = true futures.workspace = true +http.workspace = true mime_guess.workspace = true reqwest.workspace = true serde.workspace = true diff --git a/backend/windmill-ai/src/lib.rs b/backend/windmill-ai/src/lib.rs index 6b174bf2e6..a138d72f3c 100644 --- a/backend/windmill-ai/src/lib.rs +++ b/backend/windmill-ai/src/lib.rs @@ -6,6 +6,7 @@ pub mod ai_providers; pub mod ai_types; pub mod image_handler; pub mod providers; +pub mod proxy; pub mod query_builder; pub mod sse; pub mod types; diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index 8f9e2382a8..d1a602cf0e 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -6,7 +6,10 @@ pub mod openai; pub mod openrouter; pub mod other; -use crate::{ai_providers::AIProvider, query_builder::QueryBuilder, types::ProviderWithResource}; +use crate::{ + ai_providers::AIProvider, proxy::ProviderCredentials, query_builder::QueryBuilder, + types::ProviderWithResource, +}; use self::{ anthropic::AnthropicQueryBuilder, google_ai::GoogleAIQueryBuilder, openai::OpenAIQueryBuilder, @@ -29,3 +32,18 @@ pub fn create_query_builder(provider: &ProviderWithResource) -> Box 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 { + match credentials.provider { + AIProvider::GoogleAI => Box::new(GoogleAIQueryBuilder::new(credentials.platform.clone())), + AIProvider::OpenAI => Box::new(OpenAIQueryBuilder::new(credentials.provider.clone())), + AIProvider::Anthropic => Box::new(AnthropicQueryBuilder::new( + credentials.provider.clone(), + credentials.platform.clone(), + credentials.enable_1m_context, + )), + AIProvider::OpenRouter => Box::new(OpenRouterQueryBuilder::new()), + _ => Box::new(OtherQueryBuilder::new(credentials.provider.clone())), + } +} diff --git a/backend/windmill-ai/src/providers/openai.rs b/backend/windmill-ai/src/providers/openai.rs index f8928ca940..efd7229ebb 100644 --- a/backend/windmill-ai/src/providers/openai.rs +++ b/backend/windmill-ai/src/providers/openai.rs @@ -2,6 +2,7 @@ use crate::{ ai_providers::AIProvider, ai_types::OpenAIToolCall, image_handler::{prepare_messages_for_api, s3_object_to_content_part}, + proxy::{build_openai_compatible_proxy_request, ProxyBuildArgs, ProxyRequest}, query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink}, sse::{OpenAIResponsesSSEParser, SSEParser}, types::*, @@ -479,6 +480,10 @@ impl QueryBuilder for OpenAIQueryBuilder { true } + fn build_proxy_request(&self, args: &ProxyBuildArgs<'_>) -> Result { + build_openai_compatible_proxy_request(args) + } + async fn parse_streaming_response( &self, response: reqwest::Response, diff --git a/backend/windmill-ai/src/providers/openrouter.rs b/backend/windmill-ai/src/providers/openrouter.rs index aaefbb08ca..b0dce497d7 100644 --- a/backend/windmill-ai/src/providers/openrouter.rs +++ b/backend/windmill-ai/src/providers/openrouter.rs @@ -1,6 +1,7 @@ use crate::{ ai_providers::AIProvider, image_handler::prepare_messages_for_api, + proxy::{build_openai_compatible_proxy_request, ProxyBuildArgs, ProxyRequest}, query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink}, types::*, }; @@ -72,6 +73,10 @@ impl QueryBuilder for OpenRouterQueryBuilder { true } + fn build_proxy_request(&self, args: &ProxyBuildArgs<'_>) -> Result { + build_openai_compatible_proxy_request(args) + } + async fn build_request( &self, args: &BuildRequestArgs<'_>, diff --git a/backend/windmill-ai/src/providers/other.rs b/backend/windmill-ai/src/providers/other.rs index 08f922a1de..650ce1c19e 100644 --- a/backend/windmill-ai/src/providers/other.rs +++ b/backend/windmill-ai/src/providers/other.rs @@ -1,6 +1,7 @@ use crate::{ ai_providers::AIProvider, image_handler::prepare_messages_for_api, + proxy::{build_openai_compatible_proxy_request, ProxyBuildArgs, ProxyRequest}, query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink}, sse::{OpenAISSEParser, SSEParser}, types::*, @@ -188,6 +189,10 @@ impl QueryBuilder for OtherQueryBuilder { true } + fn build_proxy_request(&self, args: &ProxyBuildArgs<'_>) -> Result { + build_openai_compatible_proxy_request(args) + } + async fn parse_image_response( &self, _response: reqwest::Response, diff --git a/backend/windmill-ai/src/proxy.rs b/backend/windmill-ai/src/proxy.rs new file mode 100644 index 0000000000..c08e5e4c6a --- /dev/null +++ b/backend/windmill-ai/src/proxy.rs @@ -0,0 +1,224 @@ +use std::collections::HashMap; + +use http::{HeaderMap, Method}; +use serde_json::value::RawValue; +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. +/// +/// This is intentionally separate from the worker's `ProviderWithResource`: API +/// proxy credentials are already resolved from workspace or instance resources. +#[derive(Clone, Debug)] +pub struct ProviderCredentials { + pub provider: AIProvider, + pub base_url: String, + pub api_key: Option, + pub access_token: Option, + pub organization_id: Option, + pub user: Option, + pub region: Option, + pub aws_access_key_id: Option, + pub aws_secret_access_key: Option, + pub aws_session_token: Option, + pub platform: AIPlatform, + pub enable_1m_context: bool, + pub custom_headers: HashMap, +} + +/// Inputs needed to transform an OpenAI-compatible proxy request for a provider. +pub struct ProxyBuildArgs<'a> { + pub method: &'a Method, + pub path: &'a str, + pub headers: &'a HeaderMap, + pub body: &'a [u8], + pub credentials: &'a ProviderCredentials, +} + +/// Provider-specific request produced by proxy request builders. +#[derive(Clone, Debug)] +pub struct ProxyRequest { + pub method: Method, + pub url: String, + pub headers: Vec<(String, String)>, + pub body: Vec, +} + +pub fn supports_openai_compatible_proxy(provider: &AIProvider) -> bool { + matches!( + provider, + AIProvider::OpenAI + | AIProvider::AzureOpenAI + | AIProvider::Mistral + | AIProvider::DeepSeek + | AIProvider::Groq + | AIProvider::OpenRouter + | AIProvider::TogetherAI + | AIProvider::CustomAI + ) +} + +pub fn build_openai_compatible_proxy_request(args: &ProxyBuildArgs<'_>) -> Result { + let credentials = args.credentials; + let body = if let Some(user) = credentials.user.as_ref() { + add_user_to_body(args.body, user)? + } else { + args.body.to_vec() + }; + + let base_url = credentials.base_url.trim_end_matches('/'); + let is_azure = credentials.provider.is_azure_openai(base_url); + let url = if is_azure { + AIProvider::build_azure_openai_url(base_url, args.path) + } else { + format!("{}/{}", base_url, args.path) + }; + + let mut headers = vec![("content-type".to_string(), "application/json".to_string())]; + + if let Some(api_key) = credentials.api_key.as_ref() { + if is_azure { + headers.push(("api-key".to_string(), api_key.clone())); + } else { + headers.push(("authorization".to_string(), format!("Bearer {}", api_key))); + } + } + + if let Some(access_token) = credentials.access_token.as_ref() { + headers.push(( + "authorization".to_string(), + format!("Bearer {}", access_token), + )); + } + + if let Some(org_id) = credentials.organization_id.as_ref() { + headers.push(("OpenAI-Organization".to_string(), org_id.clone())); + } + + for (header_name, header_value) in AI_HTTP_HEADERS.iter() { + headers.push((header_name.clone(), header_value.clone())); + } + + for (header_name, header_value) in &credentials.custom_headers { + headers.push((header_name.clone(), header_value.clone())); + } + + Ok(ProxyRequest { method: args.method.clone(), url, headers, body }) +} + +fn add_user_to_body(body: &[u8], user: &str) -> Result> { + tracing::debug!("Adding user to request body"); + let mut json_body: HashMap> = serde_json::from_slice(body) + .map_err(|e| Error::internal_err(format!("Failed to parse request body: {}", e)))?; + + let user_json_string = serde_json::Value::String(user.to_string()).to_string(); + + json_body.insert( + "user".to_string(), + RawValue::from_string(user_json_string) + .map_err(|e| Error::internal_err(format!("Failed to parse user: {}", e)))?, + ); + + serde_json::to_vec(&json_body) + .map_err(|e| Error::internal_err(format!("Failed to reserialize request body: {}", e))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn credentials(provider: AIProvider, base_url: &str) -> ProviderCredentials { + ProviderCredentials { + provider, + base_url: base_url.to_string(), + api_key: Some("api-key".to_string()), + access_token: None, + organization_id: Some("org-id".to_string()), + user: None, + 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(), + } + } + + #[test] + fn builds_openai_compatible_proxy_request() { + let credentials = credentials(AIProvider::OpenRouter, "https://openrouter.ai/api/v1/"); + let method = Method::POST; + let headers = HeaderMap::new(); + let body = br#"{"model":"openrouter/model","messages":[]}"#; + + let request = build_openai_compatible_proxy_request(&ProxyBuildArgs { + method: &method, + path: "chat/completions", + headers: &headers, + body, + credentials: &credentials, + }) + .unwrap(); + + assert_eq!(request.method, Method::POST); + assert_eq!(request.url, "https://openrouter.ai/api/v1/chat/completions"); + assert_eq!(request.body, body.to_vec()); + assert!(request + .headers + .contains(&("authorization".to_string(), "Bearer api-key".to_string()))); + assert!(request + .headers + .contains(&("OpenAI-Organization".to_string(), "org-id".to_string()))); + } + + #[test] + fn builds_azure_openai_proxy_request() { + let credentials = credentials( + AIProvider::AzureOpenAI, + "https://example.openai.azure.com/openai", + ); + let method = Method::POST; + let headers = HeaderMap::new(); + + let request = build_openai_compatible_proxy_request(&ProxyBuildArgs { + method: &method, + path: "chat/completions", + headers: &headers, + body: br#"{"model":"deployment","messages":[]}"#, + credentials: &credentials, + }) + .unwrap(); + + assert_eq!( + request.url, + "https://example.openai.azure.com/openai/v1/chat/completions" + ); + assert!(request + .headers + .contains(&("api-key".to_string(), "api-key".to_string()))); + } + + #[test] + fn injects_user_into_proxy_body() { + let mut credentials = credentials(AIProvider::OpenAI, "https://api.openai.com/v1"); + credentials.user = Some("user-1".to_string()); + let method = Method::POST; + let headers = HeaderMap::new(); + + let request = build_openai_compatible_proxy_request(&ProxyBuildArgs { + method: &method, + path: "chat/completions", + headers: &headers, + body: br#"{"model":"gpt-4o","messages":[]}"#, + credentials: &credentials, + }) + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap(); + + assert_eq!(body["user"], "user-1"); + assert_eq!(body["model"], "gpt-4o"); + } +} diff --git a/backend/windmill-ai/src/query_builder.rs b/backend/windmill-ai/src/query_builder.rs index a1b6b6ba57..9830045f57 100644 --- a/backend/windmill-ai/src/query_builder.rs +++ b/backend/windmill-ai/src/query_builder.rs @@ -3,6 +3,7 @@ use windmill_common::{client::AuthedClient, error::Error}; use windmill_types::s3::S3Object; use crate::ai_types::OpenAIToolCall; +use crate::proxy::{ProxyBuildArgs, ProxyRequest}; use crate::types::*; /// Arguments for building an AI request @@ -73,6 +74,14 @@ pub trait QueryBuilder: Send + Sync { false } + /// Build a provider-specific request from an OpenAI-compatible proxy request. + fn build_proxy_request(&self, args: &ProxyBuildArgs<'_>) -> Result { + Err(Error::BadRequest(format!( + "Proxy request building is not supported for provider {:?}", + args.credentials.provider + ))) + } + /// Parse the image response from the provider async fn parse_image_response( &self, diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 8b415997c2..5cf04c79d7 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -19,6 +19,10 @@ use windmill_ai::ai_cache::current_instance_ai_config_revision; use windmill_ai::ai_providers::{ empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel, }; +use windmill_ai::providers::create_proxy_query_builder; +use windmill_ai::proxy::{ + supports_openai_compatible_proxy, ProviderCredentials, ProxyBuildArgs, ProxyRequest, +}; use windmill_ai::utils::AI_HTTP_HEADERS; use windmill_audit::{audit_oss::audit_log, ActionKind}; use windmill_common::db::UserDB; @@ -372,19 +376,22 @@ impl AIRequestConfig { headers: HeaderMap, body: Bytes, ) -> Result { - let body = if let Some(user) = self.user { - Self::add_user_to_body(body, user)? + let credentials = self.into_provider_credentials(provider.clone()); + + let body = if let Some(user) = credentials.user.as_ref() { + Self::add_user_to_body(body, user.clone())? } else { body }; - let base_url = self.base_url.trim_end_matches('/'); + let base_url = credentials.base_url.trim_end_matches('/'); - let is_azure = provider.is_azure_openai(base_url); - let is_anthropic = matches!(provider, AIProvider::Anthropic); - let is_anthropic_vertex = is_anthropic && self.platform == AIPlatform::GoogleVertexAi; + let is_azure = credentials.provider.is_azure_openai(base_url); + let is_anthropic = credentials.provider == AIProvider::Anthropic; + let is_anthropic_vertex = + is_anthropic && credentials.platform == AIPlatform::GoogleVertexAi; let is_anthropic_sdk = headers.get("X-Anthropic-SDK").is_some(); - let is_google_ai = matches!(provider, AIProvider::GoogleAI); + let is_google_ai = credentials.provider == AIProvider::GoogleAI; let base_url = base_url.to_string(); let base_url = base_url.as_str(); @@ -423,12 +430,12 @@ impl AIRequestConfig { } } - if is_anthropic_sdk && self.enable_1m_context { + if is_anthropic_sdk && credentials.enable_1m_context { request = request.header("anthropic-beta", "context-1m-2025-08-07"); } // Add authentication headers - if let Some(api_key) = self.api_key { + if let Some(api_key) = credentials.api_key { if is_azure { request = request.header("api-key", api_key.clone()) } else if is_google_ai { @@ -445,13 +452,13 @@ impl AIRequestConfig { } } - if let Some(access_token) = self.access_token { + if let Some(access_token) = credentials.access_token { request = request.header("authorization", format!("Bearer {}", access_token)) } request = request.body(body); - if let Some(org_id) = self.organization_id { + if let Some(org_id) = credentials.organization_id { request = request.header("OpenAI-Organization", org_id); } @@ -461,13 +468,31 @@ impl AIRequestConfig { } // Apply custom headers from the resource - for (header_name, header_value) in &self.custom_headers { + for (header_name, header_value) in &credentials.custom_headers { request = request.header(header_name.as_str(), header_value.as_str()); } Ok(request) } + 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, + } + } + fn add_user_to_body(body: Bytes, user: String) -> Result { tracing::debug!("Adding user to request body"); let mut json_body: HashMap> = serde_json::from_slice(&body) @@ -641,6 +666,14 @@ fn is_sse_response(headers: &HeaderMap) -> bool { .unwrap_or(false) } +fn proxy_request_to_request_builder(proxy_request: ProxyRequest) -> RequestBuilder { + let mut request = HTTP_CLIENT.request(proxy_request.method.clone(), &proxy_request.url); + for (header_name, header_value) in &proxy_request.headers { + request = request.header(header_name.as_str(), header_value.as_str()); + } + request.body(proxy_request.body) +} + pub(crate) fn inject_keepalives( upstream: S, interval: Duration, @@ -693,37 +726,64 @@ async fn global_proxy( let base_url = provider.get_base_url(None, &db).await?; - let is_anthropic = provider.is_anthropic(); - let is_anthropic_sdk = headers.get("X-Anthropic-SDK").is_some(); - - let url = if is_anthropic_sdk { - let truncated_base_url = base_url.trim_end_matches("/v1"); - format!("{}/{}", truncated_base_url, ai_path) + let request = if supports_openai_compatible_proxy(&provider) { + let credentials = ProviderCredentials { + provider: provider.clone(), + base_url, + api_key: Some(api_key.clone()), + access_token: None, + organization_id: None, + user: None, + 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(), + }; + let query_builder = create_proxy_query_builder(&credentials); + let proxy_request = query_builder.build_proxy_request(&ProxyBuildArgs { + method: &method, + path: &ai_path, + headers: &headers, + body: &body, + credentials: &credentials, + })?; + proxy_request_to_request_builder(proxy_request) } else { - format!("{}/{}", base_url, ai_path) - }; + let is_anthropic = provider.is_anthropic(); + let is_anthropic_sdk = headers.get("X-Anthropic-SDK").is_some(); - let mut request = HTTP_CLIENT - .request(method, url) - .header("content-type", "application/json") - .header("Authorization", format!("Bearer {}", &api_key)); + let url = if is_anthropic_sdk { + let truncated_base_url = base_url.trim_end_matches("/v1"); + format!("{}/{}", truncated_base_url, ai_path) + } else { + format!("{}/{}", base_url, ai_path) + }; - if is_anthropic { - request = request.header("X-API-Key", &api_key); - } + let mut request = HTTP_CLIENT + .request(method, url) + .header("content-type", "application/json") + .header("Authorization", format!("Bearer {}", &api_key)); - for (header_name, header_value) in headers.iter() { - if header_name.to_string().starts_with("anthropic-") { - request = request.header(header_name, header_value); + if is_anthropic { + request = request.header("X-API-Key", &api_key); } - } - // Apply custom headers from AI_HTTP_HEADERS environment variable - for (header_name, header_value) in AI_HTTP_HEADERS.iter() { - request = request.header(header_name.as_str(), header_value.as_str()); - } + for (header_name, header_value) in headers.iter() { + if header_name.to_string().starts_with("anthropic-") { + request = request.header(header_name, header_value); + } + } - let request = request.body(body); + // Apply custom headers from AI_HTTP_HEADERS environment variable + for (header_name, header_value) in AI_HTTP_HEADERS.iter() { + request = request.header(header_name.as_str(), header_value.as_str()); + } + + request.body(body) + }; let response = request.send().await.map_err(to_anyhow)?; @@ -1040,7 +1100,20 @@ async fn proxy( )); } - let request = request_config.prepare_request(&provider, &ai_path, method, headers, body)?; + let request = if supports_openai_compatible_proxy(&provider) { + let credentials = request_config.into_provider_credentials(provider.clone()); + let query_builder = create_proxy_query_builder(&credentials); + let proxy_request = query_builder.build_proxy_request(&ProxyBuildArgs { + method: &method, + path: &ai_path, + headers: &headers, + body: &body, + credentials: &credentials, + })?; + proxy_request_to_request_builder(proxy_request) + } else { + request_config.prepare_request(&provider, &ai_path, method, headers, body)? + }; let response = request.send().await.map_err(to_anyhow)?; @@ -1103,6 +1176,55 @@ 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(); diff --git a/docs/windmill-ai-refactor-plan.md b/docs/windmill-ai-refactor-plan.md index 0e8449d897..4c0e109735 100644 --- a/docs/windmill-ai-refactor-plan.md +++ b/docs/windmill-ai-refactor-plan.md @@ -23,57 +23,48 @@ windmill-worker → windmill-ai windmill-common does **NOT** re-export from windmill-ai (would be circular). All consumers update imports. -## Reviewer Note: Keep the Next PR Small +## Reviewer Note: Keep API Proxy Unification Split -The first merged PR established the crate boundary; it did not yet remove the duplicated API-vs-worker provider paths. The remaining work should stay split by dependency risk, not by the final desired module layout. +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. -Do not jump directly from the current state to provider moves, proxy unification, and credential unification in one PR. The riskiest part is the API proxy because it 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. - -Pull the shared plumbing forward before moving provider implementations: -- Move tiny shared utilities first, including `AI_HTTP_HEADERS`, `extract_text_content`, and `should_use_structured_output_tool`. -- Move SSE parsers next, using the existing `StreamEventSink` abstraction, and update callers to import from `windmill_ai` directly. -- Leave provider implementations, image upload/download handling, API proxy changes, and credential unification out of that PR. +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. +- Move the OpenAI-compatible proxy path into `windmill-ai` next, while keeping provider-native behavior unchanged. +- Move Anthropic/Vertex, Google AI, and Bedrock in separate follow-up PRs. +- Unify credential resolution only after all proxy request builders use the shared shape. Avoid adding modules whose only purpose is to re-export moved code. Direct imports from `windmill_ai` make ownership and dependency direction clearer at each call site. Also do not make `build_proxy_request(raw_body, path)` too narrow. The proxy path needs method, incoming headers, resolved credentials, base URL/platform, organization/user fields, custom headers, and Bedrock/Azure/Vertex-specific context. Introduce a structured `ProxyBuildArgs`/`ProviderCredentials` shape before deleting `AIRequestConfig::prepare_request`, `google.rs`, or `bedrock.rs`. -## Next Phase PR: Shared Plumbing Only +## Current Phase PR: Proxy Contract + OpenAI-Compatible Proxy -Goal: make `windmill-ai` own the provider-independent helper code that later provider moves will need, without changing API proxy behavior or agent request behavior. +Goal: introduce the shared API proxy contract in `windmill-ai` and move the OpenAI-compatible proxy request builder there without changing provider behavior. -Suggested PR title: `refactor(ai): move shared SSE plumbing into windmill-ai`. +Suggested PR title: `refactor(ai): move openai-compatible proxy building to windmill-ai`. Scope: -- Add `windmill-ai/src/utils.rs`. -- Move the duplicated `AI_HTTP_HEADERS` parsing into `windmill_ai::utils` with identical parsing behavior. -- Move `extract_text_content` and `should_use_structured_output_tool` from `windmill-worker/src/ai/utils.rs` to `windmill_ai::utils`. -- Move `windmill-worker/src/ai/sse.rs` to `windmill-ai/src/sse.rs`. -- Delete `windmill-worker/src/ai/sse.rs` and update callers to import parser types from `windmill_ai::sse`. -- Update callers of moved utility functions to import from `windmill_ai::utils` directly. -- Add the minimal new `windmill-ai` dependencies required by `sse.rs` (`eventsource-stream`, `tokio-stream`) and avoid adding worker/queue dependencies. +- Add `windmill-ai/src/proxy.rs` and export it from `lib.rs`. +- Define `ProviderCredentials`, `ProxyBuildArgs`, and `ProxyRequest`. +- Include all context known to be needed by the current API proxy path: method, path, incoming headers, body, provider, base URL, API key, OAuth access token, organization/user fields, platform, 1M context flag, custom headers, region, and AWS credentials. +- Add a conversion from API-side `AIRequestConfig` to `ProviderCredentials`. +- Add `QueryBuilder::build_proxy_request` with a default unsupported-provider implementation. +- Implement `build_proxy_request` for OpenAI-compatible providers (`OpenAI`, `AzureOpenAI`, `Mistral`, `DeepSeek`, `Groq`, `OpenRouter`, `TogetherAI`, `CustomAI`). +- Route workspace and global API proxy requests for OpenAI-compatible providers through `windmill-ai`. +- Keep FIM transformation in `windmill-api` before calling the proxy builder. +- Keep `AIRequestConfig::prepare_request` for Anthropic/Vertex and remaining fallback paths. Out of scope: -- Do not move provider implementations. -- Do not move `image_handler`. -- Do not change `QueryBuilder` method signatures. -- Do not add `build_proxy_request`. -- Do not change API proxy routing, request preparation, credential resolution, audit logging, cache behavior, or Bedrock/Google special cases. +- Do not move Anthropic/Vertex proxy behavior yet. +- Do not move Google AI or Bedrock proxy behavior yet. +- Do not change credential resolution, audit logging, cache behavior, SSE keepalive behavior, or Bedrock/Google special cases. - Do not remove `windmill-api/src/google.rs`, `windmill-api/src/bedrock.rs`, or `AIRequestConfig::prepare_request`. -Implementation checklist: -1. Add `utils.rs` to `windmill-ai` and export it from `lib.rs`. -2. Move `AI_HTTP_HEADERS` exactly once, then update `windmill-api/src/ai.rs` and `windmill-worker/src/ai_executor.rs` to import it. -3. Move the two provider-independent helper functions into `windmill_ai::utils`; leave worker-specific flow/MCP/conversation utilities in `windmill-worker/src/ai/utils.rs`. -4. Move `sse.rs` into `windmill-ai`, change imports from `crate::ai::{query_builder, types}` to `crate::{query_builder, types}`, and keep behavior unchanged. -5. Remove worker `ai/sse.rs` and update provider imports to use `windmill_ai::sse` directly. -6. Run focused grep checks for duplicate `AI_HTTP_HEADERS`, old local helper definitions, and accidental `windmill_queue`/worker dependencies from `windmill-ai`. -7. Validate with `cargo check -p windmill-ai`, `cargo check -p windmill-worker`, and `cargo check -p windmill-api`. For `bedrock` builds, also check the existing bedrock feature path. - -Review expectations: -- The diff should be mostly moved code and import updates. -- The behavior should be byte-for-byte equivalent where practical. -- Tests are only needed if helper behavior changes. For a pure move, existing backend checks plus manual AI streaming verification are enough. +Validation: +- `cargo test -p windmill-ai proxy` +- `cargo test -p windmill-api maps_request_config_to_provider_credentials` +- `cargo check -p windmill-ai -p windmill-api` +- `cargo check -p windmill-ai -p windmill-api --features bedrock` ## Step-by-Step Plan @@ -133,7 +124,7 @@ pub trait StreamEventSink: Send + Sync { --- -### Step 4: Move SSE parsers to windmill-ai +### Step 4: Move SSE parsers to windmill-ai ✅ Move from `windmill-worker/src/ai/sse.rs` to `windmill-ai/src/sse.rs`: - `SSEParser` trait @@ -168,7 +159,7 @@ Move from `windmill-worker/src/ai/image_handler.rs` to `windmill-ai/src/image_ha --- -### Step 7: Move shared utilities to windmill-ai +### Step 7: Move shared utilities to windmill-ai ✅ Move `AI_HTTP_HEADERS` lazy_static (currently duplicated in `windmill-api/src/ai.rs` and `windmill-worker/src/ai_executor.rs`) to `windmill_ai::utils`. Both consumers import from windmill-ai. @@ -190,7 +181,7 @@ fn build_proxy_request( Where `ProxyBuildArgs` carries the API proxy context that provider implementations need: ```rust pub struct ProxyBuildArgs<'a> { - pub method: http::Method, + pub method: &'a http::Method, pub path: &'a str, pub headers: &'a http::HeaderMap, pub body: &'a [u8], @@ -201,9 +192,10 @@ pub struct ProxyBuildArgs<'a> { And `ProxyRequest` contains the transformed request: ```rust pub struct ProxyRequest { + pub method: http::Method, pub url: String, - pub body: Vec, pub headers: Vec<(String, String)>, + pub body: Vec, } ``` @@ -236,24 +228,26 @@ pub struct ProxyRequest { ### Step 9: Unify credential resolution -Merge `AIRequestConfig` (API-side) and `ProviderWithResource` (worker-side) into a single type in windmill-ai. +Merge `AIRequestConfig` (API-side) and `ProviderWithResource` (worker-side) into a single credential shape in windmill-ai. 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. -Create `windmill_ai::ProviderCredentials` that both can produce: +Extend `windmill_ai::proxy::ProviderCredentials` as needed so both can produce it: ```rust pub struct ProviderCredentials { pub provider: AIProvider, - pub api_key: Option, pub base_url: String, + pub api_key: Option, + pub access_token: Option, + pub organization_id: Option, + pub user: Option, pub platform: AIPlatform, pub region: Option, pub aws_access_key_id: Option, pub aws_secret_access_key: Option, pub aws_session_token: Option, - pub custom_headers: HashMap, - pub organization_id: Option, pub enable_1m_context: bool, + pub custom_headers: HashMap, } ``` @@ -265,14 +259,15 @@ The `create_query_builder` factory takes `&ProviderCredentials` instead of `&Pro ``` windmill-ai/src/ -├── lib.rs # re-exports, AI_HTTP_HEADERS +├── lib.rs # module exports ├── ai_types.rs # OpenAI-compatible message types ├── ai_providers.rs # AIProvider enum, base URLs, config ├── ai_google.rs # Gemini types and conversions ├── ai_bedrock.rs # Bedrock SDK wrapper (feature: bedrock) ├── ai_cache.rs # Instance AI config revision -├── types.rs # ProviderCredentials, TokenUsage, Tool, OpenAPISchema, etc. -├── query_builder.rs # QueryBuilder trait, BuildRequestArgs, ParsedResponse, ProxyRequest, StreamEventSink +├── types.rs # TokenUsage, Tool, OpenAPISchema, etc. +├── proxy.rs # ProviderCredentials, ProxyBuildArgs, ProxyRequest +├── query_builder.rs # QueryBuilder trait, BuildRequestArgs, ParsedResponse, StreamEventSink ├── sse.rs # SSE parsers (OpenAI, Anthropic, Gemini, Responses) ├── image_handler.rs # S3 image upload/download ├── utils.rs # extract_text_content, should_use_structured_output_tool From fec40086961174fea25b4e1f796991152b84b211 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Mon, 18 May 2026 12:40:18 +0200 Subject: [PATCH 151/313] fix: preserve ai reasoning content (#9208) * fix: preserve ai reasoning content * fix: avoid text-only reasoning replay * feat: add deepseek ai eval models --- .../frontend/core/shared/baseEvalRunner.ts | 3 +- .../core/shared/providerConfig.test.ts | 7 ++ .../frontend/core/shared/providerConfig.ts | 3 + ai_evals/core/models.test.ts | 25 ++++-- ai_evals/core/models.ts | 44 +++++++-- ai_evals/modes/frontendCommon.test.ts | 9 +- ai_evals/modes/frontendCommon.ts | 8 +- .../copilot/chat/openaiReasoning.ts | 51 +++++++++++ .../src/lib/components/copilot/lib.test.ts | 90 +++++++++++++++++++ frontend/src/lib/components/copilot/lib.ts | 66 +++++++++----- 10 files changed, 269 insertions(+), 37 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/openaiReasoning.ts diff --git a/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts b/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts index 7fd43ccb87..5f4ea2c307 100644 --- a/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts @@ -236,7 +236,8 @@ function toFrontendEvalProvider( if ( provider === "anthropic" || provider === "openai" || - provider === "googleai" + provider === "googleai" || + provider === "deepseek" ) { return provider; } diff --git a/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts b/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts index 77d9154da3..819300bd62 100644 --- a/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts +++ b/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts @@ -27,6 +27,13 @@ describe("resolveEvalModelProvider", () => { }); }); + it("infers deepseek from DeepSeek model ids", () => { + expect(resolveEvalModelProvider("deepseek-v4-flash")).toEqual({ + provider: "deepseek", + model: "deepseek-v4-flash", + }); + }); + it("preserves an explicit provider", () => { expect(resolveEvalModelProvider("gemini-2.5-pro", "googleai")).toEqual({ provider: "googleai", diff --git a/ai_evals/adapters/frontend/core/shared/providerConfig.ts b/ai_evals/adapters/frontend/core/shared/providerConfig.ts index 15372049fe..9d9e315217 100644 --- a/ai_evals/adapters/frontend/core/shared/providerConfig.ts +++ b/ai_evals/adapters/frontend/core/shared/providerConfig.ts @@ -83,6 +83,9 @@ export function resolveEvalModelProvider( if (model.startsWith("gemini")) { return { provider: "googleai", model }; } + if (model.startsWith("deepseek")) { + return { provider: "deepseek", model }; + } if (model.startsWith("gpt") || model.startsWith("o")) { return { provider: "openai", model }; } diff --git a/ai_evals/core/models.test.ts b/ai_evals/core/models.test.ts index 86bf1c6a9a..a11fe40530 100644 --- a/ai_evals/core/models.test.ts +++ b/ai_evals/core/models.test.ts @@ -11,19 +11,34 @@ describe("resolveEvalModel", () => { provider: "googleai", model: "gemini-2.5-pro", }); - expect(resolveEvalModel("script", "gemini-3-flash-preview").frontend).toEqual({ + expect( + resolveEvalModel("script", "gemini-3-flash-preview").frontend, + ).toEqual({ provider: "googleai", model: "gemini-3-flash-preview", }); - expect(resolveEvalModel("flow", "gemini-3.1-pro-preview").frontend).toEqual({ - provider: "googleai", - model: "gemini-3.1-pro-preview", + expect(resolveEvalModel("flow", "gemini-3.1-pro-preview").frontend).toEqual( + { + provider: "googleai", + model: "gemini-3.1-pro-preview", + }, + ); + }); + + it("supports DeepSeek aliases for frontend evals", () => { + expect(resolveEvalModel("flow", "deepseek").frontend).toEqual({ + provider: "deepseek", + model: "deepseek-v4-flash", + }); + expect(resolveEvalModel("script", "deepseek-v4-pro").frontend).toEqual({ + provider: "deepseek", + model: "deepseek-v4-pro", }); }); it("rejects Gemini aliases for cli evals", () => { expect(() => resolveEvalModel("cli", "gemini")).toThrow( - "Model gemini-flash is not supported for cli mode" + "Model gemini-flash is not supported for cli mode", ); }); }); diff --git a/ai_evals/core/models.ts b/ai_evals/core/models.ts index 82f3b3f69b..054a27bc1c 100644 --- a/ai_evals/core/models.ts +++ b/ai_evals/core/models.ts @@ -1,7 +1,7 @@ import type { EvalMode } from "./types"; export interface FrontendEvalModelConfig { - provider: "anthropic" | "openai" | "googleai"; + provider: "anthropic" | "openai" | "googleai" | "deepseek"; model: string; } @@ -117,15 +117,40 @@ export const EVAL_MODELS: EvalModelSpec[] = [ { id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview", - aliases: ["gemini-3.1-pro-preview", "gemini-3.1-pro", "gemini-3-pro-preview"], + aliases: [ + "gemini-3.1-pro-preview", + "gemini-3.1-pro", + "gemini-3-pro-preview", + ], frontend: { provider: "googleai", model: "gemini-3.1-pro-preview", }, }, + { + id: "deepseek-v4-flash", + label: "DeepSeek V4 Flash", + aliases: ["deepseek", "deepseek-v4", "deepseek-v4-flash"], + frontend: { + provider: "deepseek", + model: "deepseek-v4-flash", + }, + }, + { + id: "deepseek-v4-pro", + label: "DeepSeek V4 Pro", + aliases: ["deepseek-pro", "deepseek-v4-pro"], + frontend: { + provider: "deepseek", + model: "deepseek-v4-pro", + }, + }, ]; -export function resolveEvalModel(mode: EvalMode, alias?: string): EvalModelSpec { +export function resolveEvalModel( + mode: EvalMode, + alias?: string, +): EvalModelSpec { const spec = alias ? findEvalModel(alias) : getDefaultEvalModel(mode); if (!spec) { throw new Error(`Unknown model: ${alias}`); @@ -152,14 +177,19 @@ export function getEvalModelHelpText(): string { }).join("\n"); } -export function formatRunModelLabel(mode: EvalMode, model: EvalModelSpec): string { +export function formatRunModelLabel( + mode: EvalMode, + model: EvalModelSpec, +): string { if (mode === "cli") { return `${model.cli!.provider}:${model.cli!.model}`; } return `${model.frontend!.provider}:${model.frontend!.model}`; } -export function getFrontendEvalModel(model: EvalModelSpec): FrontendEvalModelConfig { +export function getFrontendEvalModel( + model: EvalModelSpec, +): FrontendEvalModelConfig { if (!model.frontend) { throw new Error(`Model ${model.id} does not support frontend evals`); } @@ -180,6 +210,8 @@ function getDefaultEvalModel(mode: EvalMode): EvalModelSpec { function findEvalModel(alias: string): EvalModelSpec | undefined { const normalized = alias.trim().toLowerCase(); return EVAL_MODELS.find((model) => - [model.id, ...model.aliases].some((candidate) => candidate.toLowerCase() === normalized) + [model.id, ...model.aliases].some( + (candidate) => candidate.toLowerCase() === normalized, + ), ); } diff --git a/ai_evals/modes/frontendCommon.test.ts b/ai_evals/modes/frontendCommon.test.ts index cac10ffcab..897ac3f8a3 100644 --- a/ai_evals/modes/frontendCommon.test.ts +++ b/ai_evals/modes/frontendCommon.test.ts @@ -5,12 +5,14 @@ const ORIGINAL_ENV = { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, OPENAI_API_KEY: process.env.OPENAI_API_KEY, GEMINI_API_KEY: process.env.GEMINI_API_KEY, + DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY, }; afterEach(() => { process.env.ANTHROPIC_API_KEY = ORIGINAL_ENV.ANTHROPIC_API_KEY; process.env.OPENAI_API_KEY = ORIGINAL_ENV.OPENAI_API_KEY; process.env.GEMINI_API_KEY = ORIGINAL_ENV.GEMINI_API_KEY; + process.env.DEEPSEEK_API_KEY = ORIGINAL_ENV.DEEPSEEK_API_KEY; }); describe("getFrontendApiKey", () => { @@ -19,10 +21,15 @@ describe("getFrontendApiKey", () => { expect(getFrontendApiKey("googleai")).toBe("gemini-test-key"); }); + it("reads the DeepSeek API key for deepseek models", () => { + process.env.DEEPSEEK_API_KEY = "deepseek-test-key"; + expect(getFrontendApiKey("deepseek")).toBe("deepseek-test-key"); + }); + it("throws a provider-specific error when the key is missing", () => { delete process.env.GEMINI_API_KEY; expect(() => getFrontendApiKey("googleai")).toThrow( - "GEMINI_API_KEY is required for frontend evals" + "GEMINI_API_KEY is required for frontend evals", ); }); }); diff --git a/ai_evals/modes/frontendCommon.ts b/ai_evals/modes/frontendCommon.ts index f121551d86..b81907b42d 100644 --- a/ai_evals/modes/frontendCommon.ts +++ b/ai_evals/modes/frontendCommon.ts @@ -1,12 +1,16 @@ import type { FrontendEvalModelConfig } from "../core/models"; -export function getFrontendApiKey(provider: FrontendEvalModelConfig["provider"]): string { +export function getFrontendApiKey( + provider: FrontendEvalModelConfig["provider"], +): string { const envName = provider === "anthropic" ? "ANTHROPIC_API_KEY" : provider === "googleai" ? "GEMINI_API_KEY" - : "OPENAI_API_KEY"; + : provider === "deepseek" + ? "DEEPSEEK_API_KEY" + : "OPENAI_API_KEY"; const apiKey = process.env[envName]; if (!apiKey) { throw new Error(`${envName} is required for frontend evals`); diff --git a/frontend/src/lib/components/copilot/chat/openaiReasoning.ts b/frontend/src/lib/components/copilot/chat/openaiReasoning.ts new file mode 100644 index 0000000000..da809641a0 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/openaiReasoning.ts @@ -0,0 +1,51 @@ +import type { + ChatCompletionChunk, + ChatCompletionMessageFunctionToolCall, + ChatCompletionMessageParam +} from 'openai/resources/index.mjs' + +type ChatCompletionDeltaWithReasoning = ChatCompletionChunk.Choice.Delta & { + reasoning_content?: string | null +} + +type ChatCompletionAssistantMessageWithReasoning = ChatCompletionMessageParam & { + role: 'assistant' + reasoning_content?: string +} + +export type ReasoningContentState = { + hasReasoningContent: boolean + reasoningContent: string +} + +export function getReasoningContentDelta( + delta: ChatCompletionChunk.Choice.Delta +): string | null | undefined { + return (delta as ChatCompletionDeltaWithReasoning).reasoning_content +} + +export function buildAssistantTextMessage( + content: string +): ChatCompletionAssistantMessageWithReasoning { + return { + role: 'assistant', + content + } +} + +export function buildAssistantToolCallMessage({ + content, + reasoning, + toolCalls +}: { + content: string + reasoning: ReasoningContentState + toolCalls: ChatCompletionMessageFunctionToolCall[] +}): ChatCompletionAssistantMessageWithReasoning { + return { + role: 'assistant', + ...(content || reasoning.hasReasoningContent ? { content } : {}), + ...(reasoning.hasReasoningContent ? { reasoning_content: reasoning.reasoningContent } : {}), + tool_calls: toolCalls + } +} diff --git a/frontend/src/lib/components/copilot/lib.test.ts b/frontend/src/lib/components/copilot/lib.test.ts index a559401068..f1eec17cbb 100644 --- a/frontend/src/lib/components/copilot/lib.test.ts +++ b/frontend/src/lib/components/copilot/lib.test.ts @@ -1,6 +1,23 @@ +import type { + ChatCompletionChunk, + ChatCompletionMessageFunctionToolCall, + ChatCompletionMessageParam +} from 'openai/resources/index.mjs' import { describe, expect, it } from 'vitest' +import { + buildAssistantTextMessage, + buildAssistantToolCallMessage, + getReasoningContentDelta +} from './chat/openaiReasoning' import { getDefaultChatTemperature, modelDisallowsSamplingParams } from './modelConfig' +type AssistantMessageWithReasoning = ChatCompletionMessageParam & { + role: 'assistant' + content?: string + reasoning_content?: string + tool_calls?: ChatCompletionMessageFunctionToolCall[] +} + describe('modelConfig', () => { it('flags Opus 4.7 model IDs via includes matching', () => { expect(modelDisallowsSamplingParams('claude-opus-4-7')).toBe(true) @@ -25,3 +42,76 @@ describe('modelConfig', () => { expect(getDefaultChatTemperature({ provider: 'anthropic', model: 'claude-sonnet-4-6' })).toBe(0) }) }) + +describe('openaiReasoning', () => { + it('reads provider-specific reasoning_content deltas', () => { + expect( + getReasoningContentDelta({ + reasoning_content: 'thinking' + } as ChatCompletionChunk.Choice.Delta & { reasoning_content: string }) + ).toBe('thinking') + }) + + it('preserves DeepSeek reasoning_content on assistant tool-call messages', () => { + const toolCalls: ChatCompletionMessageFunctionToolCall[] = [ + { + id: 'call_1', + type: 'function', + function: { + name: 'lookup', + arguments: '{"query":"docs"}' + } + } + ] + + const assistantMessage = buildAssistantToolCallMessage({ + content: 'I will look that up.', + reasoning: { + hasReasoningContent: true, + reasoningContent: 'First, I need a lookup.' + }, + toolCalls + }) as AssistantMessageWithReasoning + + expect(assistantMessage).toMatchObject({ + role: 'assistant', + content: 'I will look that up.', + reasoning_content: 'First, I need a lookup.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'lookup', + arguments: '{"query":"docs"}' + } + } + ] + }) + }) + + it('does not preserve reasoning_content on text-only assistant messages', () => { + expect(buildAssistantTextMessage('done')).toEqual({ + role: 'assistant', + content: 'done' + }) + }) + + it('keeps empty reasoning_content when the provider emitted the field', () => { + const assistantMessage = buildAssistantToolCallMessage({ + content: '', + reasoning: { + hasReasoningContent: true, + reasoningContent: '' + }, + toolCalls: [] + }) as AssistantMessageWithReasoning + + expect(assistantMessage).toMatchObject({ + role: 'assistant', + content: '', + reasoning_content: '', + tool_calls: [] + }) + }) +}) diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index e98ff035db..f919103400 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -31,6 +31,11 @@ import { openAICompletionsUsageToChatTokenUsage, type ChatTokenUsage } from './chat/tokenUsage' +import { + buildAssistantTextMessage, + buildAssistantToolCallMessage, + getReasoningContentDelta +} from './chat/openaiReasoning' export const SUPPORTED_LANGUAGES = new Set(Object.keys(GEN_CONFIG.prompts)) @@ -960,6 +965,9 @@ export async function parseOpenAICompletion( let tokenUsage = emptyChatTokenUsage() let answer = '' + let assistantContent = '' + let reasoningContent = '' + let hasReasoningContent = false for await (const chunk of completion) { if ('usage' in chunk && chunk.usage) { tokenUsage = openAICompletionsUsageToChatTokenUsage(chunk.usage) @@ -968,9 +976,11 @@ export async function parseOpenAICompletion( continue } const c = chunk as ChatCompletionChunk + const choice = c.choices[0] + const delta = choice.delta // Check for malformed function call error (e.g. from Gemini models) - const finishReason = c.choices[0].finish_reason + const finishReason = choice.finish_reason if ( finishReason && typeof finishReason === 'string' && @@ -979,12 +989,19 @@ export async function parseOpenAICompletion( malformedFunctionCallError = true } - const delta = c.choices[0].delta.content - if (delta) { - answer += delta - callbacks.onNewToken(delta) + const reasoningDelta = getReasoningContentDelta(delta) + if (typeof reasoningDelta === 'string') { + hasReasoningContent = true + reasoningContent += reasoningDelta } - const toolCalls = c.choices[0].delta.tool_calls || [] + + const contentDelta = delta.content + if (contentDelta) { + answer += contentDelta + assistantContent += contentDelta + callbacks.onNewToken(contentDelta) + } + const toolCalls = delta.tool_calls || [] if (toolCalls.length > 0 && answer) { // if tool calls are present but we have some textual content already, we need to display it to the user first callbacks.onMessageEnd() @@ -1059,8 +1076,12 @@ export async function parseOpenAICompletion( } } - if (answer) { - const toAdd = { role: 'assistant' as const, content: answer } + const toolCalls = Object.values(finalToolCalls).filter( + (toolCall) => toolCall.id !== undefined && toolCall.function?.arguments !== undefined + ) as ChatCompletionMessageFunctionToolCall[] + + if (answer && toolCalls.length === 0) { + const toAdd = buildAssistantTextMessage(answer) addedMessages.push(toAdd) messages.push(toAdd) } @@ -1074,21 +1095,22 @@ export async function parseOpenAICompletion( } } - const toolCalls = Object.values(finalToolCalls).filter( - (toolCall) => toolCall.id !== undefined && toolCall.function?.arguments !== undefined - ) as ChatCompletionMessageFunctionToolCall[] - if (toolCalls.length > 0) { - const toAdd = { - role: 'assistant' as const, - tool_calls: toolCalls.map((t) => ({ - ...t, - function: { - ...t.function, - arguments: t.function.arguments || '{}' - } - })) - } + const normalizedToolCalls = toolCalls.map((t) => ({ + ...t, + function: { + ...t.function, + arguments: t.function.arguments || '{}' + } + })) + const toAdd = buildAssistantToolCallMessage({ + content: assistantContent, + reasoning: { + hasReasoningContent, + reasoningContent + }, + toolCalls: normalizedToolCalls + }) messages.push(toAdd) addedMessages.push(toAdd) for (const toolCall of toolCalls) { From 156eb0b045171e8d6990af9eeab752071bf7097b Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 18 May 2026 18:45:19 +0200 Subject: [PATCH 152/313] fix: resolve absolute-path imports in monaco ts editor (#9213) * fix: resolve absolute-path imports in monaco ts editor * fix: dispose absolute-path extra libs on editor teardown and reset * fix: skip late ata local-file callbacks after editor teardown --- frontend/src/lib/ata/index.ts | 2 +- frontend/src/lib/components/Editor.svelte | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/ata/index.ts b/frontend/src/lib/ata/index.ts index 4f5c585900..004fe7f6df 100644 --- a/frontend/src/lib/ata/index.ts +++ b/frontend/src/lib/ata/index.ts @@ -136,7 +136,7 @@ export const setupTypeAcquisition = (config: ATABootstrapConfig) => { : '/' + config.scriptPath + (f.raw.startsWith('../') ? '/../' : '/.') + f.raw let url = config.root + path let localPath = f.raw - if (f.raw.startsWith('.') && !f.raw.endsWith('.ts')) { + if ((f.raw.startsWith('.') || f.raw.startsWith('/')) && !f.raw.endsWith('.ts')) { url += '.ts' localPath += '.ts' } diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 352e963080..ebbf492147 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -204,6 +204,7 @@ let lastWsAttempt: Date = new Date() let nbWsAttempt = 0 let disposeMethod: (() => void) | undefined + const absolutePathExtraLibs = new Map void }>() const dispatch = createEventDispatcher() // let graphqlService: MonacoGraphQLAPI | undefined = undefined @@ -1644,6 +1645,8 @@ (scriptLang == 'bun' || scriptLang == 'tsx' || scriptLang == 'bunnative') && ata == undefined ) { + absolutePathExtraLibs.forEach((d) => d.dispose()) + absolutePathExtraLibs.clear() const hostname = getHostname() const addLibraryToRuntime = async (code: string, _path: string) => { @@ -1659,12 +1662,17 @@ } const addLocalFile = async (code: string, _path: string) => { + if (destroyed) return let p = new URL(_path, uri).href - // if (_path?.startsWith('/')) { - // p = 'file://' + p - // } let nuri = mUri.parse(p) console.log('adding local file', _path, nuri.toString()) + // Monaco's TS service resolves relative imports against the importer's URI (finding the + // model), but absolute paths like "/u/admin/foo" are looked up as raw paths and miss the + // `file://` model. Register them as extra libs so TS can resolve them. + if (_path.startsWith('/')) { + absolutePathExtraLibs.get(_path)?.dispose() + absolutePathExtraLibs.set(_path, typescriptDefaults.addExtraLib(code, _path)) + } if (editor) { let localModel = meditor.getModel(nuri) if (localModel) { @@ -1783,6 +1791,8 @@ timeoutModel && clearTimeout(timeoutModel) loadTimeout && clearTimeout(loadTimeout) aiChatEditorHandler?.clear() + absolutePathExtraLibs.forEach((d) => d.dispose()) + absolutePathExtraLibs.clear() }) async function genRoot(hostname: string) { From 2e05bdd73a664ddeec513653ab74e8c696ea1cfd Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Mon, 18 May 2026 19:43:53 +0200 Subject: [PATCH 153/313] feat: show job status in favicon on the run page (#9206) * feat: show job status in favicon on the run page * test: cover getJobStatusKind favicon status mapping * chore: remove favicon unit tests Co-authored-by: Diego Imbert Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Diego Imbert Co-authored-by: Claude Opus 4.7 --- frontend/src/lib/favicon.ts | 54 +++++++++++++++++++ .../(root)/(logged)/run/[...run]/+page.svelte | 12 ++++- 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/favicon.ts diff --git a/frontend/src/lib/favicon.ts b/frontend/src/lib/favicon.ts new file mode 100644 index 0000000000..ebfd48b76d --- /dev/null +++ b/frontend/src/lib/favicon.ts @@ -0,0 +1,54 @@ +import type { Job } from '$lib/gen' + +export type JobStatusKind = 'running' | 'success' | 'failure' + +const STATUS_COLORS: Record = { + running: '#eab308', + success: '#22c55e', + failure: '#ef4444' +} + +const DEFAULT_FAVICON = '/logo.svg' + +// Inlined windmill logo polygons (fills inlined so the SVG is self-contained as a data URI). +const LOGO_POLYGONS = + '' + + '' + + '' + + '' + + '' + + '' + +function faviconLink(): HTMLLinkElement { + let link = document.querySelector('link[rel="icon"]') + if (!link) { + link = document.createElement('link') + link.rel = 'icon' + document.head.appendChild(link) + } + return link +} + +/** Maps a job to one of the three favicon status colors, following the same + * discrimination as JobStatusIcon (`'success' in job` => completed). */ +export function getJobStatusKind(job: Job | undefined): JobStatusKind | undefined { + if (!job) return undefined + if ('success' in job) return job.success ? 'success' : 'failure' + if (job.canceled) return 'failure' + return 'running' +} + +export function setStatusFavicon(status: JobStatusKind): void { + const color = STATUS_COLORS[status] + const svg = + '' + + `${LOGO_POLYGONS}` + + '' + + `` + + '' + faviconLink().href = `data:image/svg+xml,${encodeURIComponent(svg)}` +} + +export function resetFavicon(): void { + faviconLink().href = DEFAULT_FAVICON +} diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index e31d99f6b3..05b53ece63 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -77,7 +77,8 @@ import ExecutionDuration from '$lib/components/ExecutionDuration.svelte' import { isWindmillTooBigObject } from '$lib/components/job_args' import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte' - import { setContext, untrack } from 'svelte' + import { onDestroy, setContext, untrack } from 'svelte' + import { getJobStatusKind, resetFavicon, setStatusFavicon } from '$lib/favicon' import FlowAssetsHandler, { initFlowGraphAssetsCtx @@ -391,6 +392,15 @@ $effect(() => { job && untrack(() => onJobLoaded()) }) + $effect(() => { + const status = getJobStatusKind(job) + if (status) { + setStatusFavicon(status) + } else { + resetFavicon() + } + }) + onDestroy(resetFavicon) From f965512c7a9aca32c252ca0cda7ec00ab08a38e0 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Mon, 18 May 2026 23:36:52 +0200 Subject: [PATCH 154/313] feat: add global ask user question tool (#9217) * feat: add global ask user question tool * feat: add keyboard navigation to user questions * feat: simplify ask user question answers * fix: disable strict mode for optional tool schemas * fix: scope ask question keyboard events * fix: clean up ask question display state --- .../copilot/chat/AIChatManager.svelte.ts | 47 +++- .../chat/AskUserQuestionDisplay.svelte | 85 ++++++++ .../copilot/chat/ToolExecutionDisplay.svelte | 205 ++++++++++-------- .../copilot/chat/global/core.test.ts | 43 +++- .../components/copilot/chat/global/core.ts | 67 ++++++ .../components/copilot/chat/shared.test.ts | 33 +++ .../src/lib/components/copilot/chat/shared.ts | 63 +++++- .../src/lib/components/home/ItemsList.svelte | 2 +- 8 files changed, 444 insertions(+), 101 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index a4b873795b..0ec6d725e8 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -142,6 +142,7 @@ class AIChatManager { cachedDatatables = $state([]) private confirmationCallback = $state<((value: boolean) => void) | undefined>(undefined) + private userQuestionCallbacks = new Map void>() private appDatatablesRefreshTimeout: ReturnType | undefined = undefined allowedModes: Record = $derived({ @@ -228,6 +229,40 @@ class AIChatManager { } } + requestUserQuestion = ( + toolId: string, + _question: { question: string; choices: string[] } + ): Promise => { + return new Promise((resolve) => { + this.userQuestionCallbacks.set(toolId, resolve) + }) + } + + handleUserQuestionAnswer = (toolId: string, choice: string) => { + const callback = this.userQuestionCallbacks.get(toolId) + if (!callback) { + return + } + + this.displayMessages = this.displayMessages.map((message) => { + if (message.role === 'tool' && message.tool_call_id === toolId && message.userQuestion) { + return { + ...message, + content: `User answered question: ${choice}`, + isLoading: false, + userQuestion: { + ...message.userQuestion, + selectedChoice: choice + } + } + } + return message + }) + + callback(choice) + this.userQuestionCallbacks.delete(toolId) + } + setAiChatInput(aiChatInput: AIChatInput | null) { this.aiChatInput = aiChatInput } @@ -838,7 +873,8 @@ class AIChatManager { this.displayMessages = [...this.displayMessages] } }, - requestConfirmation: this.requestConfirmation + requestConfirmation: this.requestConfirmation, + requestUserQuestion: this.requestUserQuestion } } @@ -869,6 +905,10 @@ class AIChatManager { this.confirmationCallback(false) this.confirmationCallback = undefined } + for (const resolveQuestion of this.userQuestionCallbacks.values()) { + resolveQuestion(undefined) + } + this.userQuestionCallbacks.clear() const cancelReason = reason ?? 'user_cancelled' console.log('cancelling request:', { reason: cancelReason, @@ -1207,7 +1247,10 @@ class AIChatManager { ...message, isLoading: false, content: messageText, - error: messageText + error: messageText, + userQuestion: message.userQuestion + ? { ...message.userQuestion, canceled: true } + : undefined } } return message diff --git a/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte new file mode 100644 index 0000000000..29481a04f2 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte @@ -0,0 +1,85 @@ + + +
+
+ +

{userQuestion.question}

+
+ +
+ {#each userQuestion.choices as choice, index (index)} + + {/each} +
+
diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index 695fc41d72..2e1140663d 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -6,6 +6,7 @@ import { twMerge } from 'tailwind-merge' import ToolContentDisplay from './ToolContentDisplay.svelte' import ToolMessageActions from './ToolMessageActions.svelte' + import AskUserQuestionDisplay from './AskUserQuestionDisplay.svelte' interface Props { message: ToolDisplayMessage @@ -28,111 +29,125 @@ ? message.actions : [] ) + + const activeUserQuestion = $derived( + message.userQuestion && + message.isLoading && + !message.error && + !message.userQuestion.selectedChoice && + !message.userQuestion.canceled + ? message.userQuestion + : undefined + ) -
- - + {#if message.isLoading && !message.needsConfirmation} + + {:else if message.error} + + {:else if !message.isLoading && !message.error} + + {/if} + + {message.content} + +
+ - - {#if isExpanded} -
- - {#if hasParameters || message.needsConfirmation} -
- -
- {/if} + + {#if isExpanded} +
+ + {#if hasParameters || message.needsConfirmation} +
+ +
+ {/if} - - {#if message.needsConfirmation} -
- - -
+ + +
- - {:else if !message.isStreamingArguments} - - - {#if visibleActions.length > 0} - - {:else} + + {:else if !message.isStreamingArguments} + + {#if visibleActions.length > 0} + + {:else} + + {/if} {/if} - {/if} -
- {/if} -
+
+ {/if} +
+{/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 2732db4dac..eb734b60f2 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -70,12 +70,16 @@ function getGlobalTool(name: string): Tool<{}> { return tool } -async function callGlobalTool(name: string, args: Record): Promise { +async function callGlobalTool( + name: string, + args: Record, + callbacks: ToolCallbacks = toolCallbacks +): Promise { return getGlobalTool(name).fn({ args, workspace: WORKSPACE, helpers: {}, - toolCallbacks, + toolCallbacks: callbacks, toolId: `test-${name}` }) } @@ -198,4 +202,39 @@ describe('global AI tools', () => { }) expect(item.value.value).toBeUndefined() }) + + it('asks the user a multiple-choice question and returns the selected answer', async () => { + const callbacks: ToolCallbacks = { + setToolStatus: vi.fn(), + removeToolStatus: vi.fn(), + requestUserQuestion: vi.fn(async (_toolId, question) => question.choices[1]) + } + + const raw = await callGlobalTool( + 'askUserQuestion', + { + question: 'Which script language should be used?', + choices: ['bun', 'python3'] + }, + callbacks + ) + + expect(raw).toBe('python3') + expect(callbacks.requestUserQuestion).toHaveBeenCalledWith( + 'test-askUserQuestion', + expect.objectContaining({ + question: 'Which script language should be used?', + choices: ['bun', 'python3'] + }) + ) + expect(callbacks.setToolStatus).toHaveBeenLastCalledWith( + 'test-askUserQuestion', + expect.objectContaining({ + content: 'User answered question: python3', + isLoading: false, + result: 'python3', + userQuestion: expect.objectContaining({ selectedChoice: 'python3' }) + }) + ) + }) }) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 210636e43d..61e2549a0d 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -111,6 +111,18 @@ const getInstructionsSchema = z.object({ ) }) +const askUserQuestionSchema = z.object({ + question: z + .string() + .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.')) + .min(2) + .max(6) + .describe('Two to six mutually exclusive answer strings.') +}) + const listWorkspaceItemsSchema = z.object({ types: z .array(itemTypeSchema) @@ -469,6 +481,7 @@ Important rules: - 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. @@ -1205,6 +1218,60 @@ export const globalTools: Tool<{}>[] = [ return getInstructions(parsed.subject, parsed.language) } }, + { + def: createToolDef( + askUserQuestionSchema, + 'askUserQuestion', + 'Ask the user a multiple-choice question and wait for their selection before continuing.' + ), + fn: async ({ args, toolId, toolCallbacks }) => { + const parsed = askUserQuestionSchema.parse(args) + const userQuestion = { + question: parsed.question, + choices: parsed.choices + } + + toolCallbacks.setToolStatus(toolId, { + content: parsed.question, + userQuestion, + isLoading: true + }) + + if (!toolCallbacks.requestUserQuestion) { + const message = 'This chat context cannot ask interactive questions.' + toolCallbacks.setToolStatus(toolId, { + content: message, + userQuestion: { ...userQuestion, canceled: true }, + isLoading: false, + error: message + }) + return JSON.stringify({ success: false, error: message }) + } + + const selectedChoice = await toolCallbacks.requestUserQuestion(toolId, userQuestion) + if (!selectedChoice) { + const message = 'Question cancelled by user' + toolCallbacks.setToolStatus(toolId, { + content: message, + userQuestion: { ...userQuestion, canceled: true }, + isLoading: false, + error: message + }) + return JSON.stringify({ success: false, error: message }) + } + + toolCallbacks.setToolStatus(toolId, { + content: `User answered question: ${selectedChoice}`, + userQuestion: { + ...userQuestion, + selectedChoice + }, + result: selectedChoice, + isLoading: false + }) + return selectedChoice + } + }, { def: createToolDef( listWorkspaceItemsSchema, diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index 1cb0b3edc0..5cfe98a204 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -68,6 +68,39 @@ describe('createToolDef', () => { expect(parameters?.properties?.config?.anyOf?.length).toBeGreaterThan(1) }) + it('disables strict mode for schemas with optional properties', async () => { + const { createToolDef } = await import('./shared') + const toolDef = createToolDef( + z.object({ + subject: z.string(), + language: z.string().optional() + }), + 'get_instructions', + 'Get instructions' + ) + + const parameters = toolDef.function.parameters as any + expect(toolDef.function.strict).toBe(false) + expect(parameters.required).toEqual(['subject']) + expect(parameters.properties.language.type).toBe('string') + }) + + it('keeps strict mode for schemas without optional properties', async () => { + const { createToolDef } = await import('./shared') + const toolDef = createToolDef( + z.object({ + question: z.string(), + choices: z.array(z.string()) + }), + 'askUserQuestion', + 'Ask a question' + ) + + const parameters = toolDef.function.parameters as any + expect(toolDef.function.strict).toBe(true) + expect(parameters.required).toEqual(['question', 'choices']) + }) + it('does not expose runnable target fields on workspace mutation tools', async () => { const { createWorkspaceMutationTools } = await import('./workspaceTools') const [scheduleTool, triggerTool] = createWorkspaceMutationTools() diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index d29f9fc0ee..9e60d9fc99 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -485,6 +485,13 @@ export type CreatedResourceAction = { export type ToolDisplayAction = CreatedResourceAction +export type UserQuestionDisplay = { + question: string + choices: string[] + selectedChoice?: string + canceled?: boolean +} + export type ToolDisplayMessage = { role: 'tool' tool_call_id: string @@ -500,6 +507,7 @@ export type ToolDisplayMessage = { toolName?: string showFade?: boolean actions?: ToolDisplayAction[] + userQuestion?: UserQuestionDisplay } export type AssistantDisplayMessage = BaseDisplayMessage & { @@ -684,6 +692,10 @@ export interface ToolCallbacks { setToolStatus: (id: string, metadata?: Partial) => void removeToolStatus: (id: string) => void requestConfirmation?: (toolId: string) => Promise + requestUserQuestion?: ( + toolId: string, + question: UserQuestionDisplay + ) => Promise } export function createToolDef( @@ -697,11 +709,12 @@ export function createToolDef( delete parameters.$schema if (!parameters.required) parameters.required = [] normalizeToolParameterSchema(parameters) + const effectiveStrict = strict && !hasOptionalProperties(parameters) return { type: 'function', function: { - strict, + strict: effectiveStrict, name, description, parameters @@ -709,6 +722,54 @@ export function createToolDef( } } +function hasOptionalProperties(schema: Record | undefined): boolean { + if (!schema || typeof schema !== 'object') { + return false + } + + if (schema.properties && typeof schema.properties === 'object') { + const required = new Set(Array.isArray(schema.required) ? schema.required : []) + const propertyKeys = Object.keys(schema.properties) + if (propertyKeys.some((key) => !required.has(key))) { + return true + } + for (const key of propertyKeys) { + if (hasOptionalProperties(schema.properties[key])) { + return true + } + } + } + + if (schema.items) { + if (Array.isArray(schema.items)) { + if (schema.items.some((item) => hasOptionalProperties(item))) { + return true + } + } else if (hasOptionalProperties(schema.items)) { + return true + } + } + + if ( + schema.additionalProperties && + typeof schema.additionalProperties === 'object' && + hasOptionalProperties(schema.additionalProperties) + ) { + return true + } + + for (const key of ['allOf', 'anyOf', 'oneOf']) { + if ( + Array.isArray(schema[key]) && + schema[key].some((subSchema: Record) => hasOptionalProperties(subSchema)) + ) { + return true + } + } + + return false +} + const searchHubScriptsSchema = z.object({ query: z .string() diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index 0248ceda1d..d1950697f9 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -492,7 +492,7 @@ } const skipSelector = - '[role="menu"], [role="menuitem"], [role="dialog"], [role="listbox"], [role="combobox"], [aria-expanded="true"], [data-menu]' + '[role="menu"], [role="menuitem"], [role="dialog"], [role="listbox"], [role="combobox"], [aria-expanded="true"], [data-menu], [data-chat-keyboard-scope]' if (target) { const tag = target.tagName const isEditable = From 4313225c7d0ee3d2e269f9cac2e448e38da36bd9 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 19 May 2026 00:32:49 +0200 Subject: [PATCH 155/313] oauth: add docusign provider (#9155) Adds the Docusign Authorization Code OAuth entry. Used by the Docusign integration in the windmill-integrations hub (PR #128). Co-authored-by: Claude Opus 4.7 --- backend/oauth_connect.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index d57e700c0d..c9693b2311 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -170,5 +170,12 @@ "full_api_access" ], "extra_params": {} + }, + "docusign": { + "auth_url": "https://account.docusign.com/oauth/auth", + "token_url": "https://account.docusign.com/oauth/token", + "scopes": [ + "signature" + ] } } From 29f4bada11f9a43f19b2b72dffec90e20d962c85 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 19 May 2026 00:33:12 +0200 Subject: [PATCH 156/313] chore: watch WIN and GIT teams in webmux linear integration (#9215) --- .webmux.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.webmux.yaml b/.webmux.yaml index e00435465d..19a0ea9c30 100644 --- a/.webmux.yaml +++ b/.webmux.yaml @@ -106,3 +106,4 @@ integrations: dir: ../windmill-ee-private__worktrees linear: enabled: true + watchTeams: [WIN,GIT] From 49ebf6f8ba0ea55ea7987f40ecdd32738241a3f2 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Tue, 19 May 2026 00:35:08 +0200 Subject: [PATCH 157/313] feat: add global chat selected context (#9216) * feat: add global chat selected context * refactor: store workspace context as references * fix: refresh db context after global mode --- .../copilot/chat/AIChatInput.svelte | 8 +- .../copilot/chat/AIChatManager.svelte.ts | 11 ++- .../copilot/chat/AvailableContextList.svelte | 81 +++++++------------ .../copilot/chat/ContextManager.svelte.ts | 23 +++++- .../copilot/chat/ContextManager.test.ts | 79 ++++++++++++++++++ .../lib/components/copilot/chat/context.ts | 13 ++- .../copilot/chat/global/core.test.ts | 34 +++++++- .../components/copilot/chat/global/core.ts | 23 +++++- .../components/copilot/chat/shared.test.ts | 29 +++++++ .../src/lib/components/copilot/chat/shared.ts | 18 ++--- 10 files changed, 236 insertions(+), 83 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/ContextManager.test.ts diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index 833649e9b6..f0fc826243 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -90,7 +90,11 @@ let appTooltipCurrentViewNumber = $state(0) export function focusInput() { - if (aiChatManager.mode === AIMode.SCRIPT || aiChatManager.mode === AIMode.FLOW) { + if ( + aiChatManager.mode === AIMode.SCRIPT || + aiChatManager.mode === AIMode.FLOW || + aiChatManager.mode === AIMode.GLOBAL + ) { contextTextareaComponent?.focus() } else { instructionsTextareaComponent?.focus() @@ -390,7 +394,7 @@
- {#if aiChatManager.mode === AIMode.SCRIPT || aiChatManager.mode === AIMode.FLOW} + {#if aiChatManager.mode === AIMode.SCRIPT || aiChatManager.mode === AIMode.FLOW || aiChatManager.mode === AIMode.GLOBAL} {#if showContext}
diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 0ec6d725e8..ddef347a7f 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -573,7 +573,7 @@ class AIChatManager { } else if (this.mode === AIMode.NAVIGATOR) { return prepareNavigatorUserMessage(pendingPrompt) } else if (this.mode === AIMode.GLOBAL) { - return prepareGlobalUserMessage(pendingPrompt) + return prepareGlobalUserMessage(pendingPrompt, this.contextManager.getSelectedContext()) } return undefined }, @@ -750,7 +750,7 @@ class AIChatManager { role: 'user', content: this.instructions, contextElements: - this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW + this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW || this.mode === AIMode.GLOBAL ? oldSelectedContext : undefined, snapshot, @@ -790,7 +790,7 @@ class AIChatManager { userMessage = prepareApiUserMessage(oldInstructions) break case AIMode.GLOBAL: - userMessage = prepareGlobalUserMessage(oldInstructions) + userMessage = prepareGlobalUserMessage(oldInstructions, oldSelectedContext) break case AIMode.APP: userMessage = prepareAppUserMessage( @@ -1035,6 +1035,11 @@ class AIChatManager { !copilotSessionModel?.model.endsWith('/thinking'), untrack(() => this.contextManager.getSelectedContext()) ) + } else if (this.mode === AIMode.GLOBAL) { + this.contextManager.updateAvailableContextForGlobal( + workspaceStore ?? '', + untrack(() => this.contextManager.getSelectedContext()) + ) } if (this.scriptEditorOptions) { diff --git a/frontend/src/lib/components/copilot/chat/AvailableContextList.svelte b/frontend/src/lib/components/copilot/chat/AvailableContextList.svelte index b66ab9f14f..fb914ecd0b 100644 --- a/frontend/src/lib/components/copilot/chat/AvailableContextList.svelte +++ b/frontend/src/lib/components/copilot/chat/AvailableContextList.svelte @@ -3,7 +3,7 @@ import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte' import type { FlowModule } from '$lib/gen/types.gen' import { workspaceStore } from '$lib/stores' - import { workspaceRunnablesSearch, MAX_RUNNABLE_CONTENT_LENGTH } from './shared' + import { workspaceRunnablesSearch } from './shared' import { ContextIconMap, type ContextElement, @@ -167,54 +167,31 @@ }, 300) } - async function handleWorkspaceItemSelect(path: string) { - const workspace = $workspaceStore - if (!workspace || !onSelectWorkspaceItem) return + function handleWorkspaceItemSelect(item: { path: string; summary?: string }) { + if (!onSelectWorkspaceItem) return - try { - if (currentView === 'scripts') { - const script = await workspaceRunnablesSearch.getScript(path, workspace) - const content = script.content ?? '' - const truncatedContent = - content.length > MAX_RUNNABLE_CONTENT_LENGTH - ? content.slice(0, MAX_RUNNABLE_CONTENT_LENGTH) + '\n... (truncated)' - : content - const element: WorkspaceScriptElement & { deletable: boolean } = { - type: 'workspace_script', - path: script.path, - title: script.path, - summary: script.summary, - language: script.language, - content: truncatedContent, - schema: script.schema, - deletable: true - } - onSelectWorkspaceItem(element) - } else if (currentView === 'flows') { - const flow = await workspaceRunnablesSearch.getFlow(path, workspace) - const flowValue = JSON.stringify(flow.value, null, 2) - const truncatedValue = - flowValue.length > MAX_RUNNABLE_CONTENT_LENGTH - ? flowValue.slice(0, MAX_RUNNABLE_CONTENT_LENGTH) + '\n... (truncated)' - : flowValue - const element: WorkspaceFlowElement & { deletable: boolean } = { - type: 'workspace_flow', - path: flow.path, - title: flow.path, - summary: flow.summary, - description: flow.description || '', - value: truncatedValue, - schema: flow.schema, - deletable: true - } - onSelectWorkspaceItem(element) + if (currentView === 'scripts') { + const element: WorkspaceScriptElement & { deletable: boolean } = { + type: 'workspace_script', + path: item.path, + title: item.path, + summary: item.summary, + deletable: true } - currentView = 'categories' - workspaceSearchQuery = '' - workspaceSearchResults = [] - } catch (err) { - console.error('Error fetching workspace item', err) + onSelectWorkspaceItem(element) + } else if (currentView === 'flows') { + const element: WorkspaceFlowElement & { deletable: boolean } = { + type: 'workspace_flow', + path: item.path, + title: item.path, + summary: item.summary, + deletable: true + } + onSelectWorkspaceItem(element) } + currentView = 'categories' + workspaceSearchQuery = '' + workspaceSearchResults = [] } function handleKeyDown(e: KeyboardEvent) { @@ -241,7 +218,7 @@ e.stopPropagation() const selectedItem = workspaceSearchResults[itemSelectedIndex] if (selectedItem) { - handleWorkspaceItemSelect(selectedItem.path) + handleWorkspaceItemSelect(selectedItem) } } } else if (e.key === 'Escape') { @@ -358,7 +335,7 @@ > {#if stringSearch.length > 0} - {#each filteredAvailableContext as element, i} + {#each filteredAvailableContext as element, i (element.type + '-' + element.title)} {@const Icon = ContextIconMap[element.type]}
- - {/snippet} - -
-
+ {/if} {#if messages.length === 0} - You can use {getModifierKey()}L to open or close this chat, and {getModifierKey()}K in the - script editor to modify selected lines. + {#if emptyHint} + {@render emptyHint()} + {:else} + You can use {getModifierKey()}L to open or close this chat, and {getModifierKey()}K in the + script editor to modify selected lines. + {/if} {/if} {#if messages.length > 0} -
{ - aiChatManager.disableAutomaticScroll() - }} - > -
- {#each messages as message, messageIndex (messageIndex)} - - {/each} - {#if aiChatManager.loading && !aiChatManager.currentReply && !isLastMessageTool} -
- -
- {/if} +
+
+
+ {#each messages as message, messageIndex (messageIndex)} + + {/each} + {#if showTypingIndicator} +
+ +
+ {/if} +
+ {#if showScrollToLatest} +
+
+ {/if}
{/if} -
0} class="relative"> - {#if aiChatManager.loading} -
- -
- {:else if aiChatManager.flowAiChatHelpers?.hasPendingChanges()} +
+ {#if aiChatManager.flowAiChatHelpers?.hasPendingChanges()}
{/if} -
+
+ {#if inputPreface} + {@render inputPreface()} + {/if} {:else}
- + {#if !hideModeSelector} + + {/if} {#if aiChatManager.mode === AIMode.APP} {/if} @@ -344,8 +427,8 @@ {#each suggestions as suggestion (suggestion)}
From 790987831380611b5bd19a760b0a5433492d7796 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 20 May 2026 14:54:23 +0200 Subject: [PATCH 196/313] feat(chat): waiting-for-user indicator + scroll-to-latest polish (#9252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(chat): waiting-for-user indicator and arrow polish - Show "Waiting for your input" (text-accent + flipping Hourglass) instead of the typing dots when the latest tool is staged for confirmation (Run/Cancel) or has an active askUserQuestion. The dots imply the AI is working, which is misleading when the loop is paused on the user. - Scroll-to-latest arrow: - Move up to bottom-12 when the flow Accept/Reject row is visible so they no longer overlap. - Wrap in a solid bg-surface + shadow + border badge so the icon doesn't bleed into messages behind it. - Bump unifiedSize xs → sm for a slightly larger target. - Hourglass uses a custom CSS keyframe (:global so the rule reaches the Lucide SVG root) with 4 s period and cubic-bezier(0.65, 0, 0.35, 1) easing — feels like flipping the hourglass rather than spinning. * fix(chat): raise waiting indicator above accept/reject row * fix(chat): solid background behind reject all button * feat(chat): @ picker in controls row, badges above input, polish --- .../copilot/chat/AIChatDisplay.svelte | 165 +++++++++++++++--- .../copilot/chat/AIChatInput.svelte | 75 +++----- 2 files changed, 160 insertions(+), 80 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 3e57542dbf..5967a75d30 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -1,10 +1,13 @@ -{#key $tutorialsToDo} - - {#snippet buttonReplacement()} -
diff --git a/frontend/src/lib/components/apps/editor/settingsPanel/common/PanelSection.svelte b/frontend/src/lib/components/apps/editor/settingsPanel/common/PanelSection.svelte index 4fa88a021b..eda78badd8 100644 --- a/frontend/src/lib/components/apps/editor/settingsPanel/common/PanelSection.svelte +++ b/frontend/src/lib/components/apps/editor/settingsPanel/common/PanelSection.svelte @@ -1,6 +1,12 @@ + + @@ -932,6 +969,8 @@ onUndo={handleUndo} onRedo={handleRedo} onOpenYamlEditor={() => yamlEditorDrawer?.openDrawer()} + sidebarCollapsed={sidebarCollapsed.val} + onToggleSidebar={() => (sidebarCollapsed.val = !sidebarCollapsed.val)} /> - - files, - (newFiles) => { - files = newFiles - setFilesInIframe(newFiles ?? {}) + {#if !sidebarCollapsed.val} + + files, + (newFiles) => { + files = newFiles + setFilesInIframe(newFiles ?? {}) + } } - } - onSelectFile={handleSelectFile} - bind:selectedRunnable - bind:selectedDocument - dataTableRefs={dataTableRefsObjects} - onDataTableRefsChange={(newRefs) => { - data.tables = newRefs.map(formatDataTableRef) - saveFrontendDraft() - }} - defaultDatatable={data.datatable} - defaultSchema={data.schema} - onDefaultChange={(datatable, schema) => { - data.datatable = datatable - data.schema = schema - // Also sync to aiChatManager - aiChatManager.datatableCreationPolicy = { - ...aiChatManager.datatableCreationPolicy, - datatable, - schema - } - saveFrontendDraft() - }} - {runnables} - {modules} - {historyManager} - historySelectedId={historyManager.selectedEntryId} - onHistorySelect={handleHistorySelect} - onHistorySelectCurrent={() => { - // Restore the temporary current state if it exists - const tempState = historyManager.getAndClearTemporaryState() - if (tempState) { - applyEntry(tempState) - } - // Clear selection to indicate we're at current state - historyManager.clearSelection() - }} - onManualSnapshot={() => { - historyManager.manualSnapshot(files ?? {}, runnables, summary, data, true) - }} - > - + onSelectFile={handleSelectFile} + bind:selectedRunnable + bind:selectedDocument + dataTableRefs={dataTableRefsObjects} + onDataTableRefsChange={(newRefs) => { + data.tables = newRefs.map(formatDataTableRef) + saveFrontendDraft() + }} + defaultDatatable={data.datatable} + defaultSchema={data.schema} + onDefaultChange={(datatable, schema) => { + data.datatable = datatable + data.schema = schema + // Also sync to aiChatManager + aiChatManager.datatableCreationPolicy = { + ...aiChatManager.datatableCreationPolicy, + datatable, + schema + } + saveFrontendDraft() + }} + {runnables} + {modules} + {historyManager} + historySelectedId={historyManager.selectedEntryId} + onHistorySelect={handleHistorySelect} + onHistorySelectCurrent={() => { + // Restore the temporary current state if it exists + const tempState = historyManager.getAndClearTemporaryState() + if (tempState) { + applyEntry(tempState) + } + // Clear selection to indicate we're at current state + historyManager.clearSelection() + }} + onManualSnapshot={() => { + historyManager.manualSnapshot(files ?? {}, runnables, summary, data, true) + }} + > + + {/if} -
+
+ {/if} +
+
+ +
+ {#if selectedRunnable !== undefined} + { + if (selection === null) { + codeSelection = undefined + } else if (selectedRunnable) { + codeSelection = { + type: 'app_code_selection', + source: selectedRunnable, + sourceType: 'backend', + title: `${selectedRunnable}:L${selection.startLine}-L${selection.endLine}`, + content: selection.content, + startLine: selection.startLine, + endLine: selection.endLine, + startColumn: selection.startColumn, + endColumn: selection.endColumn + } + } + }} + /> + {/if} +
+
+
+
+ + paneBRightSize, (v) => rememberPaneDrag(100 - v)} minSize={0}> +
+ activateTab(id)} + onClose={(id) => closeTab(id)} + onReorder={(next) => reorderTabs(next)} + > + {#snippet trailing()} +
+ + + + +
+ {/snippet} +
+ + {#if logs} +
+ +
+ {#if !logsCollapsed} +
{logs}
+ {/if} +
+
+ {/if} +
+
+ +
- {/if} - -
- - {#if selectedRunnable !== undefined} - -
- { - console.log('handle selection', selection) - - if (selection === null) { - codeSelection = undefined - } else if (selectedRunnable) { - codeSelection = { - type: 'app_code_selection', - source: selectedRunnable, - sourceType: 'backend', - title: `${selectedRunnable}:L${selection.startLine}-L${selection.endLine}`, - content: selection.content, - startLine: selection.startLine, - endLine: selection.endLine, - startColumn: selection.startColumn, - endColumn: selection.endColumn - } - } - }} - /> -
- {/if} -
- - -
- + +
+ + diff --git a/frontend/src/lib/utils/splitpaneSizing.ts b/frontend/src/lib/utils/splitpaneSizing.ts new file mode 100644 index 0000000000..dd5435f85c --- /dev/null +++ b/frontend/src/lib/utils/splitpaneSizing.ts @@ -0,0 +1,9 @@ +/** + * `svelte-splitpanes` only takes percentages, so convert a pixel `minSize` + * threshold into a % of the container's current width (pair with + * `bind:clientWidth`). Capped at `cap`%; returns 0 until the width is known. + */ +export function paneMinPercent(containerWidth: number, minPx: number, cap: number = 80): number { + if (containerWidth <= 0) return 0 + return Math.min(cap, (minPx / containerWidth) * 100) +} From 80f2831a84c00fea792ee27dcd8ebdde9fff79a6 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 25 May 2026 18:12:49 +0200 Subject: [PATCH 255/313] chore(raw_apps): bump bundled ui_builder to b4f6219 (#9314) --- frontend/scripts/ui_builder_artifact.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json index 8b1a4892f6..5685992677 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": "61b6fdd", - "sha256": "d7c316b4429442eed9462756db0fdf13128849b5b9adf4a7b7acc7a26b1a7280" + "version": "b4f6219", + "sha256": "69575342aab968fc32a89d3410c21bf888b0f00ce5c68fe80936d3adc1359b36" } From e218d60919f01600eeb15dc5b943250e0da23ae6 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 25 May 2026 16:13:28 +0000 Subject: [PATCH 256/313] skip workspaced-route duplicate checks on cloud (#9305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(settings): skip workspaced-route duplicate checks on cloud The pre-write validation hooks for `app_workspaced_route` and `http_route_workspaced_route` query the DB for cross-workspace duplicates and fail the save when any are found. On cloud both `custom_path_exists` (apps) and `route_path_key_exists` (HTTP triggers) already scope lookups by `workspace_id` regardless of these settings, so duplicates across workspaces are expected and the validation has no runtime meaning. The result was that any cloud super-admin attempting to save instance settings with these toggles set to false received `Duplicate HTTP route paths detected` even though the setting has no effect on cloud routing. Fixes WIN-1983 Co-Authored-By: Claude Opus 4.7 (1M context) * fix(error): render JsonErr as readable text and return 400 `Error::JsonErr` previously rendered through `#[error("Error: {0:#?}")]`, leaking Rust's `Debug` output (`Object { "error": String(...), "details": Array [...] }`) into the HTTP response body, and was bucketed into the catch-all 500 branch in `IntoResponse`. The result was a 500 status with a wall of Rust debug syntax in the toast — confusing and user-hostile. - Bucket `JsonErr` into 400 (Bad Request): every current call site (workspaced-route duplicate checks, OAuth client errors, etc.) is a client/validation issue, not an internal server fault. - Add `format_json_err_message` which surfaces the `error` field as the headline, summarises `details` (with a `- key=value` per entry), and pretty-prints the rest as JSON for unknown shapes. The frontend toast now reads e.g. Duplicate HTTP route paths detected - route_path=a, workspace_id=admins, http_method=post - route_path=a, workspace_id=starter, http_method=post Co-Authored-By: Claude Opus 4.7 (1M context) * fix(toast): preserve newlines and escape HTML in multi-line errors The toast renders via `{@html processMessage(message)}`, so server-side error bodies that span multiple lines (e.g. the duplicate-route response from the settings endpoint) collapsed into a single line because HTML treats consecutive whitespace (including `\n`) as a single space. When the message contains a newline, escape HTML first (defends against injected markup in server error bodies) and convert `\n` to `
` so multi-line errors stay readable in the toast. Co-Authored-By: Claude Opus 4.7 (1M context) * fixup: address CI review feedback - toast.ts: escape HTML unconditionally. The previous gate on `\n` left single-line server error bodies unsafe under {@html}, which cubic flagged as P0. The path regex below only inserts a `` around a `u/...` or `f/...` capture that can't contain HTML metacharacters, so escaping the whole input is the simpler and correct fix. - error.rs: add unit tests pinning the rendered shape of `format_json_err_message` (error+details, error-only, truncation cap, non-object fallback to pretty JSON). Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- backend/windmill-api-settings/src/lib.rs | 13 ++- backend/windmill-common/src/error.rs | 106 ++++++++++++++++++++++- frontend/src/lib/components/toast.ts | 18 +++- 3 files changed, 132 insertions(+), 5 deletions(-) diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 3078be9818..644973f894 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -63,7 +63,7 @@ use windmill_common::{ server::Smtp, worker::is_cloud_production_host, }; -use windmill_common::{error::to_anyhow, PgDatabase}; +use windmill_common::{error::to_anyhow, worker::CLOUD_HOSTED, PgDatabase}; /// Unauthenticated settings routes. /// @@ -588,7 +588,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 { @@ -651,7 +654,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 { 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/frontend/src/lib/components/toast.ts b/frontend/src/lib/components/toast.ts index 6a68f76f26..d013d20aca 100644 --- a/frontend/src/lib/components/toast.ts +++ b/frontend/src/lib/components/toast.ts @@ -1,12 +1,28 @@ const pathRegex = /\b(u|f)(\/[^\/\s]+){2,}\b/g +function escapeHtml(s: string): string { + return s + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + export function processMessage(message: string | undefined): string { let msg = !message ? 'Error without message' : typeof message != 'string' ? JSON.stringify(message, null, 2) : message - return msg.replaceAll(pathRegex, (path) => { + // Toast renders via {@html}, so escape unconditionally — server error + // bodies can contain arbitrary content (e.g. `<` from a SQL fragment in + // an error message), and the path regex below only ever inserts a safe + // `` around a `u/...` or `f/...` capture that itself cannot match + // any HTML metacharacter. Convert `\n` to `
` so multi-line errors + // stay readable in the toast (without this, HTML collapses newlines). + let html = escapeHtml(msg).replaceAll('\n', '
') + return html.replaceAll(pathRegex, (path) => { return `${path}` }) } From b125eca7628b07c071bd102b161d389259fd6c62 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 25 May 2026 16:18:38 +0000 Subject: [PATCH 257/313] feat(service-accounts): allow choosing role at creation time (#9307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [ee] feat(service-accounts): allow choosing role at creation time Previously, service accounts were hardcoded to operator and could not be used as the CLI sync user since they had no write access. They also only counted as 0.5 seat each. This change: - Extends `NewServiceAccount` to accept optional `is_admin` / `operator` (defaults to `operator=true` for backward compatibility). - Exposes a role picker in `AddUser.svelte` when creating a service account (Operator / Developer / Admin). - Lets admins update a service account's role from the user list (it used to be locked to "Operator" with a tooltip). - Updates the OpenAPI spec + regenerates the frontend client. A developer/admin service account counts as 1 seat under the existing seat-cap logic (operators stay at 0.5). Companion PR on windmill-ee-private updates the `INSERT INTO usr` to honour the chosen role. Fixes WIN-1985 * [ee] feat(service-accounts): wm_deployers opt-in for Dev role When creating a service account with role=Developer, surface a toggle "Add to wm_deployers" (recommended). Members of wm_deployers can deploy on behalf of other users — the typical setup when the service account is used as the CLI sync / CI deploy identity. - `NewServiceAccount` gains an optional `add_to_deployers` flag. - Frontend defaults the toggle to on but only shows it under Developer (admins have it implicitly; operators can't deploy). - Tooltip links to docs.windmill.dev "Run on behalf of". Companion EE PR updates the handler to INSERT into usr_to_group for wm_deployers when the flag is set. Refs WIN-1985 * chore: update ee-repo-ref to 974ed42067d9f63acb42332b671b8c01ffd4b625 This commit updates the EE repository reference after PR #589 was merged in windmill-ee-private. Previous ee-repo-ref: f7dbc3cc2ba21c396f4828881e3b9d9ab6f50c69 New ee-repo-ref: 974ed42067d9f63acb42332b671b8c01ffd4b625 Automated by sync-ee-ref workflow. * [ee] fix(service-accounts): unhardcode role in superadmin user list Two review issues from the merged #9307 / #589: 1. P1 — The global Users tab in #superadmin-settings still pinned every service account to "Operator". Now it shows the actual role (Admin / Operator / Developer), derived from the SA's usr row. - `list_users_as_super_admin`: replaced `true as operator_only` with the real `operator` value, and added `is_workspace_admin` from the row (NULL for password users since their admin status is per-workspace). - `global_whoami`: when the email belongs to a service account, look up its real `operator` / `is_admin` instead of pinning to operator. - `SuperadminSettingsInner.svelte`: drop the hardcoded "Operator" badge; render Admin / Operator / Developer using the new fields, matching the workspace-level view. 2. P2 — Regenerate the bundled `openapi-deref.{yaml,json}` so the `createServiceAccount` body (now exposing `is_admin`, `operator`, `add_to_deployers`) and the new `GlobalUserInfo.is_workspace_admin` field show up at runtime in `/api/openapi.{yaml,json}`. Bumps `ee-repo-ref.txt` to the EE follow-up that adds the offline seat-cap check on `create_service_account`. Refs WIN-1985 * chore: update ee-repo-ref to b7a6068c1f3dc845e012959268b2426f0de4d697 This commit updates the EE repository reference after PR #590 was merged in windmill-ee-private. Previous ee-repo-ref: 0b1307c21d1bfd6fb43a03c2ba39d2a8bf8e6470 New ee-repo-ref: b7a6068c1f3dc845e012959268b2426f0de4d697 Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] --- ...15af008ea7db408f49cf525656d6224092429.json | 28 + ...005305fb3b29751ec987bece183444e89e7d1.json | 100 + ...0b66fb08f156776b96b7f3701aed330a7d000.json | 101 + ...ff27702b8fc8d2850fdb262b9a2a77b468646.json | 101 + ...6a64554a9a29ebc6064718072470e45a0ae28.json | 18 + backend/ee-repo-ref.txt | 2 +- backend/windmill-api-users/src/users.rs | 31 +- .../windmill-api-workspaces/src/workspaces.rs | 10 + backend/windmill-api/openapi-deref.json | 1277 +++++- backend/windmill-api/openapi-deref.yaml | 4057 +++++++++++------ backend/windmill-api/openapi.yaml | 12 + frontend/src/lib/components/AddUser.svelte | 58 +- .../components/SuperadminSettingsInner.svelte | 16 +- .../settings/WorkspaceUserSettings.svelte | 9 +- 14 files changed, 4262 insertions(+), 1558 deletions(-) create mode 100644 backend/.sqlx/query-0cca68f11329cd41ab9372297b715af008ea7db408f49cf525656d6224092429.json create mode 100644 backend/.sqlx/query-1d0341bd8de94ab8d34a4bb1bb2005305fb3b29751ec987bece183444e89e7d1.json create mode 100644 backend/.sqlx/query-8aae160c589adf02e20b7e6ba860b66fb08f156776b96b7f3701aed330a7d000.json create mode 100644 backend/.sqlx/query-bce66e3f3fecda6c226556f2f3cff27702b8fc8d2850fdb262b9a2a77b468646.json create mode 100644 backend/.sqlx/query-dd540bcb206d931eb19aa85059b6a64554a9a29ebc6064718072470e45a0ae28.json 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-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-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/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 1e357752b5..733bf778bf 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -da5189cf69a453de3855057f41be0d84e5910707 +b7a6068c1f3dc845e012959268b2426f0de4d697 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-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/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 64b90fa782..9382716b51 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -2816,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: @@ -26602,6 +26611,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: 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'} +
+ {/if} + {/if}
{:else}
diff --git a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte index 93acba3f28..73f5e37571 100644 --- a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte +++ b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte @@ -848,14 +848,7 @@ >
- {#if user.is_service_account} -
- - Operator - - Service accounts are always operators. -
- {:else if added_via?.source === 'instance_group'} + {#if added_via?.source === 'instance_group'}
{is_admin ? 'Admin' : operator ? 'Operator' : 'Developer'} From 108a88a1801548c8570d56aa3e1eb80246367bf4 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 25 May 2026 16:24:57 +0000 Subject: [PATCH 258/313] fix(jobs): authorization bypass in only_result job updates (WIN-1980) (#9301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(jobs): enforce anonymous-only guard on `only_result` job updates The `jobs_u/getupdate/{id}` and `jobs_u/getupdate_sse/{id}` endpoints accept `only_result=true`. In that branch, `get_job_update_data` queried the result solely by (workspace_id, job_id) and skipped the `created_by == "anonymous"` check that the non-only_result path and adjacent unauthenticated endpoints apply. An unauthenticated requester who learned a private job UUID could therefore retrieve that job's output. Hoist the guard to the top of `get_job_update_data` so both branches are covered. Fixes WIN-1980 Co-Authored-By: Claude Opus 4.7 (1M context) * refactor: fold `created_by` check into existing only_result queries Avoids the extra `SELECT created_by` round-trip per call by joining `v2_job` once in the two queries that handled the unauth path and checking inline. Behavior is identical to the prior commit; the SSE polling loop now does one query per poll instead of two for unauthenticated callers. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor: cache anonymous_verified across SSE polls Replace the LEFT JOIN approach with an upfront `SELECT created_by` guarded by a new `&mut bool anonymous_verified` parameter that mirrors `early_return_suppressed`. The SSE polling loop now performs the auth check exactly once per stream rather than per poll, and the data SQL reverts to its original form so authenticated callers pay no extra cost. `created_by` cannot change after job creation, so caching the verification across polls is safe. Cost matrix: - Authed (any path): 0 extra queries - Unauthed one-shot: 1 extra query (unavoidable) - Unauthed SSE: 1 extra query at stream start, 0 per poll Co-Authored-By: Claude Opus 4.7 (1M context) * refactor: scope anonymous check to only_result branch The non-only_result branch already enforces the `created_by` check via its main query, so a top-level hoisted check duplicated work for unauthenticated default-path callers. Move the check inside the `if only_result.unwrap_or(false)` block — exactly where the bypass lives — and leave the non-only_result path untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- backend/windmill-api/src/jobs.rs | 34 ++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 85f0aedfc3..9d1b29ee48 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -7217,6 +7217,7 @@ async fn get_job_update( None, false, &mut false, + &mut false, ) .await?, )) @@ -7307,6 +7308,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; @@ -7331,6 +7336,7 @@ pub fn start_job_update_sse_stream( early_return.as_deref(), has_failure_module, &mut early_return_suppressed, + &mut anonymous_verified, ) .await { @@ -7451,6 +7457,7 @@ pub fn start_job_update_sse_stream( early_return.as_deref(), has_failure_module, &mut early_return_suppressed, + &mut anonymous_verified, ) .await { @@ -7584,6 +7591,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( @@ -7605,6 +7613,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!( From 90a196d8d81993ffc2377d7088ab98f7b0f5ddcc Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 25 May 2026 20:15:14 +0200 Subject: [PATCH 259/313] feat(raw_apps): surface UI Builder build errors over the preview pane (#9316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(raw_apps): surface UI Builder build errors over the preview pane Companion to the matching change in the UI Builder repo (see linked PR), which stops rendering the build-error overlay over the VS Code editor iframe and instead emits a `buildError` postMessage on every build (message: undefined on success to clear). Listen for that message on the existing window message handler (already source-gated by the UI Builder iframe), store it in a `buildError` $state, and surface it in two places: * A red banner over the preview iframe, sibling to the existing logs overlay (`top-12 left-2 right-2 z-20` so it clears the tab bar) — failures appear right where the user looks for the rendered output. * The Preview tab's icon and label tint red (`text-red-600 dark:text-red-400`, matching the existing error convention in raw_apps) — important in single-tab mode where the preview pane is collapsed to 0px and the banner would be hidden. Done by mapping `leftPaneTabs` / `rightPaneTabs` through a small `tintPreviewOnError` helper so the source-of-truth `tabs` array is untouched (DnD, ordering, fallback selection keep using the original previewTab object). Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(raw_apps): use Alert component for the build-error banner Replace the hand-rolled red div with the shared `Alert` component (`type="error"`, `title="Build failed"`). The error text stays in a `
` child so multi-line bundler output keeps its formatting, with
`max-h-60` so a long error never takes over the whole preview pane.

The absolute-positioned wrapper (`top-12 left-2 right-2 z-20`) and the
`role="alert"` move to that wrapper so the Alert component itself stays
unstyled at the call site.

Co-Authored-By: Claude Opus 4.7 (1M context) 

* fix(raw_apps): solid bg-surface backing behind build-error Alert

The Alert's error background is semi-transparent in dark mode
(`bg-red-900/40` in `common/alert/model.ts`), so the preview iframe
shows through when the banner is laid over it. Add a `::before`
pseudo on the Alert root with `bg-surface` (matched `rounded-md`,
`-z-10` so it sits behind the red bg) to give it a solid plate.

Co-Authored-By: Claude Opus 4.7 (1M context) 

* refactor(raw_apps): isolate banner stacking context, DRY tab tint chain

Two small follow-ups from review:

* Add `isolate` to the build-error banner wrapper so the `before:-z-10`
  pseudo's stacking context is pinned locally — it works today because
  `position: absolute` + `z-20` creates one, but `isolate` makes the
  dependency self-documenting and survives a future refactor that
  removes the explicit `z-20`.
* Extract `tintTabs = (ts) => ts.map(tintPreviewOnError)` so the two
  `$derived` blocks for leftPaneTabs / rightPaneTabs read identically.

Co-Authored-By: Claude Opus 4.7 (1M context) 

* chore(raw_apps): trim build-error overlay comments

Per review feedback. Keep only the load-bearing facts (bg-surface backs
the Alert's translucent red, isolate pins the pseudo stacking, the
`message: undefined` clear convention) and drop the prose context that
duplicated what the code already shows.

Co-Authored-By: Claude Opus 4.7 (1M context) 

* chore(raw_apps): bump bundled ui_builder to 00c9834

Brings in the postMessage emission from
windmill-labs/windmill-code-ui-builder#9 (merged) so this PR's host
listener actually receives `buildError` events. SHA verified against
the R2 artifact.

Co-Authored-By: Claude Opus 4.7 (1M context) 

---------

Co-authored-by: Claude Opus 4.7 (1M context) 
---
 frontend/scripts/ui_builder_artifact.json     |  4 +--
 .../components/raw_apps/RawAppEditor.svelte   | 35 +++++++++++++++++--
 2 files changed, 35 insertions(+), 4 deletions(-)

diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json
index 5685992677..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": "b4f6219",
-	"sha256": "69575342aab968fc32a89d3410c21bf888b0f00ce5c68fe80936d3adc1359b36"
+	"version": "00c9834",
+	"sha256": "5757e5b9cbf79c20d507dc4c588640368e84b873cb48f3806ca8c67fd1aa625f"
 }
diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte
index aa6658b4a9..7249f0ae05 100644
--- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte
+++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte
@@ -6,6 +6,7 @@
 	import RawAppEditorHeader from './RawAppEditorHeader.svelte'
 	import RawAppYamlEditor, { type RawAppYamlUpdate } from './RawAppYamlEditor.svelte'
 	import type Drawer from '../common/drawer/Drawer.svelte'
+	import Alert from '../common/alert/Alert.svelte'
 	import { type Policy, WorkspaceService } from '$lib/gen'
 	import DiffDrawer from '../DiffDrawer.svelte'
 	import { deepEqual } from 'fast-equals'
@@ -195,6 +196,9 @@
 	// them as an overlay inside the preview pane (right side) so they're
 	// visually tied to the build output, not to the source editor.
 	let logs = $state('')
+
+	// Latest UI Builder error; cleared on next successful build.
+	let buildError = $state(undefined)
 	let logsCollapsed = $state(false)
 	let logsDiv: HTMLDivElement | undefined = $state(undefined)
 	$effect(() => {
@@ -229,13 +233,20 @@
 				? 'file'
 				: 'runnable'
 	)
+	// Tint the Preview tab red so the error is visible when Preview isn't active.
+	function tintPreviewOnError(t: TabItem): TabItem {
+		if (t.id !== PREVIEW_TAB_ID || !buildError) return t
+		const errClass = 'text-red-600 dark:text-red-400'
+		return { ...t, iconClass: errClass, labelClass: errClass }
+	}
+	const tintTabs = (ts: TabItem[]) => ts.map(tintPreviewOnError)
 	// Single mode: both bars mirror the full list (the visible pane carries
 	// every tab). Split mode: left = files/runnables, right = Preview only.
 	const leftPaneTabs = $derived(
-		splitWithPreview ? tabs.filter((t) => t.id !== PREVIEW_TAB_ID) : tabs
+		tintTabs(splitWithPreview ? tabs.filter((t) => t.id !== PREVIEW_TAB_ID) : tabs)
 	)
 	const rightPaneTabs = $derived(
-		splitWithPreview ? tabs.filter((t) => t.id === PREVIEW_TAB_ID) : tabs
+		tintTabs(splitWithPreview ? tabs.filter((t) => t.id === PREVIEW_TAB_ID) : tabs)
 	)
 	// In split mode the right bar always highlights Preview, regardless of the
 	// left pane's active file/runnable.
@@ -938,6 +949,12 @@
 			return
 		}
 
+		// `message: undefined` arrives on the next successful build and clears the banner.
+		if (fromUiBuilder && e.data.type === 'buildError') {
+			buildError = typeof e.data.message === 'string' ? e.data.message : undefined
+			return
+		}
+
 		// Inspector events come exclusively from the preview iframe.
 		if (fromPreview && e.data.type === 'inspectorSelect') {
 			inspectorElement = e.data.element as InspectorElementInfo
@@ -1557,6 +1574,20 @@
 									src="/ui_builder/app-preview.html"
 									class="w-full flex-1 block"
 								>
+								{#if buildError}
+									
+									
+								{/if}
 								{#if logs}
 									
 	
- + {#snippet children({ createMenu })} - +
+ - {#each secondaryMenuLinks as menuLink (menuLink.href ?? menuLink.label)} - {#if menuLink.subItems} - {@const notificationsCount = computeAllNotificationsCount(menuLink.subItems)} - - {#snippet triggr({ trigger })} - - {/snippet} + {#each secondaryMenuLinks as menuLink (menuLink.href ?? menuLink.label)} + {#if menuLink.subItems} + {@const notificationsCount = computeAllNotificationsCount(menuLink.subItems)} + + {#snippet triggr({ trigger })} + + {/snippet} - {#snippet children({ item })} - {#each menuLink.subItems as subItem (subItem.href ?? subItem.label)} - { - subItem?.['action']?.() - }} - aiId={subItem.aiId} - aiDescription={subItem.aiDescription} - > -
- {#if subItem.icon} - - {/if} - {subItem.label} - {#if subItem?.['notificationCount']} -
- -
- {/if} -
-
- {/each} - {/snippet} -
- {:else} - - {#snippet children({})} - - {/snippet} - - {/if} - {/each} - {/snippet} - - - - {#snippet children({ createMenu })} - {#each thirdMenuLinks as menuLink (menuLink)} - {#if menuLink.subItems} - - {#snippet triggr({ trigger })} - - {/snippet} - {#snippet children({ item })} - {#each menuLink.subItems as subItem (subItem.href ?? subItem.label)} - -
- {#if subItem.icon} - - {/if} - - {subItem.label} -
-
- {/each} - {#if recentChangelogs.length > 0} -
- Latest changelogs - {#each recentChangelogs as changelog} -
- {changelog.label} + {#if subItem.icon} + + {/if} + {subItem.label} + {#if subItem?.['notificationCount']} +
+ +
+ {/if}
{/each} - {/if} - {/snippet} -
- {/if} - {/each} + {/snippet} +
+ {:else} + + {#snippet children({})} + + {/snippet} + + {/if} + {/each} +
+ +
+ {#each thirdMenuLinks as menuLink (menuLink)} + {#if menuLink.subItems} + + {#snippet triggr({ trigger })} + + {/snippet} + {#snippet children({ item })} + {#each menuLink.subItems as subItem (subItem.href ?? subItem.label)} + +
+ {#if subItem.icon} + + {/if} + + {subItem.label} +
+
+ {/each} + {#if recentChangelogs.length > 0} +
+ Latest changelogs + {#each recentChangelogs as changelog} + +
+ {changelog.label} +
+
+ {/each} + {/if} + {/snippet} +
+ {/if} + {/each} +
{/snippet}
From 4efc37212a98571214aba135b0fbb10dc263fd4f Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 27 May 2026 23:16:21 +0200 Subject: [PATCH 283/313] fix: infer script arg schema when deploying via AI chat (#9356) Co-authored-by: Claude Opus 4.7 (1M context) --- .../lib/components/copilot/chat/global/core.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index dab974fa2f..693ba3072f 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -64,6 +64,7 @@ import { import type { ContextElement } from '../context' import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte' import { emptySchema } from '$lib/utils' +import { inferArgs } from '$lib/infer' import { resourceRequestSchema, scheduleRequestSchema, @@ -2746,10 +2747,16 @@ async function deployDraft( const existing = (await ScriptService.existsScriptByPath({ workspace, path })) ? await ScriptService.getScriptByPath({ workspace, path }) : undefined - await ScriptService.createScript({ - workspace, - requestBody: buildScriptDeployRequestBody(path, draft, existing, deploymentMessage) - }) + const requestBody = buildScriptDeployRequestBody(path, draft, existing, deploymentMessage) + // Infer the arg schema from the content so it matches the code, like the editor does. + try { + const schema = emptySchema() + await inferArgs(requestBody.language, requestBody.content, schema) + requestBody.schema = schema + } catch (e) { + console.error('Failed to infer script schema before deploy', e) + } + await ScriptService.createScript({ workspace, requestBody }) break } case 'flow': { From c2b5ba8871abbbcff6de69c90e2f09fee70586c1 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 28 May 2026 12:31:25 +0200 Subject: [PATCH 284/313] fix(cli): stop re-prompting on wmill refresh prompts (#9357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit referencesIncludeLine required the include token to be the entire trimmed line. The wmill-default CLAUDE.md template is `Instructions are in @AGENTS.md` — include mid-sentence — so the migration prompt fired every run on files wmill itself wrote. Accept the include as a whitespace-separated token on any non-comment line. Co-authored-by: Claude Opus 4.7 (1M context) --- cli/src/guidance/writer.ts | 18 ++++++++++++------ cli/test/guidance_writer_unit.test.ts | 8 +++++++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/cli/src/guidance/writer.ts b/cli/src/guidance/writer.ts index fcaae0671a..033519bf8e 100644 --- a/cli/src/guidance/writer.ts +++ b/cli/src/guidance/writer.ts @@ -207,13 +207,19 @@ async function reconcileIncludingFile(options: { } function referencesIncludeLine(content: string, includeLine: string): boolean { - // Match only when the include sits on a line by itself (allowing leading - // and trailing whitespace). Earlier we split on `\s+`, but that - // false-positives on commented-out includes like `` - // where the middle token equals the include. CRLF is handled by the - // `\r?\n` split. + // Match when the include appears as a whitespace-separated token on any + // line that isn't an HTML comment. We can't require the include to be on a + // line by itself: our own CLAUDE.md default is `Instructions are in + // @AGENTS.md` (one sentence), and a strict equality check made `wmill + // refresh prompts` re-prompt every run on files wmill itself wrote. + // Skipping comment-bearing lines keeps `` from + // false-positiving. for (const line of content.split(/\r?\n/)) { - if (line.trim() === includeLine) { + const trimmed = line.trim(); + if (trimmed.startsWith("")) { + continue; + } + if (trimmed.split(/\s+/).includes(includeLine)) { return true; } } diff --git a/cli/test/guidance_writer_unit.test.ts b/cli/test/guidance_writer_unit.test.ts index fa3a91d641..cbeb494d1c 100644 --- a/cli/test/guidance_writer_unit.test.ts +++ b/cli/test/guidance_writer_unit.test.ts @@ -391,6 +391,13 @@ describe("writeAiGuidanceFiles — referencesAgentsCli (via reconciliation)", () ["between blank lines", "before\n\n@AGENTS.cli.md\n\nafter"], ["leading whitespace then include", " @AGENTS.cli.md\n"], ["CRLF line endings", "line one\r\n@AGENTS.cli.md\r\nline three"], + // Mid-sentence include: this is how our own CLAUDE.md default looks + // ("Instructions are in @AGENTS.md"). A strict line-equality check made + // `wmill refresh prompts` re-prompt every run on files wmill wrote. + ["mid-sentence include", "Instructions are in @AGENTS.cli.md\n"], + // `>` blockquote prefix doesn't disable Claude's `@`-import expansion, + // so we treat it as a reference too. + ["blockquoted include", "> @AGENTS.cli.md"], ])("treats %s as a reference (no append)", async (_label, content) => { await withTempDir(async (tempDir) => { await writeFile(join(tempDir, "AGENTS.md"), content, "utf8"); @@ -406,7 +413,6 @@ describe("writeAiGuidanceFiles — referencesAgentsCli (via reconciliation)", () ["@AGENTS-cli-md (lookalike)", "@AGENTS-cli-md"], ["@AGENTS.cli.md without surrounding whitespace", "foo@AGENTS.cli.md"], ["commented-out include", ""], - ["blockquoted include", "> @AGENTS.cli.md"], ])("does not treat %s as a reference (append happens)", async (_label, content) => { await withTempDir(async (tempDir) => { await writeFile(join(tempDir, "AGENTS.md"), content, "utf8"); From a9e514099585e5ee72df21bd551a223cceb20fb0 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Thu, 28 May 2026 15:57:22 +0200 Subject: [PATCH 285/313] feat: warn when custom instance db is shared across workspaces (#9359) * feat: warn when custom instance db is shared across workspaces * Fix leaking workspace names * sqlx prepare --- ...1f7f387f5055c47f493271d26731336257384.json | 10 +-- ...0a168cc1191acaab96bed6a016c437059c2cd.json | 26 +++++++ backend/windmill-api-settings/src/lib.rs | 70 ++++++++++++++++--- backend/windmill-api/openapi.yaml | 5 ++ .../CustomInstanceDbSelect.svelte | 39 +++++++++-- 5 files changed, 132 insertions(+), 18 deletions(-) create mode 100644 backend/.sqlx/query-815d96aea4681490582b08630a30a168cc1191acaab96bed6a016c437059c2cd.json diff --git a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json index d29a18c691..e7ed0aee65 100644 --- a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json +++ b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json @@ -46,11 +46,11 @@ ] }, "nullable": [ - true, - true, - true, - true, - true, + false, + false, + false, + false, + false, true, true ] diff --git a/backend/.sqlx/query-815d96aea4681490582b08630a30a168cc1191acaab96bed6a016c437059c2cd.json b/backend/.sqlx/query-815d96aea4681490582b08630a30a168cc1191acaab96bed6a016c437059c2cd.json new file mode 100644 index 0000000000..28a725277d --- /dev/null +++ b/backend/.sqlx/query-815d96aea4681490582b08630a30a168cc1191acaab96bed6a016c437059c2cd.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT ws.workspace_id AS \"workspace_id!\", entry->'catalog'->>'resource_path' AS dbname\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(\n CASE WHEN jsonb_typeof(ws.ducklake->'ducklakes') = 'object'\n THEN ws.ducklake->'ducklakes'\n ELSE '{}'::jsonb END\n ) AS dl(k, entry)\n WHERE entry->'catalog'->>'resource_type' = 'instance'\n AND entry->'catalog'->>'resource_path' IS NOT NULL\n UNION ALL\n SELECT ws.workspace_id AS \"workspace_id!\", entry->'database'->>'resource_path' AS dbname\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(\n CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object'\n THEN ws.datatable->'datatables'\n ELSE '{}'::jsonb END\n ) AS dt(k, entry)\n WHERE entry->'database'->>'resource_type' = 'instance'\n AND entry->'database'->>'resource_path' IS NOT NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "dbname", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null + ] + }, + "hash": "815d96aea4681490582b08630a30a168cc1191acaab96bed6a016c437059c2cd" +} diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index af7c42ee4b..9fd1091d66 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -6,7 +6,10 @@ * LICENSE-AGPL for a copy of the license. */ -use std::{collections::HashMap, time::Duration}; +use std::{ + collections::{BTreeSet, HashMap}, + time::Duration, +}; #[cfg(feature = "parquet")] mod audit_logs_s3; @@ -47,6 +50,7 @@ use windmill_common::secret_backend::{ AwsSecretsManagerSettings, AzureKeyVaultSettings, SecretMigrationReport, VaultSettings, }; use windmill_common::{ + auth::is_super_admin_email, ee_oss::{get_license_plan, LicensePlan}, email_oss::send_email_plain_text, error::{self, JsonResult, Result}, @@ -1135,6 +1139,8 @@ struct CustomInstanceDb { success: bool, error: Option, tag: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + used_by_workspaces: Vec, } #[derive(Deserialize, Debug, Serialize, Default)] @@ -1154,7 +1160,7 @@ struct CustomInstanceDbLogs { } async fn list_custom_instance_pg_databases( - _authed: ApiAuthed, + authed: ApiAuthed, Extension(db): Extension, ) -> JsonResult> { let result = sqlx::query_scalar!( @@ -1163,12 +1169,57 @@ async fn list_custom_instance_pg_databases( .fetch_one(&db) .await? .ok_or_else(|| error::Error::ExecutionErr("Couldn't find custom_instance_pg_databases".to_string()))?; - let result = serde_json::from_value(result).map_err(|e| { - error::Error::ExecutionErr(format!( - "couldn't parse custom_instance_pg_databases.databases : {}", - e.to_string() - )) - })?; + let mut result: HashMap = + serde_json::from_value(result).map_err(|e| { + error::Error::ExecutionErr(format!( + "couldn't parse custom_instance_pg_databases.databases : {}", + e.to_string() + )) + })?; + + if is_super_admin_email(&db, &authed.email).await? { + // Enrich each database with the list of workspaces referencing it through + // either a ducklake catalog or a datatable database whose resource_type is + // 'instance'. Not stored in DB to avoid drift. + let usages = sqlx::query!( + r#" + SELECT ws.workspace_id AS "workspace_id!", entry->'catalog'->>'resource_path' AS dbname + FROM workspace_settings ws + CROSS JOIN LATERAL jsonb_each( + CASE WHEN jsonb_typeof(ws.ducklake->'ducklakes') = 'object' + THEN ws.ducklake->'ducklakes' + ELSE '{}'::jsonb END + ) AS dl(k, entry) + WHERE entry->'catalog'->>'resource_type' = 'instance' + AND entry->'catalog'->>'resource_path' IS NOT NULL + UNION ALL + SELECT ws.workspace_id AS "workspace_id!", entry->'database'->>'resource_path' AS dbname + FROM workspace_settings ws + CROSS JOIN LATERAL jsonb_each( + CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object' + THEN ws.datatable->'datatables' + ELSE '{}'::jsonb END + ) AS dt(k, entry) + WHERE entry->'database'->>'resource_type' = 'instance' + AND entry->'database'->>'resource_path' IS NOT NULL + "#, + ) + .fetch_all(&db) + .await?; + + let mut by_db: HashMap> = HashMap::new(); + for row in usages { + if let Some(dbname) = row.dbname { + by_db.entry(dbname).or_default().insert(row.workspace_id); + } + } + for (dbname, entry) in result.iter_mut() { + if let Some(workspaces) = by_db.remove(dbname) { + entry.used_by_workspaces = workspaces.into_iter().collect(); + } + } + } + return Ok(Json(result)); } @@ -1196,7 +1247,8 @@ async fn setup_custom_instance_pg_database( let result = setup_custom_instance_pg_database_inner(authed, &db, &dbname, &mut logs).await; let success = result.is_ok(); let error = result.err().map(|e| e.to_string()); - let status = CustomInstanceDb { logs, success, error, tag: body.tag }; + let status = + CustomInstanceDb { logs, success, error, tag: body.tag, used_by_workspaces: vec![] }; let status_json = serde_json::to_value(&status).map_err(to_anyhow)?; // Save that the database was setup successfully sqlx::query!( diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 0d21853205..4576f655ea 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -25405,6 +25405,11 @@ components: example: "Connection timeout" tag: $ref: "#/components/schemas/CustomInstanceDbTag" + used_by_workspaces: + type: array + items: + type: string + description: Workspaces that reference this database via a ducklake catalog or datatable database with resource_type 'instance'. Computed at request time, not persisted. NewSqsTrigger: type: object diff --git a/frontend/src/lib/components/workspaceSettings/CustomInstanceDbSelect.svelte b/frontend/src/lib/components/workspaceSettings/CustomInstanceDbSelect.svelte index 9fccaa8b35..222e5606c8 100644 --- a/frontend/src/lib/components/workspaceSettings/CustomInstanceDbSelect.svelte +++ b/frontend/src/lib/components/workspaceSettings/CustomInstanceDbSelect.svelte @@ -9,6 +9,8 @@ import { ArrowRight, TriangleAlert } from 'lucide-svelte' import type { ConfirmationModalHandle } from '../common/confirmationModal/asyncConfirmationModal.svelte' import type { Snippet } from 'svelte' + import Tooltip from '../meltComponents/Tooltip.svelte' + import { workspaceStore } from '$lib/stores' type Props = { value: string | undefined @@ -37,6 +39,11 @@ ) ) let open = $state(false) + + function otherWorkspaces(dbname: string): string[] { + const all = customInstanceDbs.current?.[dbname]?.used_by_workspaces ?? [] + return all.filter((w) => w !== $workspaceStore) + }
@@ -52,11 +59,17 @@ disabled={!$isCustomInstanceDbEnabled} > {#snippet endSnippet({ item })} - {@render customInstanceDbWizardButton(item.value)} +
+ {@render sharedWorkspacesWarning(item.value)} + {@render customInstanceDbWizardButton(item.value)} +
{/snippet} {#if value} - {@render customInstanceDbWizardButton(value, 'absolute right-1.5')} +
+ {@render sharedWorkspacesWarning(value)} + {@render customInstanceDbWizardButton(value)} +
{/if}
@@ -74,12 +87,12 @@ } /> -{#snippet customInstanceDbWizardButton(dbname: string, clazz: string = '')} +{#snippet customInstanceDbWizardButton(dbname: string)} {@const status = customInstanceDbs.current?.[dbname]} {/snippet} + +{#snippet sharedWorkspacesWarning(dbname: string)} + {@const others = otherWorkspaces(dbname)} + {#if others.length > 0} + + + {#snippet text()} + This database is also used by workspace{others.length > 1 ? 's' : ''} + {others.join(', ')}. Any data written here will be shared + with {others.length > 1 ? 'them' : 'it'}. + {/snippet} + + {/if} +{/snippet} From 9e7eaf36847ad3a004ec84e8b7d4784771b7b451 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Thu, 28 May 2026 15:57:49 +0200 Subject: [PATCH 286/313] feat: inject active editor into global chat (#9361) --- ai_evals/README.md | 19 +- .../frontend/core/global/globalEvalRunner.ts | 57 ++++- ai_evals/cases/global.yaml | 89 +++++++ ai_evals/cli/index.ts | 10 +- ai_evals/core/cases.test.ts | 28 ++ ai_evals/core/results.test.ts | 242 ++++++++++++++++++ ai_evals/core/results.ts | 181 ++++++++----- ai_evals/core/types.ts | 3 + .../initial/current_greeting_live_script.json | 66 +++++ .../initial/current_invoice_live_flow.json | 118 +++++++++ ai_evals/modes/global.ts | 8 +- .../copilot/chat/AIChatManager.svelte.ts | 10 +- .../copilot/chat/global/core.test.ts | 22 ++ .../components/copilot/chat/global/core.ts | 47 +++- 14 files changed, 821 insertions(+), 79 deletions(-) create mode 100644 ai_evals/core/results.test.ts create mode 100644 ai_evals/fixtures/frontend/global/initial/current_greeting_live_script.json create mode 100644 ai_evals/fixtures/frontend/global/initial/current_invoice_live_flow.json diff --git a/ai_evals/README.md b/ai_evals/README.md index 6982d70da9..4804ad2543 100644 --- a/ai_evals/README.md +++ b/ai_evals/README.md @@ -142,6 +142,15 @@ For `global` mode, `validate` can express draft-level requirements such as: - required or forbidden draft counts - forbidden draft paths +Global initial fixtures can also seed `liveEditorDrafts` with `type`, +`storagePath`, `effectivePath`, and `value` fields. These drafts emulate the +currently open script, flow, or raw app editor so cases can test prompts that +refer to "this" or the "current" item. + +Set `WMILL_AI_EVAL_DISABLE_ACTIVE_EDITOR_CONTEXT=1` to run those cases with +the old behavior where the live editor is only discoverable through +`list_workspace_items`. + App fixtures can also include an optional `datatables.json` file at the fixture root. For `flow` mode, an `initial` fixture can also include a benchmark workspace catalog of @@ -189,11 +198,15 @@ If `--record` is used, the CLI also appends one compact JSON line to: Each recorded line contains: - run metadata (`createdAt`, `gitSha`, `mode`, `runModel`, `judgeModel`) -- suite totals (`caseCount`, `attemptCount`, `passedAttempts`, `passRate`, `averageDurationMs`, `averageJudgeScore`) -- average token usage (`averageTokenUsagePerAttempt`) -- per-case metrics under `cases[]` (`averageDurationMs`, `averageJudgeScore`, `averageTokenUsagePerAttempt`, pass rate) +- suite totals (`caseCount`, `attemptCount`, `passedAttempts`, `passRate`, `averageDurationMs`, `averagePassedDurationMs`, `averageJudgeScore`) +- average token usage (`averageTokenUsagePerAttempt`, `averageTokenUsagePerPassedAttempt`) +- per-case metrics under `cases[]` (`averageDurationMs`, `averagePassedDurationMs`, `averageJudgeScore`, `averageTokenUsagePerAttempt`, `averageTokenUsagePerPassedAttempt`, pass rate) - `failedCaseIds` +The CLI headline duration and token averages use passed attempts only. +All-attempt averages are still recorded to make failures auditable without +letting failed attempts skew success cost comparisons. + Example: - summary: `ai_evals/results/2026-04-09T09-40-33.051Z__flow.json` diff --git a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts index 8541348c51..058adc3644 100644 --- a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts @@ -12,6 +12,7 @@ import { listGlobalDrafts, } from "../../../../../frontend/src/lib/components/copilot/chat/global/userDraftAdapter"; import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared"; +import { UserDraft } from "../../../../../frontend/src/lib/userDraft.svelte"; import type { ModeRunContext } from "../../../../core/types"; import type { GlobalDraftState } from "../../../../core/validators"; import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings"; @@ -27,6 +28,21 @@ const MUTATING_GLOBAL_TOOLS = new Set([ "deploy_workspace_item", "delete_workspace_item", ]); +const DISABLE_ACTIVE_EDITOR_CONTEXT_ENV = + "WMILL_AI_EVAL_DISABLE_ACTIVE_EDITOR_CONTEXT"; + +const LIVE_EDITOR_ITEM_KINDS = { + script: "script", + flow: "flow", + app: "raw_app", +} as const; + +export interface GlobalLiveEditorDraftFixture { + type: keyof typeof LIVE_EDITOR_ITEM_KINDS; + storagePath?: string; + effectivePath?: string; + value?: unknown; +} export interface GlobalEvalResult { success: boolean; @@ -41,6 +57,7 @@ export interface GlobalEvalResult { export interface GlobalEvalOptions { workspaceFixtures?: BenchmarkWorkspaceRunnables; + liveEditorDrafts?: GlobalLiveEditorDraftFixture[]; model?: string; maxIterations?: number; provider?: AIProvider; @@ -60,13 +77,20 @@ export async function runGlobalEval( clearGlobalDrafts(workspaceRoot); registerBenchmarkWorkspaceRunnables(workspaceRoot, options.workspaceFixtures ?? {}); + seedLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []); try { const model = options.model ?? "claude-haiku-4-5-20251001"; + const injectActiveEditorContext = + process.env[DISABLE_ACTIVE_EDITOR_CONTEXT_ENV] !== "1"; const rawResult = await runEval({ userPrompt, systemMessage: prepareGlobalSystemMessage(), - userMessage: prepareGlobalUserMessage(userPrompt), + userMessage: prepareGlobalUserMessage( + userPrompt, + [], + injectActiveEditorContext ? { workspace: workspaceRoot } : {}, + ), tools: getGlobalEvalTools(), helpers: {}, apiKey, @@ -98,6 +122,7 @@ export async function runGlobalEval( }; } finally { clearGlobalDrafts(workspaceRoot); + clearLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []); unregisterBenchmarkWorkspaceRunnables(workspaceRoot); if (!options.workspaceRoot) { await rm(workspaceRoot, { recursive: true, force: true }); @@ -105,6 +130,36 @@ export async function runGlobalEval( } } +function seedLiveEditorDrafts( + workspace: string, + fixtures: GlobalLiveEditorDraftFixture[], +): void { + for (const fixture of fixtures) { + const itemKind = LIVE_EDITOR_ITEM_KINDS[fixture.type]; + const storagePath = fixture.storagePath ?? fixture.effectivePath ?? ""; + if (fixture.value !== undefined) { + UserDraft.save(itemKind, storagePath, fixture.value, { workspace }); + } + UserDraft.setLiveEditorDraft({ + workspace, + itemKind, + storagePath, + effectivePath: fixture.effectivePath ?? fixture.storagePath, + }); + } +} + +function clearLiveEditorDrafts( + workspace: string, + fixtures: GlobalLiveEditorDraftFixture[], +): void { + for (const fixture of fixtures) { + const itemKind = LIVE_EDITOR_ITEM_KINDS[fixture.type]; + const storagePath = fixture.storagePath ?? fixture.effectivePath ?? ""; + UserDraft.clearLiveEditorDraft(itemKind, { workspace, storagePath }); + } +} + function getGlobalEvalTools(): ProductionTool<{}>[] { return (globalTools as ProductionTool<{}>[]).map((tool) => { if (!MUTATING_GLOBAL_TOOLS.has(tool.def.function.name)) { diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 373408a23d..36238e8ccd 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -341,3 +341,92 @@ - updates the total calculation to apply 8% tax - returns subtotal, tax, and total from the updated flow logic - leaves the result as an AI draft only + +- id: global-test12-current-live-script-edit + prompt: |- + The script I have open formats greetings. + Can you update this script so it uppercases the name before greeting them and ends with an exclamation mark? + Keep it as draft work. + initial: ai_evals/fixtures/frontend/global/initial/current_greeting_live_script.json + runtime: + maxTurns: 8 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + path: f/evals/global/current_greeting + language: bun + valueIncludes: + - toUpperCase + - "!" + forbiddenDrafts: + - type: script + path: f/evals/global/format_greeting + - type: script + path: f/evals/global/format_greeting_archive + toolExpect: + requiredToolsUsed: + - read_workspace_item + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - resolves "this script" to the active live editor script instead of another similarly named workspace script + - updates the greeting logic to uppercase the provided name + - returns a greeting ending with an exclamation mark + - leaves the result as a draft only + +- id: global-test13-current-live-flow-edit + prompt: |- + I have the invoice flow open. + In the current flow, update the total calculation to add 8% tax and return subtotal, tax, and total. + Keep the change as a draft. + initial: ai_evals/fixtures/frontend/global/initial/current_invoice_live_flow.json + runtime: + maxTurns: 10 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: flow + path: f/evals/global/current_invoice_flow + valueIncludes: + - calculate_total + - tax + - total + forbiddenDrafts: + - type: flow + path: f/evals/global/process_invoice + - type: flow + path: f/evals/global/process_refund + toolExpect: + requiredToolsUsed: + - read_workspace_item + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - resolves "current flow" to the active live editor flow + - does not edit the similarly named deployed invoice or refund flows + - updates the calculate_total logic to apply 8% tax + - returns subtotal, tax, and total from the updated flow logic + - leaves the result as a draft only + +- id: global-test14-current-without-live-editor-asks-question + prompt: |- + Please update this script so it returns `ok`. + Keep it as a draft. + runtime: + maxTurns: 4 + validate: + draftCountExactly: 0 + toolExpect: + forbiddenToolsUsed: + - write_script + - edit_script + - write_flow + - deploy_workspace_item + - delete_workspace_item + skipJudge: true + judgeChecklist: + - asks which script to update when the user refers to "this script" without selected or active editor context + - does not guess a path or create a new script draft diff --git a/ai_evals/cli/index.ts b/ai_evals/cli/index.ts index 8ed61740c8..f92d6d7027 100644 --- a/ai_evals/cli/index.ts +++ b/ai_evals/cli/index.ts @@ -211,7 +211,7 @@ async function handleRun(input: { const summaries: Array<{ label: string; passRate: number; - averageDurationMs: number; + averagePassedDurationMs: number | null; }> = []; for (const [index, model] of models.entries()) { @@ -259,7 +259,7 @@ async function handleRun(input: { summaries.push({ label: `${model.id} (${runModel})`, passRate: result.passRate, - averageDurationMs: result.averageDurationMs, + averagePassedDurationMs: result.averagePassedDurationMs ?? null, }); } @@ -267,7 +267,7 @@ async function handleRun(input: { process.stdout.write("\nModel summary\n"); for (const summary of summaries) { process.stdout.write( - `- ${summary.label}: ${formatPercent(summary.passRate)} | ${Math.round(summary.averageDurationMs)}ms\n`, + `- ${summary.label}: ${formatPercent(summary.passRate)} | passed avg ${formatNullableDuration(summary.averagePassedDurationMs)}\n`, ); } } @@ -351,6 +351,10 @@ function formatPercent(value: number): string { return `${(value * 100).toFixed(1)}%`; } +function formatNullableDuration(value: number | null): string { + return value === null ? "n/a" : `${Math.round(value)}ms`; +} + void main().catch((error) => { const message = error instanceof Error ? error.message : String(error); process.stderr.write(`${message}\n`); diff --git a/ai_evals/core/cases.test.ts b/ai_evals/core/cases.test.ts index 733d34ddd2..526ea7c70f 100644 --- a/ai_evals/core/cases.test.ts +++ b/ai_evals/core/cases.test.ts @@ -203,6 +203,34 @@ describe("loadCases", () => { }); }); + it("loads global active-editor eval cases", async () => { + const globalCases = await loadCases("global"); + const scriptCase = globalCases.find( + (entry) => entry.id === "global-test12-current-live-script-edit" + ); + const flowCase = globalCases.find( + (entry) => entry.id === "global-test13-current-live-flow-edit" + ); + + expect(scriptCase?.initialPath).toContain( + "ai_evals/fixtures/frontend/global/initial/current_greeting_live_script.json" + ); + expect(scriptCase?.toolExpect).toMatchObject({ + requiredToolsUsed: ["read_workspace_item"], + }); + expect(flowCase?.initialPath).toContain( + "ai_evals/fixtures/frontend/global/initial/current_invoice_live_flow.json" + ); + expect(flowCase?.validate).toMatchObject({ + requiredDrafts: [ + { + type: "flow", + path: "f/evals/global/current_invoice_flow", + }, + ], + }); + }); + it("loads tool expectations for workspace mutation cases", async () => { const scriptCases = await loadCases("script"); const caseEntry = scriptCases.find( diff --git a/ai_evals/core/results.test.ts b/ai_evals/core/results.test.ts new file mode 100644 index 0000000000..2d6077c5bd --- /dev/null +++ b/ai_evals/core/results.test.ts @@ -0,0 +1,242 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { describe, expect, it } from "bun:test"; +import { + appendHistoryRecord, + buildRunResult, + formatRunSummary, +} from "./results"; +import type { BenchmarkCaseResult } from "./types"; + +function caseResult( + attempts: BenchmarkCaseResult["attempts"], +): BenchmarkCaseResult { + return { + id: "case-1", + prompt: "Do the thing", + attempts, + }; +} + +describe("benchmark results", () => { + it("keeps success cost metrics separate from failed attempts", () => { + const result = buildRunResult({ + mode: "global", + runs: 1, + runModel: "model-under-test", + judgeModel: "judge-model", + caseResults: [ + caseResult([ + { + attempt: 1, + passed: true, + durationMs: 1000, + assistantMessageCount: 1, + toolCallCount: 1, + toolsUsed: ["edit_script"], + skillsInvoked: [], + checks: [{ name: "edited", passed: true }], + judgeScore: 100, + judgeSummary: "ok", + error: null, + tokenUsage: { prompt: 100, completion: 20, total: 120 }, + }, + { + attempt: 2, + passed: false, + durationMs: 100, + assistantMessageCount: 1, + toolCallCount: 0, + toolsUsed: [], + skillsInvoked: [], + checks: [{ name: "edited", passed: false }], + judgeScore: 10, + judgeSummary: "missed", + error: "failed", + tokenUsage: { prompt: 10, completion: 5, total: 15 }, + }, + ]), + ], + }); + + expect(result.attemptCount).toBe(2); + expect(result.passedAttempts).toBe(1); + expect(result.passRate).toBe(0.5); + expect(result.averageDurationMs).toBe(550); + expect(result.averagePassedDurationMs).toBe(1000); + expect(result.totalTokenUsage).toEqual({ + prompt: 110, + completion: 25, + total: 135, + }); + expect(result.totalPassedTokenUsage).toEqual({ + prompt: 100, + completion: 20, + total: 120, + }); + expect(result.averageTokenUsagePerAttempt).toEqual({ + prompt: 55, + completion: 12.5, + total: 67.5, + }); + expect(result.averageTokenUsagePerPassedAttempt).toEqual({ + prompt: 100, + completion: 20, + total: 120, + }); + + const summary = formatRunSummary(result); + expect(summary).toContain("Average duration (passed): 1000ms"); + expect(summary).toContain("Average tokens (passed): 120 total"); + expect(summary).toContain("Average duration (all attempts): 550ms"); + }); + + it("reports passed averages as unavailable when no attempt passes", () => { + const result = buildRunResult({ + mode: "global", + runs: 1, + runModel: "model-under-test", + judgeModel: "judge-model", + caseResults: [ + caseResult([ + { + attempt: 1, + passed: false, + durationMs: 100, + assistantMessageCount: 1, + toolCallCount: 0, + toolsUsed: [], + skillsInvoked: [], + checks: [{ name: "edited", passed: false }], + judgeScore: 10, + judgeSummary: "missed", + error: "failed", + tokenUsage: { prompt: 10, completion: 5, total: 15 }, + }, + ]), + ], + }); + + expect(result.averagePassedDurationMs).toBeNull(); + expect(result.totalPassedTokenUsage).toBeNull(); + expect(result.averageTokenUsagePerPassedAttempt).toBeNull(); + expect(formatRunSummary(result)).toContain( + "Average duration (passed): n/a", + ); + }); + + it("normalizes passed token averages by passed attempts", () => { + const result = buildRunResult({ + mode: "global", + runs: 1, + runModel: "model-under-test", + judgeModel: "judge-model", + caseResults: [ + caseResult([ + { + attempt: 1, + passed: true, + durationMs: 1000, + assistantMessageCount: 1, + toolCallCount: 1, + toolsUsed: ["edit_script"], + skillsInvoked: [], + checks: [{ name: "edited", passed: true }], + judgeScore: 100, + judgeSummary: "ok", + error: null, + tokenUsage: { prompt: 100, completion: 20, total: 120 }, + }, + { + attempt: 2, + passed: true, + durationMs: 1200, + assistantMessageCount: 1, + toolCallCount: 1, + toolsUsed: ["edit_script"], + skillsInvoked: [], + checks: [{ name: "edited", passed: true }], + judgeScore: 100, + judgeSummary: "ok", + error: null, + tokenUsage: null, + }, + ]), + ], + }); + + expect(result.passedAttempts).toBe(2); + expect(result.totalPassedTokenUsage).toEqual({ + prompt: 100, + completion: 20, + total: 120, + }); + expect(result.averageTokenUsagePerPassedAttempt).toEqual({ + prompt: 50, + completion: 10, + total: 60, + }); + }); + + it("records passed-attempt metrics in history", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "windmill-ai-evals-")); + try { + const historyPath = join(tempDir, "history.jsonl"); + const result = buildRunResult({ + mode: "global", + runs: 1, + runModel: "model-under-test", + judgeModel: "judge-model", + caseResults: [ + caseResult([ + { + attempt: 1, + passed: true, + durationMs: 1000, + assistantMessageCount: 1, + toolCallCount: 1, + toolsUsed: ["edit_script"], + skillsInvoked: [], + checks: [{ name: "edited", passed: true }], + judgeScore: 100, + judgeSummary: "ok", + error: null, + tokenUsage: { prompt: 100, completion: 20, total: 120 }, + }, + { + attempt: 2, + passed: false, + durationMs: 100, + assistantMessageCount: 1, + toolCallCount: 0, + toolsUsed: [], + skillsInvoked: [], + checks: [{ name: "edited", passed: false }], + judgeScore: 10, + judgeSummary: "missed", + error: "failed", + tokenUsage: { prompt: 10, completion: 5, total: 15 }, + }, + ]), + ], + }); + + await appendHistoryRecord(result, historyPath); + const record = JSON.parse(await readFile(historyPath, "utf8")); + + expect(record.averageDurationMs).toBe(550); + expect(record.averagePassedDurationMs).toBe(1000); + expect(record.averageTokenUsagePerAttempt.total).toBe(67.5); + expect(record.averageTokenUsagePerPassedAttempt.total).toBe(120); + expect(record.cases[0].averageDurationMs).toBe(550); + expect(record.cases[0].averagePassedDurationMs).toBe(1000); + expect(record.cases[0].averageTokenUsagePerAttempt.total).toBe(67.5); + expect(record.cases[0].averageTokenUsagePerPassedAttempt.total).toBe( + 120, + ); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/ai_evals/core/results.ts b/ai_evals/core/results.ts index e58840f911..0b84497165 100644 --- a/ai_evals/core/results.ts +++ b/ai_evals/core/results.ts @@ -4,12 +4,20 @@ import { execFileSync } from "node:child_process"; import { getAiEvalsRoot, getRepoRoot } from "./cases"; import type { BenchmarkArtifactFile, + BenchmarkAttemptResult, BenchmarkCaseResult, BenchmarkRunResult, BenchmarkTokenUsage, EvalMode, } from "./types"; +type AttemptAggregate = { + attemptCount: number; + durationTotal: number; + tokenUsageAttemptCount: number; + tokenUsageTotal: BenchmarkTokenUsage | null; +}; + export async function writeRunResult( result: BenchmarkRunResult, outputPath?: string, @@ -77,36 +85,12 @@ export function buildRunResult(input: { judgeModel: string | null; caseResults: BenchmarkCaseResult[]; }): BenchmarkRunResult { - const attemptCount = input.caseResults.reduce( - (sum, entry) => sum + entry.attempts.length, - 0, - ); - const passedAttempts = input.caseResults.reduce( - (sum, entry) => - sum + entry.attempts.filter((attempt) => attempt.passed).length, - 0, - ); - const durationTotal = input.caseResults.reduce( - (sum, entry) => - sum + - entry.attempts.reduce((inner, attempt) => inner + attempt.durationMs, 0), - 0, - ); - const tokenUsageTotal = input.caseResults.reduce( - (sum, entry) => { - for (const attempt of entry.attempts) { - if (!attempt.tokenUsage) { - continue; - } - sum ??= { prompt: 0, completion: 0, total: 0 }; - sum.prompt += attempt.tokenUsage.prompt; - sum.completion += attempt.tokenUsage.completion; - sum.total += attempt.tokenUsage.total; - } - return sum; - }, - null, - ); + const attempts = input.caseResults.flatMap((entry) => entry.attempts); + const passedAttemptResults = attempts.filter((attempt) => attempt.passed); + const attemptAggregate = aggregateAttempts(attempts); + const passedAttemptAggregate = aggregateAttempts(passedAttemptResults); + const attemptCount = attemptAggregate.attemptCount; + const passedAttempts = passedAttemptAggregate.attemptCount; return { version: 1, @@ -120,16 +104,19 @@ export function buildRunResult(input: { attemptCount, passedAttempts, passRate: attemptCount === 0 ? 0 : passedAttempts / attemptCount, - averageDurationMs: attemptCount === 0 ? 0 : durationTotal / attemptCount, - totalTokenUsage: tokenUsageTotal, + averageDurationMs: + attemptCount === 0 ? 0 : attemptAggregate.durationTotal / attemptCount, + averagePassedDurationMs: averageDuration(passedAttemptAggregate), + totalTokenUsage: attemptAggregate.tokenUsageTotal, + totalPassedTokenUsage: passedAttemptAggregate.tokenUsageTotal, averageTokenUsagePerAttempt: - attemptCount === 0 || !tokenUsageTotal + attemptCount === 0 ? null - : { - prompt: tokenUsageTotal.prompt / attemptCount, - completion: tokenUsageTotal.completion / attemptCount, - total: tokenUsageTotal.total / attemptCount, - }, + : averageTokenUsage(attemptAggregate, attemptCount), + averageTokenUsagePerPassedAttempt: averageTokenUsage( + passedAttemptAggregate, + passedAttempts, + ), cases: input.caseResults, }; } @@ -138,9 +125,25 @@ export function formatRunSummary(result: BenchmarkRunResult): string { const lines = [ `${result.mode} benchmark complete`, `Pass rate: ${formatPercent(result.passRate)} (${result.passedAttempts}/${result.attemptCount})`, - `Average duration: ${Math.round(result.averageDurationMs)}ms`, + `Average duration (passed): ${formatNullableDuration(result.averagePassedDurationMs ?? null)}`, ]; + if (result.averageTokenUsagePerPassedAttempt) { + lines.push( + `Average tokens (passed): ${formatTokenUsage(result.averageTokenUsagePerPassedAttempt)}`, + ); + } + if (result.passedAttempts < result.attemptCount) { + lines.push( + `Average duration (all attempts): ${Math.round(result.averageDurationMs)}ms`, + ); + if (result.averageTokenUsagePerAttempt) { + lines.push( + `Average tokens (all attempts): ${formatTokenUsage(result.averageTokenUsagePerAttempt)}`, + ); + } + } + const failures = collectFailures(result); if (failures.length > 0) { lines.push("Failures:"); @@ -172,6 +175,60 @@ function collectFailures(result: BenchmarkRunResult): string[] { return failures; } +function aggregateAttempts(attempts: BenchmarkAttemptResult[]): AttemptAggregate { + const aggregate: AttemptAggregate = { + attemptCount: attempts.length, + durationTotal: 0, + tokenUsageAttemptCount: 0, + tokenUsageTotal: null, + }; + + for (const attempt of attempts) { + aggregate.durationTotal += attempt.durationMs; + if (!attempt.tokenUsage) { + continue; + } + aggregate.tokenUsageAttemptCount += 1; + aggregate.tokenUsageTotal ??= { prompt: 0, completion: 0, total: 0 }; + aggregate.tokenUsageTotal.prompt += attempt.tokenUsage.prompt; + aggregate.tokenUsageTotal.completion += attempt.tokenUsage.completion; + aggregate.tokenUsageTotal.total += attempt.tokenUsage.total; + } + + return aggregate; +} + +function averageDuration(aggregate: AttemptAggregate): number | null { + return aggregate.attemptCount === 0 + ? null + : aggregate.durationTotal / aggregate.attemptCount; +} + +function averageTokenUsage( + aggregate: AttemptAggregate, + denominator: number, +): BenchmarkTokenUsage | null { + if (denominator === 0 || !aggregate.tokenUsageTotal) { + return null; + } + return { + prompt: aggregate.tokenUsageTotal.prompt / denominator, + completion: aggregate.tokenUsageTotal.completion / denominator, + total: aggregate.tokenUsageTotal.total / denominator, + }; +} + +function formatNullableDuration(value: number | null): string { + return value === null ? "n/a" : `${Math.round(value)}ms`; +} + +function formatTokenUsage(value: BenchmarkTokenUsage): string { + const total = Math.round(value.total); + const prompt = Math.round(value.prompt); + const completion = Math.round(value.completion); + return `${total} total (${prompt} prompt, ${completion} completion)`; +} + function defaultFileName(mode: EvalMode): string { return `${new Date().toISOString().replaceAll(":", "-")}__${mode}.json`; } @@ -252,12 +309,15 @@ function toHistoryRecord(result: BenchmarkRunResult) { passedAttempts: result.passedAttempts, passRate: result.passRate, averageDurationMs: result.averageDurationMs, + averagePassedDurationMs: result.averagePassedDurationMs ?? null, averageJudgeScore: judgeScores.length === 0 ? null : judgeScores.reduce((sum, score) => sum + score, 0) / judgeScores.length, averageTokenUsagePerAttempt: result.averageTokenUsagePerAttempt ?? null, + averageTokenUsagePerPassedAttempt: + result.averageTokenUsagePerPassedAttempt ?? null, failedCaseIds: Array.from( new Set( result.cases @@ -268,31 +328,15 @@ function toHistoryRecord(result: BenchmarkRunResult) { ), ), cases: result.cases.map((caseResult) => { - const attemptCount = caseResult.attempts.length; - const passedAttempts = caseResult.attempts.filter( - (attempt) => attempt.passed, - ).length; - const totalDurationMs = caseResult.attempts.reduce( - (sum, attempt) => sum + attempt.durationMs, - 0, + const attemptAggregate = aggregateAttempts(caseResult.attempts); + const passedAttemptAggregate = aggregateAttempts( + caseResult.attempts.filter((attempt) => attempt.passed), ); + const attemptCount = attemptAggregate.attemptCount; + const passedAttempts = passedAttemptAggregate.attemptCount; const judgeScores = caseResult.attempts.flatMap((attempt) => typeof attempt.judgeScore === "number" ? [attempt.judgeScore] : [], ); - const totalTokenUsage = - caseResult.attempts.reduce( - (sum, attempt) => { - if (!attempt.tokenUsage) { - return sum; - } - sum ??= { prompt: 0, completion: 0, total: 0 }; - sum.prompt += attempt.tokenUsage.prompt; - sum.completion += attempt.tokenUsage.completion; - sum.total += attempt.tokenUsage.total; - return sum; - }, - null, - ); return { id: caseResult.id, @@ -300,20 +344,23 @@ function toHistoryRecord(result: BenchmarkRunResult) { passedAttempts, passRate: attemptCount === 0 ? 0 : passedAttempts / attemptCount, averageDurationMs: - attemptCount === 0 ? 0 : totalDurationMs / attemptCount, + attemptCount === 0 + ? 0 + : attemptAggregate.durationTotal / attemptCount, + averagePassedDurationMs: averageDuration(passedAttemptAggregate), averageJudgeScore: judgeScores.length === 0 ? null : judgeScores.reduce((sum, score) => sum + score, 0) / judgeScores.length, averageTokenUsagePerAttempt: - attemptCount === 0 || !totalTokenUsage + attemptCount === 0 ? null - : { - prompt: totalTokenUsage.prompt / attemptCount, - completion: totalTokenUsage.completion / attemptCount, - total: totalTokenUsage.total / attemptCount, - }, + : averageTokenUsage(attemptAggregate, attemptCount), + averageTokenUsagePerPassedAttempt: averageTokenUsage( + passedAttemptAggregate, + passedAttempts, + ), }; }), }; diff --git a/ai_evals/core/types.ts b/ai_evals/core/types.ts index 19c97bce11..ecc46591fc 100644 --- a/ai_evals/core/types.ts +++ b/ai_evals/core/types.ts @@ -326,8 +326,11 @@ export interface BenchmarkRunResult { passedAttempts: number; passRate: number; averageDurationMs: number; + averagePassedDurationMs?: number | null; totalTokenUsage?: BenchmarkTokenUsage | null; + totalPassedTokenUsage?: BenchmarkTokenUsage | null; averageTokenUsagePerAttempt?: BenchmarkTokenUsage | null; + averageTokenUsagePerPassedAttempt?: BenchmarkTokenUsage | null; artifactsPath?: string | null; cases: BenchmarkCaseResult[]; } diff --git a/ai_evals/fixtures/frontend/global/initial/current_greeting_live_script.json b/ai_evals/fixtures/frontend/global/initial/current_greeting_live_script.json new file mode 100644 index 0000000000..98538a1ccb --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/current_greeting_live_script.json @@ -0,0 +1,66 @@ +{ + "workspace": { + "scripts": [ + { + "path": "f/evals/global/format_greeting", + "summary": "Format a deployed greeting", + "description": "Returns a plain greeting for a provided name.", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + }, + "content": "export async function main(name: string) {\n return `Hello, ${name}`\n}\n" + }, + { + "path": "f/evals/global/format_greeting_archive", + "summary": "Archived greeting formatter", + "description": "Older greeting formatter kept for reference.", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + }, + "content": "export async function main(name: string) {\n return `Hi, ${name}`\n}\n" + } + ] + }, + "liveEditorDrafts": [ + { + "type": "script", + "storagePath": "f/evals/global/current_greeting", + "effectivePath": "f/evals/global/current_greeting", + "value": { + "path": "f/evals/global/current_greeting", + "summary": "Open greeting formatter", + "description": "Formats a greeting in the live editor.", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + }, + "content": "export async function main(name: string) {\n return `Hello, ${name}`\n}\n", + "is_template": false, + "kind": "script" + } + } + ] +} diff --git a/ai_evals/fixtures/frontend/global/initial/current_invoice_live_flow.json b/ai_evals/fixtures/frontend/global/initial/current_invoice_live_flow.json new file mode 100644 index 0000000000..5c2ffcb0f0 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/current_invoice_live_flow.json @@ -0,0 +1,118 @@ +{ + "workspace": { + "flows": [ + { + "path": "f/evals/global/process_invoice", + "summary": "Deployed invoice processor", + "description": "Calculates invoice totals from a subtotal.", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "subtotal": { + "type": "number" + } + }, + "required": ["subtotal"] + }, + "value": { + "modules": [ + { + "id": "calculate_total", + "summary": "Calculate total from subtotal", + "value": { + "type": "rawscript", + "language": "bun", + "content": "export async function main(subtotal: number) {\n return { subtotal, total: subtotal }\n}\n", + "input_transforms": { + "subtotal": { + "type": "javascript", + "expr": "flow_input.subtotal" + } + } + } + } + ] + } + }, + { + "path": "f/evals/global/process_refund", + "summary": "Refund processor", + "description": "Calculates refund totals.", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "subtotal": { + "type": "number" + } + }, + "required": ["subtotal"] + }, + "value": { + "modules": [ + { + "id": "calculate_total", + "summary": "Calculate refund total", + "value": { + "type": "rawscript", + "language": "bun", + "content": "export async function main(subtotal: number) {\n return { subtotal, total: subtotal }\n}\n", + "input_transforms": { + "subtotal": { + "type": "javascript", + "expr": "flow_input.subtotal" + } + } + } + } + ] + } + } + ] + }, + "liveEditorDrafts": [ + { + "type": "flow", + "storagePath": "f/evals/global/current_invoice_flow", + "effectivePath": "f/evals/global/current_invoice_flow", + "value": { + "path": "f/evals/global/current_invoice_flow", + "summary": "Open invoice processor", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "subtotal": { + "type": "number" + } + }, + "required": ["subtotal"] + }, + "value": { + "modules": [ + { + "id": "calculate_total", + "summary": "Calculate total from subtotal", + "value": { + "type": "rawscript", + "language": "bun", + "content": "export async function main(subtotal: number) {\n return { subtotal, total: subtotal }\n}\n", + "input_transforms": { + "subtotal": { + "type": "javascript", + "expr": "flow_input.subtotal" + } + } + } + } + ] + }, + "edited_by": "", + "edited_at": "", + "archived": false, + "extra_perms": {} + } + } + ] +} diff --git a/ai_evals/modes/global.ts b/ai_evals/modes/global.ts index d68df9f5f8..f3cbf6fd86 100644 --- a/ai_evals/modes/global.ts +++ b/ai_evals/modes/global.ts @@ -1,5 +1,8 @@ import { readFile } from "node:fs/promises"; -import { runGlobalEval } from "../adapters/frontend/core/global/globalEvalRunner"; +import { + runGlobalEval, + type GlobalLiveEditorDraftFixture, +} from "../adapters/frontend/core/global/globalEvalRunner"; import type { BenchmarkWorkspaceRunnables } from "../adapters/frontend/mockBackend"; import type { FrontendEvalModelConfig } from "../core/models"; import type { BenchmarkArtifactFile, GlobalValidationSpec, ModeRunner } from "../core/types"; @@ -9,6 +12,7 @@ import { getFrontendApiKey } from "./frontendCommon"; export interface GlobalInitialFixture { workspace?: BenchmarkWorkspaceRunnables; + liveEditorDrafts?: GlobalLiveEditorDraftFixture[]; } export function createGlobalModeRunner( @@ -31,6 +35,7 @@ export function createGlobalModeRunner( getFrontendApiKey(modelConfig.provider), { workspaceFixtures: initial?.workspace, + liveEditorDrafts: initial?.liveEditorDrafts, maxIterations: context.evalCase?.runtime?.maxTurns, provider: modelConfig.provider, model: modelConfig.model, @@ -73,6 +78,7 @@ async function loadGlobalInitialFixture(path: string): Promise { expect(content).toContain( 'Use discard_local_draft to remove an unsaved local draft, including the matching open editor draft' ) + expect(content).toContain('If the user message includes an ACTIVE EDITOR section') expect(content).not.toContain('AI draft') expect(content).not.toContain('UserDraft') expect(content).not.toContain('localStorage') @@ -1359,6 +1360,27 @@ describe('prepareGlobalSystemMessage', () => { }) describe('prepareGlobalUserMessage', () => { + it('injects the active editor reference without contents', () => { + __resetUserDraftForTesting() + localStorage.clear() + UserDraft.setLiveEditorDraft({ + workspace: WORKSPACE, + itemKind: 'script', + storagePath: '', + effectivePath: 'f/scripts/live_greeting' + }) + + const message = prepareGlobalUserMessage('Update this script', [], { workspace: WORKSPACE }) + + expect(message.content).toContain('## ACTIVE EDITOR') + expect(message.content).toContain('type: script') + expect(message.content).toContain('path: f/scripts/live_greeting') + expect(message.content).toContain('isLiveDraft: true') + expect(message.content).toContain('## INSTRUCTIONS:\nUpdate this script') + expect(message.content).not.toContain('When the user says') + expect(message.content).not.toContain('content') + }) + it('includes selected workspace item references without contents', () => { const message = prepareGlobalUserMessage('Update these items', [ { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 693ba3072f..f193da10a0 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -113,6 +113,28 @@ const INSTRUCTION_SUBJECTS = [ 'app' ] as const satisfies readonly WorkspaceItemType[] const MAX_LIST_LIMIT = 100 +type ActiveGlobalEditorType = Extract +type LiveEditorDraftKind = Parameters[0] + +const ACTIVE_GLOBAL_EDITOR_DRAFTS: readonly { + itemKind: LiveEditorDraftKind + type: ActiveGlobalEditorType +}[] = [ + { itemKind: 'script', type: 'script' }, + { itemKind: 'flow', type: 'flow' }, + { itemKind: 'raw_app', type: 'app' } +] + +export type GlobalActiveEditorContext = { + type: ActiveGlobalEditorType + path: string + isLiveDraft: true +} + +export type GlobalUserMessageOptions = { + workspace?: string + activeEditor?: GlobalActiveEditorContext +} const itemTypeSchema = z.enum(ITEM_TYPES) const instructionSubjectSchema = z.enum(INSTRUCTION_SUBJECTS) @@ -499,7 +521,7 @@ Use tools to inspect workspace items and create local drafts for scripts, flows, 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. +- If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor". - 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". @@ -3000,15 +3022,36 @@ export function prepareGlobalSystemMessage( } } +export function getActiveGlobalEditorContext( + workspace: string +): GlobalActiveEditorContext | undefined { + for (const { itemKind, type } of ACTIVE_GLOBAL_EDITOR_DRAFTS) { + const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace }) + const path = liveDraft?.effectivePath || liveDraft?.storagePath + if (!path) continue + return { type, path, isLiveDraft: true } + } +} + export function prepareGlobalUserMessage( instructions: string, - selectedContext: ContextElement[] = [] + selectedContext: ContextElement[] = [], + options: GlobalUserMessageOptions = {} ): ChatCompletionUserMessageParam { const selectedWorkspaceItems = selectedContext.filter( (context) => context.type === 'workspace_script' || context.type === 'workspace_flow' ) + const activeEditor = + options.activeEditor ?? (options.workspace ? getActiveGlobalEditorContext(options.workspace) : undefined) let content = '' + if (activeEditor) { + content += '## ACTIVE EDITOR\n' + content += `type: ${activeEditor.type}\n` + content += `path: ${activeEditor.path}\n` + content += `isLiveDraft: true\n\n` + } + if (selectedWorkspaceItems.length > 0) { content += '## SELECTED CONTEXT\n' for (const context of selectedWorkspaceItems) { From aea00611c41379be2afdad0eedd608c9537d03f7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 28 May 2026 16:48:54 +0200 Subject: [PATCH 287/313] fix(frontend): prevent MultiSelect crash on undefined value (#9364) MultiSelect read `value.length` directly while `value` is a bindable prop with no default, so a parent passing `undefined` (e.g. an enum-array approval form field with no initial value via ArgInput) threw a TypeError that blanked the entire approval page. Guard all reads behind a `value ?? []` derived. Fixes WIN-1996 Co-authored-by: Claude Opus 4.7 (1M context) --- .../lib/components/select/MultiSelect.svelte | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/components/select/MultiSelect.svelte b/frontend/src/lib/components/select/MultiSelect.svelte index 750ab62b8c..419984b20d 100644 --- a/frontend/src/lib/components/select/MultiSelect.svelte +++ b/frontend/src/lib/components/select/MultiSelect.svelte @@ -72,6 +72,8 @@ let wrapperEl: HTMLDivElement | undefined = $state() let searchInputEl: TextInput | undefined = $state() + let currentValue = $derived(value ?? []) + $effect(() => searchInputEl?.focus()) let processedItems: ProcessedItem[] = $derived.by(() => { @@ -87,18 +89,20 @@ }) let valueEntry = $derived( - value.map((v) => processedItems.find((item) => item.value === v) ?? { value: v, label: v }) + currentValue.map( + (v) => processedItems.find((item) => item.value === v) ?? { value: v, label: v } + ) ) function onAddValue(item: ProcessedItem) { if (item.__is_create && onCreateItem) { onCreateItem(item.value) } else { - value = [...value, item.value] + value = [...currentValue, item.value] } } function onRemoveValue(item: ProcessedItem) { - value = value.filter((v) => v !== item.value) + value = currentValue.filter((v) => v !== item.value) } function clearValue() { @@ -132,7 +136,7 @@ - {#if value.length === 0} + {#if currentValue.length === 0} {placeholder} @@ -149,7 +153,7 @@ {allowClear} onRemove={onRemoveValue} onReorder={reorderable - ? (oldIdx, newIdx) => (value = reorder(value, oldIdx, newIdx)) + ? (oldIdx, newIdx) => (value = reorder(currentValue, oldIdx, newIdx)) : undefined} /> @@ -166,7 +170,7 @@ {disablePortal} onSelectValue={onAddValue} {open} - processedItems={processedItems.filter((item) => !value.includes(item.value))} + processedItems={processedItems.filter((item) => !currentValue.includes(item.value))} value={undefined} {disabled} {filterText} @@ -181,7 +185,7 @@ ulClass="options" > {#snippet header()} - {#if processedItems.length - value.length > 0 || onCreateItem} + {#if processedItems.length - currentValue.length > 0 || onCreateItem}
Date: Thu, 28 May 2026 16:53:05 +0200 Subject: [PATCH 288/313] feat(queue): duration-weighted fairness admission (#9334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [ee] feat(queue): duration-weighted fairness admission atomic Add the `WORKSPACE_FAIRNESS_ADMISSION_PPM` atomic that the EE `workspace_fairness_ee::refresh_overloaded` writes on each refresh (see companion EE PR). The atomic is read on every pull by `should_admit_capped` to decide whether the dispatch goes down the standard or fairness path. Defaults to 10_000 (= admit all) so the pre-fairness behaviour is preserved until the first refresh fires. OSS stub in `workspace_fairness.rs` continues to return `true` unconditionally, so non-EE builds are bit-identical. * docs(queue): consolidate full fairness algorithm into workspace_fairness.rs Move the algorithm doc — what "overloaded" means in worker-seconds, the duration-weighted admission derivation, coordinated refresh structure, audit emission, the SQL perf constraints (no params CTE, drive running side from v2_job_runtime), and EE gating — into the OSS surface module where it is readable without EE access. The EE file becomes implementation only. Also bump ee-repo-ref to the EE commit that strips the duplicate doc. * docs(queue): clarify ADMISSION_PPM default is "admit all", not count-based Addresses CI review (claude[bot]): the `10_000` initial value is the "admit all" no-op default that applies before the first refresh classifies an overloaded set — not the count-based value (which would be `target * 10_000`). The count-based form is the empty-bucket fallback inside `compute_admission_ppm`, a different thing. * chore(queue): point ee-repo-ref at EE main (fairness admission merged via #593) * fix(queue): duration-weighted admission uses unclamped service-time window Bumps ee-repo-ref to the EE fix (windmill-ee-private#596) that sources D_c/D_u for the admission probability from a separate 60s service-time window of true `duration_ms`, instead of the occupancy aggregation whose per-job contributions are clamped to the 10s occupancy window. The clamp truncated D_c for capped jobs longer than the window, under-admitting the duration skew (true 34s jobs → ~86% effective share instead of the target 65%). Occupancy worker-seconds still drive overload classification. Updates the algorithm doc in workspace_fairness.rs accordingly. Note: ee-repo-ref points at the EE feature branch; re-point to EE main once #596 merges. --- backend/ee-repo-ref.txt | 2 +- backend/windmill-common/src/worker.rs | 11 + .../windmill-queue/src/workspace_fairness.rs | 258 +++++++++++++++++- 3 files changed, 260 insertions(+), 11 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 761de56ebb..80a746c1c0 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -327d23f7438968a21bac9fd42e7f6f027c61477c \ No newline at end of file +55c19293232be379a3044eb78f677b545882ffd6 \ No newline at end of file diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index 5a7c6d2bfa..92ebf08477 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -252,6 +252,17 @@ lazy_static::lazy_static! { 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); + /// Stochastic admission probability for capped workspaces, expressed in + /// parts per 10_000 (so `420` = 4.2%). The refresh computes this from the + /// observed worker-second distribution and the configured cap so that + /// admission converges to the target *worker-second* share — independent + /// of how the capped vs uncapped workspaces compare on per-job durations. + /// See `workspace_fairness_ee::refresh_overloaded` for the derivation. + /// `10_000` (= admit all) is the default until the first refresh + /// classifies an overloaded set — before that, no workspace is capped so + /// `should_admit_capped` is moot and "admit all" is the correct no-op. + pub static ref WORKSPACE_FAIRNESS_ADMISSION_PPM: AtomicU32 = AtomicU32::new(10_000); + 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()); diff --git a/backend/windmill-queue/src/workspace_fairness.rs b/backend/windmill-queue/src/workspace_fairness.rs index 9f4ba00211..8eb3a69394 100644 --- a/backend/windmill-queue/src/workspace_fairness.rs +++ b/backend/windmill-queue/src/workspace_fairness.rs @@ -1,15 +1,253 @@ -//! Per-workspace fairness for the shared worker pool (Enterprise feature). +//! # 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. +//! On multi-tenant deployments (notably `app.windmill.dev`, and any EE +//! cluster with a single shared worker group) a single workspace flooding +//! the queue with jobs can degrade quality of service for everyone else. +//! This module computes the set of "overloaded" workspaces whose share of +//! the worker pool must be capped, and the dispatch in `jobs.rs` uses a +//! **duration-weighted stochastic admission rule** at pull time to enforce +//! the cap as a *worker-second* share, not a pull-count share. //! -//! See [`crate::workspace_fairness_ee`] for design notes and SQL details. +//! The full algorithm lives in [`crate::workspace_fairness_ee`] behind the +//! `private` feature — this OSS-facing module is the public surface that +//! the pull dispatch and integration tests call. When EE is on, the symbols +//! here transparently re-export the EE implementation. When EE is off, they +//! are no-ops: `maybe_refresh_overloaded` does nothing, `should_admit_capped` +//! always returns `true`, and the pull path is bit-identical to its +//! pre-fairness shape. **Both runtime correctness and the entire reasoning +//! below assume the EE module is compiled in**; the OSS build is a stub. +//! +//! Every numerical default mentioned below (`MAX_PERCENT = 50`, +//! `DURATION_SECS = 10`, `MIN_TOTAL = 4`, `WORKER_PING_LIVE_SECS = 60`, +//! `ADMISSION_EPSILON_PERCENT = 5`) is tunable via global settings or +//! constants; the values here are the as-shipped defaults at the time of +//! writing and what the design discussion below was calibrated against. +//! +//! ## 1. What "overloaded" means — worker-seconds, not jobs +//! +//! A workspace is overloaded when, over a rolling +//! `WORKSPACE_FAIRNESS_DURATION_SECS = 10s` window, it has consumed at least +//! `WORKSPACE_FAIRNESS_MAX_PERCENT = 50%` of cluster worker-time. Activity +//! is measured in **worker-seconds**: each job contributes the wall-clock +//! time it actually held a worker, intersected with the window. A +//! count-based signal — "what fraction of jobs in the window are from this +//! workspace" — gets badly fooled by job-duration heterogeneity: 600 +//! short (100ms) jobs and one long (60s) job consume the same worker-time +//! but the count-based form attributes 600× more weight to the spammy +//! workspace. Worker-seconds put both patterns on the same scale. +//! +//! Two sources contribute to a workspace's worker-second total: +//! +//! - **Running** (live, currently-on-a-worker): driven from `v2_job_runtime` +//! filtered on `ping > now() - WORKER_PING_LIVE_SECS` (60s, ≈ 2× worker +//! heartbeat interval), then PK-joined to `v2_job` for the `kind` filter +//! and `v2_job_queue` for `started_at` / `suspend_until`. Contribution is +//! `clamp(min(now, ping) − max(started_at, window_start), 0, window)`. +//! End-of-interval is the per-job `ping`, which both (a) implements the +//! zombie defense — a worker that stopped pinging stops accruing +//! worker-seconds at its last heartbeat, so a backlog of stuck +//! `running = true` rows can't dominate the denominator — and (b) matches +//! the semantic of "worker-seconds the worker has confirmed". `v2_job_runtime` +//! is small (rows deleted on completion), so driving the scan from there +//! keeps the per-refresh cost bounded by the *in-flight* count rather +//! than by the queue size, even when one workspace has thousands of +//! `running = true` rows. +//! +//! - **Completed** (recently finished): pulled by an index scan over +//! `v2_job_completed (completed_at)`, then PK-joined to `v2_job`. The +//! index hit is critical — see "Why no `WITH params AS (...)` CTE" below. +//! Contribution is `clamp(min(completed_at, now) − max(started_at, +//! completed_at − duration_ms, window_start), 0, window)`. Clamping +//! start-of-interval by `completed_at − duration_ms` defends against +//! zombie rows that `zombie_monitor` force-failed: `started_at` may be +//! far in the past, but `duration_ms` reflects the actual measured worker +//! time, so the row only contributes its real runtime, not the idle wait +//! before force-fail. +//! +//! Both halves exclude **flow-orchestration kinds** +//! (`flow, flowpreview, flownode, singlestepflow`) and **concurrency- +//! suspended rows** (`suspend_until IS NOT NULL`) — these hold +//! `running = true` but consume no worker slot. Same predicate as +//! `handle_zombie_jobs` in `monitor.rs`. +//! +//! `WORKSPACE_FAIRNESS_MIN_TOTAL = 4` is also in worker-seconds (≈ 40 % +//! utilization of one worker over a 10s window) — below the floor, the +//! cluster is too quiet to bother capping anyone. +//! +//! ## 2. The cap is enforced stochastically, weighted by duration +//! +//! The pull dispatch in `jobs.rs` flips a coin on every pull: with +//! probability `p_c` it uses the standard pull query (capped workspaces +//! are admissible — FIFO will pick them if they're at the head), and with +//! probability `1 − p_c` it uses the *fairness pull query* which excludes +//! the overloaded workspaces. Doing it as a probabilistic split rather +//! than a binary cap/uncap gate keeps victim latency flat instead of +//! breathing in/out with each refresh cycle. +//! +//! The key design choice is how `p_c` is set. The natural first try is +//! `p_c = (MAX_PERCENT + ε) / 100` — a constant. That converges the +//! *pull-count* ratio to `MAX_PERCENT`, but only matches the worker-second +//! ratio when capped and uncapped workspaces share the same mean job +//! duration. The steady-state share equation is: +//! +//! `share = p_c · D_c / (p_c · D_c + (1 − p_c) · D_u)` +//! +//! where `D_c` and `D_u` are the per-job mean durations of capped and +//! uncapped workspaces respectively. With `D_c = 34s` and `D_u = 1s` (the +//! exact numbers observed during the lancom01-prod / jps-internal cloud +//! incident), a constant `p_c = 0.65` (60 % + 5 % ε) yields +//! +//! `share = 0.65 · 34 / (0.65 · 34 + 0.35 · 1) = 22.1 / 22.45 ≈ 98%` +//! +//! — i.e., the "60 % cap" was in practice giving capped workspaces 98 % +//! of worker-seconds. Victims were observed waiting 15s+ for pickup +//! despite the cap firing on every pull. +//! +//! Inverting the equation for the desired share `t = (MAX_PERCENT + ε) / 100`: +//! +//! `p_c = t · D_u / ((1 − t) · D_c + t · D_u)` +//! +//! Same numbers, target 0.65: `p_c ≈ 0.054` — about 12× tighter than the +//! count-based form. The refresh computes `p_c` and stores it in +//! [`WORKSPACE_FAIRNESS_ADMISSION_PPM`] (parts-per-10_000, fits in an +//! `AtomicU32`). The pull-time check is one atomic load plus one +//! `rand::rng().random_range(0..10_000)` draw — same hot-path cost as the +//! count-based form. +//! +//! ### `D_c`/`D_u` come from a separate, longer service-time window +//! +//! Crucially, `D_c` and `D_u` must be **true mean service times**, because +//! the share equation above is Little's-law-based +//! (`occupancy = arrival_rate × mean_service_time`). They are **not** taken +//! from the occupancy aggregation: that aggregation clamps each job's +//! contribution to the short occupancy window (`DURATION_SECS`, 10s), so a +//! job longer than the window contributes at most 10s — fine for measuring +//! *share*, but it would truncate `D_c` to ≤ 10s and systematically +//! under-admit the skew exactly when capped jobs are long (the case the cap +//! exists for: e.g. true `D_c = 34s` clamped to 10s gives `p_c ≈ 0.157`, an +//! 86 % effective share instead of 65 %). Instead, the refresh samples true +//! unclamped `duration_ms` of completed jobs over a longer, decoupled +//! service-time window (`DURATION_SAMPLE_SECS`, 60s) — long enough to avoid +//! truncation and to keep the mean stable when few jobs complete within the +//! 10s occupancy window. So the refresh emits two per-workspace signals: +//! windowed occupancy worker-seconds (for classification) and a 60s +//! service-time `(Σ duration_ms, count)` (for admission), merged per +//! workspace. +//! +//! ### Why we kept the fallback when the fairness pull returns empty +//! +//! The 100 − `p_c` % of pulls that try the fairness query (excluding +//! capped workspaces) fall back to the standard query if the fairness +//! query returns no row. The alternative — idle the worker, holding the +//! slot open in case a victim shows up — was considered but rejected for +//! the first iteration: with `p_c` correctly tightened, victims do get the +//! slot they need *when they exist*, and absent victims, falling back to +//! the capped pool is the right behaviour (otherwise the cluster +//! under-utilises itself for no benefit). Adding a reserve-capacity skip +//! is a fine-tuning lever for bursty victim arrival patterns and is left +//! as a follow-up. +//! +//! ### Degenerate cases +//! +//! If either bucket is empty — no capped jobs, no uncapped jobs, or a +//! capped workspace with zero completions in the 60s service-time window +//! (all its jobs still running) — the formula is undefined. The refresh +//! falls back to the count-based `p_c = t` in those cases — it matches +//! the pre-refactor behaviour and is the safest thing to do when there's +//! no service-time signal yet to weight on. +//! +//! ## 3. Coordinated refresh — exactly once per cycle, cluster-wide +//! +//! The aggregation is too expensive to run on every worker process every +//! pull (and would produce no new information on the sub-second +//! timescale). It runs **at most once every `refresh_interval` seconds +//! across the entire fleet**, gated by both a per-process CAS and a +//! DB-side row lock: +//! +//! 1. **Per-process gate** — `maybe_refresh_overloaded` (called from the +//! pull path) does `LAST_REFRESH_MICROS.compare_exchange` to ensure at +//! most one in-flight refresh per process per interval. If the CAS +//! fails or the interval hasn't elapsed yet, the call is a no-op. +//! Cost on the hot path: one atomic load, optionally one CAS. +//! +//! 2. **DB-side claim** — `refresh_overloaded` first does a cheap upsert +//! (`INSERT ... ON CONFLICT ON background_task_state ... WHERE +//! updated_at < NOW() − refresh_interval RETURNING true`). The `VALUES` +//! clause is all constants, so Postgres has no expensive work to do +//! even for losers. Only the unique winner per cycle gets `Some(true)`; +//! losers get `None` and skip the aggregation entirely. +//! +//! 3. **Winner-only aggregation** — the winner runs the +//! `v2_job_runtime ∪ v2_job_completed` worker-second aggregation +//! returning per-workspace `(workspace_id, worker_seconds, jobs)`, +//! classifies into overloaded/uncapped, computes `p_c`, and writes the +//! new payload `{"overloaded": [...], "admission_ppm": N}` back to +//! `background_task_state.workspace_fairness`. +//! +//! 4. **Everyone reads** — winner and losers alike then `SELECT` the +//! current value, parse it, and update their in-process +//! `WORKSPACE_FAIRNESS_OVERLOADED` and `WORKSPACE_FAIRNESS_ADMISSION_PPM` +//! atomics. This is what makes losers eventually see the winner's +//! decision; they just don't pay the aggregation cost. +//! +//! The refresh interval is `ACTIVE_REFRESH_SECS = 2s` when the cluster +//! currently has a capped workspace (faster — we want the cap to lift +//! promptly once load drops) and `IDLE_REFRESH_SECS = 5s` otherwise +//! (slower — minimise DB load during normal operation). The DB-side guard +//! always uses the tighter `ACTIVE_REFRESH_SECS` to bound the race +//! window; the per-process gate enforces the idle cadence. +//! +//! If a refresh fails (DB error, timeout > 5s), `LAST_REFRESH_MICROS` is +//! left set to the attempt's timestamp so the next attempt has to wait a +//! full interval — exactly the same cooldown as a successful refresh. +//! Resetting to `0` on failure would remove the rate limit entirely +//! precisely when DB load is highest, which is the wrong direction. +//! +//! ## 4. Audit logging +//! +//! Workspaces entering or leaving the capped set produce +//! `workspace_fairness.capped` / `workspace_fairness.uncapped` audit +//! entries scoped to the `admins` workspace, with the affected workspace +//! as the `resource` field. Emitted by the refresh winner only, so a +//! transition produces exactly one audit row regardless of fleet size. +//! The "previous list" diffed against is the DB value (not the per-process +//! cache) so a freshly-restarted worker that happens to win the first +//! claim doesn't emit spurious "newly capped" entries for workspaces that +//! were already capped before it started. +//! +//! ## 5. Notable SQL performance constraints +//! +//! - **No `WITH params AS (...)` CTE for `window_start`.** A natural +//! refactor would be to compute `NOW() - make_interval(secs => N)` once +//! in a CTE and reference it in both halves of the UNION. But Postgres +//! *materialises* the CTE and the optimiser can no longer push the +//! `completed_at > window_start` predicate down to the +//! `ix_job_completed_completed_at` index. On the production cloud DB +//! (~12M `v2_job_completed` rows), that turns a 10 ms index scan into a +//! ~47s full table scan. The query intentionally inlines `NOW()` and +//! `NOW() - make_interval(...)` at every callsite. +//! +//! - **Drive running side from `v2_job_runtime`, not `v2_job_queue`.** +//! Naive ordering ("scan v2_job_queue for `running = true`, join v2_job +//! for the kind filter") does a Seq Scan over ~thousands of running-or- +//! bookkeeping rows and does a PK lookup into `v2_job` for every one of +//! them — ~10 ms in prod, but worse: bounded by *queue size*. Pivoting +//! to drive the scan from `v2_job_runtime` filtered on +//! `ping > NOW() - 60s` narrows to the in-flight set (small, deletes- +//! on-completion) *before* any PK lookups: 1.3 ms, 9× less I/O, +//! bounded by *live worker count*. +//! +//! ## 6. Enterprise gating +//! +//! The cap is an Enterprise feature. `windmill-api-settings` rejects +//! `workspace_fairness_enabled = true` writes from non-EE builds, and on a +//! single-tenant self-hosted deployment the default +//! `workspace_fairness_enabled = false` keeps the pull path identical to +//! the pre-fairness baseline. At runtime the dispatch checks the atomic +//! only — when fairness is off, `maybe_refresh_overloaded` drains the +//! cached state in one pull cycle (resetting `WORKSPACE_FAIRNESS_OVERLOADED` +//! to empty and `WORKSPACE_FAIRNESS_ADMISSION_PPM` to 10_000 = "admit all"), +//! so toggling the feature off without restarting workers is safe. #[cfg(feature = "private")] #[allow(unused)] From a7d85a39ff177834e87b7baf444ed19179a14ca9 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Thu, 28 May 2026 18:05:04 +0200 Subject: [PATCH 289/313] refactor: clean up ai provider proxy logic (#9360) * refactor: clean up ai provider proxy logic * docs: remove completed ai refactor plan * fix: audit failed google global proxy calls --- backend/windmill-ai/src/ai_bedrock.rs | 36 ++ backend/windmill-ai/src/credentials.rs | 25 + backend/windmill-ai/src/lib.rs | 1 + .../windmill-ai/src/providers/anthropic.rs | 3 +- backend/windmill-ai/src/providers/bedrock.rs | 356 +++++++++----- .../windmill-ai/src/providers/google_ai.rs | 2 +- backend/windmill-ai/src/providers/mod.rs | 4 +- backend/windmill-ai/src/proxy.rs | 28 +- backend/windmill-ai/src/proxy/fim.rs | 120 +++++ backend/windmill-ai/src/types.rs | 2 +- backend/windmill-api/src/ai.rs | 188 ++++---- backend/windmill-worker/src/ai/mod.rs | 2 +- ...y_builder.rs => stream_event_processor.rs} | 0 backend/windmill-worker/src/ai/tools.rs | 2 +- backend/windmill-worker/src/ai_executor.rs | 2 +- docs/windmill-ai-refactor-plan.md | 441 ------------------ 16 files changed, 510 insertions(+), 702 deletions(-) create mode 100644 backend/windmill-ai/src/credentials.rs create mode 100644 backend/windmill-ai/src/proxy/fim.rs rename backend/windmill-worker/src/ai/{query_builder.rs => stream_event_processor.rs} (100%) delete mode 100644 docs/windmill-ai-refactor-plan.md diff --git a/backend/windmill-ai/src/ai_bedrock.rs b/backend/windmill-ai/src/ai_bedrock.rs index c05094b8f4..e549e63041 100644 --- a/backend/windmill-ai/src/ai_bedrock.rs +++ b/backend/windmill-ai/src/ai_bedrock.rs @@ -754,6 +754,26 @@ pub fn bedrock_stream_event_to_tool_start( } } +pub fn bedrock_stream_event_to_tool_start_with_block_index( + event: &ConverseStreamOutput, +) -> Option<(usize, StreamingToolCall)> { + match event { + ConverseStreamOutput::ContentBlockStart(start) => { + let block_index = usize::try_from(start.content_block_index()).ok()?; + let tool_use = start.start().and_then(|s| s.as_tool_use().ok())?; + Some(( + block_index, + StreamingToolCall { + id: tool_use.tool_use_id().to_string(), + name: tool_use.name().to_string(), + arguments: String::new(), + }, + )) + } + _ => None, + } +} + /// Extract tool use input delta from stream pub fn bedrock_stream_event_to_tool_delta(event: &ConverseStreamOutput) -> Option { match event { @@ -765,6 +785,22 @@ pub fn bedrock_stream_event_to_tool_delta(event: &ConverseStreamOutput) -> Optio } } +pub fn bedrock_stream_event_to_tool_delta_with_block_index( + event: &ConverseStreamOutput, +) -> Option<(usize, String)> { + match event { + ConverseStreamOutput::ContentBlockDelta(delta) => { + let block_index = usize::try_from(delta.content_block_index()).ok()?; + let input = delta + .delta() + .and_then(|d| d.as_tool_use().ok()) + .map(|tool_use| tool_use.input().to_string())?; + Some((block_index, input)) + } + _ => None, + } +} + /// Check if stream event indicates content block stop pub fn bedrock_stream_event_is_block_stop(event: &ConverseStreamOutput) -> bool { matches!(event, ConverseStreamOutput::ContentBlockStop(_)) diff --git a/backend/windmill-ai/src/credentials.rs b/backend/windmill-ai/src/credentials.rs new file mode 100644 index 0000000000..75d523cf25 --- /dev/null +++ b/backend/windmill-ai/src/credentials.rs @@ -0,0 +1,25 @@ +use std::collections::HashMap; + +use crate::ai_providers::{AIPlatform, AIProvider}; + +/// Resolved provider credentials shared by API proxy and worker execution. +/// +/// 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, + pub base_url: String, + pub api_key: Option, + pub access_token: Option, + pub organization_id: Option, + pub user: Option, + pub region: Option, + pub aws_access_key_id: Option, + pub aws_secret_access_key: Option, + pub aws_session_token: Option, + pub platform: AIPlatform, + pub enable_1m_context: bool, + pub custom_headers: HashMap, +} diff --git a/backend/windmill-ai/src/lib.rs b/backend/windmill-ai/src/lib.rs index a138d72f3c..b6487c0ac5 100644 --- a/backend/windmill-ai/src/lib.rs +++ b/backend/windmill-ai/src/lib.rs @@ -4,6 +4,7 @@ pub mod ai_cache; pub mod ai_google; pub mod ai_providers; pub mod ai_types; +pub mod credentials; pub mod image_handler; pub mod providers; pub mod proxy; diff --git a/backend/windmill-ai/src/providers/anthropic.rs b/backend/windmill-ai/src/providers/anthropic.rs index 8a8a2b846d..17a27e370f 100644 --- a/backend/windmill-ai/src/providers/anthropic.rs +++ b/backend/windmill-ai/src/providers/anthropic.rs @@ -729,8 +729,7 @@ impl QueryBuilder for AnthropicQueryBuilder { mod tests { use super::*; use crate::{ - proxy::{ProviderCredentials, ProxyBuildArgs}, - query_builder::QueryBuilder, + credentials::ProviderCredentials, proxy::ProxyBuildArgs, query_builder::QueryBuilder, }; use http::{HeaderMap, HeaderValue, Method}; use std::collections::HashMap; diff --git a/backend/windmill-ai/src/providers/bedrock.rs b/backend/windmill-ai/src/providers/bedrock.rs index ec6f4fbcd3..0eef359c36 100644 --- a/backend/windmill-ai/src/providers/bedrock.rs +++ b/backend/windmill-ai/src/providers/bedrock.rs @@ -10,9 +10,10 @@ 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, + bedrock_stream_event_to_tool_delta_with_block_index, bedrock_stream_event_to_tool_start, + bedrock_stream_event_to_tool_start_with_block_index, 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}, @@ -403,137 +404,15 @@ pub fn sdk_stream_to_sse( .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(); + let mut state = BedrockSseStreamState::new(id, model, created); 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))); + for chunk in bedrock_sse_chunks_for_event(&event, &mut state) { + yield Ok(chunk); } } Ok(None) => break, @@ -551,6 +430,149 @@ pub fn sdk_stream_to_sse( } } +#[derive(Debug)] +struct BedrockSseStreamState { + id: String, + model: String, + created: u64, + tool_calls: HashMap, + tool_block_indexes: HashMap, + next_tool_index: usize, +} + +impl BedrockSseStreamState { + fn new(id: String, model: String, created: u64) -> Self { + Self { + id, + model, + created, + tool_calls: HashMap::new(), + tool_block_indexes: HashMap::new(), + next_tool_index: 0, + } + } +} + +fn bedrock_sse_chunks_for_event( + event: &aws_sdk_bedrockruntime::types::ConverseStreamOutput, + state: &mut BedrockSseStreamState, +) -> Vec { + let mut chunks = Vec::new(); + + if let Some((block_index, tool_call)) = + bedrock_stream_event_to_tool_start_with_block_index(event) + { + let index = state.next_tool_index; + state.next_tool_index += 1; + state.tool_block_indexes.insert(block_index, 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 + }] + }); + + chunks.push(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 + }] + }); + + chunks.push(Bytes::from(format!("data: {}\n\n", chunk))); + } + + if let Some((block_index, input_delta)) = + bedrock_stream_event_to_tool_delta_with_block_index(event) + { + if let Some(index) = state.tool_block_indexes.get(&block_index).copied() { + 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 + }] + }); + + chunks.push(Bytes::from(format!("data: {}\n\n", chunk))); + } + } + } + + 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 + }] + }); + + chunks.push(Bytes::from(format!("data: {}\n\n", chunk))); + } + + chunks +} + async fn handle_bedrock_sdk_non_streaming( model: &str, body: &[u8], @@ -970,6 +992,19 @@ impl BedrockQueryBuilder { #[cfg(test)] mod tests { use super::*; + use aws_sdk_bedrockruntime::types::{ + ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStart, ContentBlockStartEvent, + ContentBlockStopEvent, ConverseStreamOutput, ToolUseBlockDelta, ToolUseBlockStart, + }; + + fn sse_json(chunk: &Bytes) -> serde_json::Value { + let chunk = std::str::from_utf8(chunk).expect("SSE chunk should be UTF-8"); + let payload = chunk + .strip_prefix("data: ") + .and_then(|chunk| chunk.strip_suffix("\n\n")) + .expect("chunk should be SSE data"); + serde_json::from_str(payload).expect("chunk should contain JSON") + } #[test] fn determine_auth_config_prioritizes_bearer_token() { @@ -1022,4 +1057,69 @@ mod tests { let config = determine_auth_config(None, Some("AKIA123"), None, Some("session-token")); assert!(matches!(config, BedrockAuthConfig::Environment)); } + + #[test] + fn bedrock_sse_tool_indexes_ignore_text_block_stops() { + let mut state = + BedrockSseStreamState::new("chatcmpl-test".to_string(), "model".to_string(), 1); + + let text_delta = ConverseStreamOutput::ContentBlockDelta( + ContentBlockDeltaEvent::builder() + .content_block_index(0) + .delta(ContentBlockDelta::Text("hello".to_string())) + .build() + .unwrap(), + ); + assert_eq!( + bedrock_sse_chunks_for_event(&text_delta, &mut state).len(), + 1 + ); + + let text_stop = ConverseStreamOutput::ContentBlockStop( + ContentBlockStopEvent::builder() + .content_block_index(0) + .build() + .unwrap(), + ); + assert!(bedrock_sse_chunks_for_event(&text_stop, &mut state).is_empty()); + + let tool_start = ConverseStreamOutput::ContentBlockStart( + ContentBlockStartEvent::builder() + .content_block_index(1) + .start(ContentBlockStart::ToolUse( + ToolUseBlockStart::builder() + .tool_use_id("call_1") + .name("lookup") + .build() + .unwrap(), + )) + .build() + .unwrap(), + ); + let start_chunks = bedrock_sse_chunks_for_event(&tool_start, &mut state); + let start_json = sse_json(&start_chunks[0]); + assert_eq!( + start_json["choices"][0]["delta"]["tool_calls"][0]["index"], + 0 + ); + + let tool_delta = ConverseStreamOutput::ContentBlockDelta( + ContentBlockDeltaEvent::builder() + .content_block_index(1) + .delta(ContentBlockDelta::ToolUse( + ToolUseBlockDelta::builder() + .input("{\"city\":\"Paris\"}") + .build() + .unwrap(), + )) + .build() + .unwrap(), + ); + let delta_chunks = bedrock_sse_chunks_for_event(&tool_delta, &mut state); + let delta_json = sse_json(&delta_chunks[0]); + assert_eq!( + delta_json["choices"][0]["delta"]["tool_calls"][0]["index"], + 0 + ); + } } diff --git a/backend/windmill-ai/src/providers/google_ai.rs b/backend/windmill-ai/src/providers/google_ai.rs index 57abae5182..cf00ddc142 100644 --- a/backend/windmill-ai/src/providers/google_ai.rs +++ b/backend/windmill-ai/src/providers/google_ai.rs @@ -691,7 +691,7 @@ impl QueryBuilder for GoogleAIQueryBuilder { #[cfg(test)] mod tests { use super::*; - use crate::{ai_providers::AIProvider, proxy::ProviderCredentials}; + use crate::{ai_providers::AIProvider, credentials::ProviderCredentials}; use std::collections::HashMap; fn credentials(base_url: &str, platform: AIPlatform) -> ProviderCredentials { diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index f3f4a37478..fb50707221 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -6,7 +6,9 @@ pub mod openai; pub mod openrouter; pub mod other; -use crate::{ai_providers::AIProvider, proxy::ProviderCredentials, query_builder::QueryBuilder}; +use crate::{ + ai_providers::AIProvider, credentials::ProviderCredentials, query_builder::QueryBuilder, +}; use self::{ anthropic::AnthropicQueryBuilder, google_ai::GoogleAIQueryBuilder, openai::OpenAIQueryBuilder, diff --git a/backend/windmill-ai/src/proxy.rs b/backend/windmill-ai/src/proxy.rs index 2600999e1c..32ba35cd6d 100644 --- a/backend/windmill-ai/src/proxy.rs +++ b/backend/windmill-ai/src/proxy.rs @@ -4,30 +4,11 @@ use http::{HeaderMap, Method}; use serde_json::value::RawValue; use windmill_common::error::{Error, Result}; -use crate::ai_providers::{AIPlatform, AIProvider}; +use crate::ai_providers::AIProvider; +use crate::credentials::ProviderCredentials; use crate::utils::AI_HTTP_HEADERS; -/// Resolved provider credentials shared by API proxy and worker execution. -/// -/// 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, - pub base_url: String, - pub api_key: Option, - pub access_token: Option, - pub organization_id: Option, - pub user: Option, - pub region: Option, - pub aws_access_key_id: Option, - pub aws_secret_access_key: Option, - pub aws_session_token: Option, - pub platform: AIPlatform, - pub enable_1m_context: bool, - pub custom_headers: HashMap, -} +pub mod fim; /// Inputs needed to transform an OpenAI-compatible proxy request for a provider. pub struct ProxyBuildArgs<'a> { @@ -167,6 +148,9 @@ pub(crate) fn add_user_to_body(body: &[u8], user: &str) -> Result> { #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; + + use crate::ai_providers::AIPlatform; fn credentials(provider: AIProvider, base_url: &str) -> ProviderCredentials { ProviderCredentials { diff --git a/backend/windmill-ai/src/proxy/fim.rs b/backend/windmill-ai/src/proxy/fim.rs new file mode 100644 index 0000000000..2d14fd23ce --- /dev/null +++ b/backend/windmill-ai/src/proxy/fim.rs @@ -0,0 +1,120 @@ +use bytes::Bytes; +use serde::Deserialize; +use serde_json::json; +use windmill_common::error::{Error, Result}; + +use crate::ai_providers::AIProvider; + +#[derive(Debug, Eq, PartialEq)] +pub struct FimProxyTransform { + pub body: Bytes, + pub path: String, +} + +#[derive(Deserialize)] +struct FimRequest { + model: String, + prompt: String, + suffix: Option, + temperature: Option, + max_tokens: Option, + stop: Option>, +} + +pub fn supports_native_fim(provider: &AIProvider) -> bool { + matches!(provider, AIProvider::Mistral) +} + +pub fn maybe_transform_fim_request( + provider: &AIProvider, + path: &str, + body: &[u8], +) -> Result> { + if path.contains("fim/completions") && !supports_native_fim(provider) { + transform_fim_to_chat_completions(body).map(Some) + } else { + Ok(None) + } +} + +fn transform_fim_to_chat_completions(body: &[u8]) -> Result { + let fim_req: FimRequest = serde_json::from_slice(body) + .map_err(|e| Error::BadRequest(format!("Failed to parse FIM request: {}", e)))?; + + let suffix = fim_req.suffix.unwrap_or_default(); + + let system_prompt = "You are a code completion assistant. Complete the code at the position between the given prefix and suffix. Output ONLY the code that goes at the cursor - no explanations, no markdown, no repeating the prefix or suffix."; + + let user_content = format!( + "\n{}\n\n\n{}", + fim_req.prompt, suffix + ); + + let chat_req = json!({ + "model": fim_req.model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_content} + ], + "temperature": fim_req.temperature.unwrap_or(0.0), + "max_tokens": fim_req.max_tokens.unwrap_or(256), + "stop": fim_req.stop + }); + + let body = serde_json::to_vec(&chat_req) + .map_err(|e| Error::internal_err(format!("Failed to serialize chat request: {}", e)))?; + + Ok(FimProxyTransform { body: Bytes::from(body), path: "chat/completions".to_string() }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mistral_keeps_native_fim_request() { + let transformed = + maybe_transform_fim_request(&AIProvider::Mistral, "fim/completions", br#"{}"#).unwrap(); + + assert!(transformed.is_none()); + assert!(supports_native_fim(&AIProvider::Mistral)); + } + + #[test] + fn openai_fim_request_is_transformed_to_chat_completion() { + let transformed = maybe_transform_fim_request( + &AIProvider::OpenAI, + "fim/completions", + br#"{ + "model": "gpt-4.1", + "prompt": "fn main() {", + "suffix": "}", + "stop": ["\n\n"] + }"#, + ) + .unwrap() + .expect("OpenAI FIM should be transformed"); + + assert_eq!(transformed.path, "chat/completions"); + + let body: serde_json::Value = serde_json::from_slice(&transformed.body).unwrap(); + assert_eq!(body["model"], "gpt-4.1"); + assert_eq!(body["temperature"], 0.0); + assert_eq!(body["max_tokens"], 256); + assert_eq!(body["stop"], serde_json::json!(["\n\n"])); + assert_eq!(body["messages"][1]["role"], "user"); + assert_eq!( + body["messages"][1]["content"], + "\nfn main() {\n\n\n}" + ); + } + + #[test] + fn invalid_fim_body_is_bad_request() { + let err = + maybe_transform_fim_request(&AIProvider::OpenAI, "fim/completions", br#"{"model": 1}"#) + .unwrap_err(); + + assert!(matches!(err, Error::BadRequest(_))); + } +} diff --git a/backend/windmill-ai/src/types.rs b/backend/windmill-ai/src/types.rs index 1d18e2411c..56a796e41d 100644 --- a/backend/windmill-ai/src/types.rs +++ b/backend/windmill-ai/src/types.rs @@ -18,7 +18,7 @@ pub struct McpToolSource { use crate::{ ai_google::sanitize_schema_for_google, ai_providers::{empty_string_as_none, AIProvider}, - proxy::ProviderCredentials, + credentials::ProviderCredentials, }; use windmill_common::{db::DB, error::Error, flow_status::AgentAction, flows::FlowModule}; use windmill_parser::Typ; diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 6956192f04..81f601e53f 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -11,13 +11,14 @@ use http::{HeaderMap, Method}; use quick_cache::sync::Cache; use reqwest::{Client, RequestBuilder}; use serde::{Deserialize, Serialize}; -use serde_json::{json, value::RawValue}; +use serde_json::value::RawValue; use std::collections::HashMap; use std::time::Duration; use windmill_ai::ai_cache::current_instance_ai_config_revision; use windmill_ai::ai_providers::{ empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel, }; +use windmill_ai::credentials::ProviderCredentials; #[cfg(feature = "bedrock")] use windmill_ai::providers::bedrock::{ handle_bedrock_proxy, BedrockProxyResponse, BedrockProxyResponseBody, @@ -30,10 +31,9 @@ use windmill_ai::providers::{ }, }; use windmill_ai::proxy::{ - proxy_execution_mode, supports_query_builder_proxy, ProviderCredentials, ProxyBuildArgs, - ProxyExecutionMode, ProxyRequest, + fim::maybe_transform_fim_request, proxy_execution_mode, ProxyBuildArgs, ProxyExecutionMode, + ProxyRequest, }; -use windmill_ai::utils::AI_HTTP_HEADERS; use windmill_audit::{audit_oss::audit_log, ActionKind}; use windmill_common::db::UserDB; use windmill_common::error::{to_anyhow, Error, Result}; @@ -369,53 +369,6 @@ impl AIConfig { } } -// FIM (Fill-in-the-Middle) simulation for providers that don't support native FIM -#[derive(Deserialize, Debug)] -struct FimRequest { - model: String, - prompt: String, // code before cursor - suffix: Option, // code after cursor - temperature: Option, - max_tokens: Option, - stop: Option>, -} - -/// Checks if the AI provider supports native FIM (Fill-in-the-Middle) endpoint -fn supports_native_fim(provider: &AIProvider) -> bool { - matches!(provider, AIProvider::Mistral) -} - -/// Transforms a FIM request to chat/completions format for providers that don't support native FIM. -fn transform_fim_to_chat_completions(body: &Bytes) -> Result<(Bytes, String)> { - let fim_req: FimRequest = serde_json::from_slice(body) - .map_err(|e| Error::internal_err(format!("Failed to parse FIM request: {}", e)))?; - - let suffix = fim_req.suffix.unwrap_or_default(); - - let system_prompt = "You are a code completion assistant. Complete the code at the position between the given prefix and suffix. Output ONLY the code that goes at the cursor - no explanations, no markdown, no repeating the prefix or suffix."; - - let user_content = format!( - "\n{}\n\n\n{}", - fim_req.prompt, suffix - ); - - let chat_req = json!({ - "model": fim_req.model, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_content} - ], - "temperature": fim_req.temperature.unwrap_or(0.0), - "max_tokens": fim_req.max_tokens.unwrap_or(256), - "stop": fim_req.stop - }); - - let chat_body = serde_json::to_vec(&chat_req) - .map_err(|e| Error::internal_err(format!("Failed to serialize chat request: {}", e)))?; - - Ok((Bytes::from(chat_body), "chat/completions".to_string())) -} - pub fn global_service() -> Router { Router::new().route("/proxy/{*ai}", post(global_proxy).get(global_proxy)) } @@ -455,6 +408,24 @@ fn proxy_request_to_request_builder(proxy_request: ProxyRequest) -> RequestBuild request.body(proxy_request.body) } +async fn audit_global_ai_request(db: &DB, authed: &ApiAuthed) -> Result<()> { + let mut tx = db.begin().await?; + + audit_log( + &mut *tx, + authed, + "ai.global_request", + ActionKind::Execute, + "global", + Some(&authed.email), + None, + ) + .await?; + tx.commit().await?; + + Ok(()) +} + fn google_ai_proxy_response_to_body( response: GoogleAIProxyResponse, ) -> (http::StatusCode, HeaderMap, axum::body::Body) { @@ -530,63 +501,78 @@ async fn global_proxy( return Err(Error::BadRequest("API key is required".to_string())); }; - let base_url = provider.get_base_url(None, &db).await?; + let proxy_mode = proxy_execution_mode(&provider); - let request = if supports_query_builder_proxy(&provider) { - let credentials = ProviderCredentials { - provider: provider.clone(), - base_url, - api_key: Some(api_key.clone()), - access_token: None, - organization_id: None, - user: None, - 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(), - }; - let query_builder = create_query_builder(&credentials); - let proxy_request = query_builder.build_proxy_request(&ProxyBuildArgs { + if matches!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock) { + return Err(Error::BadRequest( + "AWS Bedrock global proxy is not supported; use a workspace AI resource with a region" + .to_string(), + )); + } + + let base_url = provider.get_base_url(None, &db).await?; + let credentials = ProviderCredentials { + provider: provider.clone(), + base_url, + api_key: Some(api_key.clone()), + access_token: None, + organization_id: None, + user: None, + 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(), + }; + + if matches!(proxy_mode, ProxyExecutionMode::NativeGoogleAi) { + let proxy_args = ProxyBuildArgs { method: &method, path: &ai_path, headers: &headers, body: &body, credentials: &credentials, - })?; - proxy_request_to_request_builder(proxy_request) - } else { - let url = format!("{}/{}", base_url, ai_path); - let mut request = HTTP_CLIENT - .request(method, url) - .header("content-type", "application/json") - .header("Authorization", format!("Bearer {}", &api_key)); + }; - // Apply custom headers from AI_HTTP_HEADERS environment variable - for (header_name, header_value) in AI_HTTP_HEADERS.iter() { - request = request.header(header_name.as_str(), header_value.as_str()); + audit_global_ai_request(&db, &authed).await?; + + let response = match ai_path.as_str() { + "chat/completions" => handle_google_ai_chat_proxy(&HTTP_CLIENT, &proxy_args).await, + "models" => handle_google_ai_models_proxy(&HTTP_CLIENT, &proxy_args).await, + _ => Err(Error::BadRequest(format!( + "Unsupported Google AI path: {}", + ai_path + ))), + }?; + + return Ok(google_ai_proxy_response_to_body(response)); + } + + let request = match proxy_mode { + ProxyExecutionMode::HttpForward => { + let query_builder = create_query_builder(&credentials); + let proxy_request = query_builder.build_proxy_request(&ProxyBuildArgs { + method: &method, + path: &ai_path, + headers: &headers, + body: &body, + credentials: &credentials, + })?; + proxy_request_to_request_builder(proxy_request) + } + ProxyExecutionMode::NativeGoogleAi | ProxyExecutionMode::NativeAwsBedrock => { + return Err(Error::BadRequest(format!( + "Unsupported global proxy mode for provider {:?}", + provider + ))) } - - request.body(body) }; let response = request.send().await.map_err(to_anyhow)?; - let mut tx = db.begin().await?; - - audit_log( - &mut *tx, - &authed, - "ai.global_request", - ActionKind::Execute, - "global", - Some(&authed.email), - None, - ) - .await?; - tx.commit().await?; + audit_global_ai_request(&db, &authed).await?; if response.error_for_status_ref().is_err() { let err_msg = response.text().await.unwrap_or("".to_string()); @@ -772,17 +758,13 @@ async fn proxy( } }; - // Check if this is a FIM request to a provider that doesn't support native FIM endpoint - // For such providers, transform to use FIM sentinel tokens with the chat/completions endpoint - let is_fim_request = ai_path.contains("fim/completions"); - if is_fim_request && !supports_native_fim(&provider) { + if let Some(fim_transform) = maybe_transform_fim_request(&provider, &ai_path, &body)? { tracing::debug!( "Transforming FIM request to chat/completions with FIM tokens for provider {:?}", provider ); - let (chat_body, chat_path) = transform_fim_to_chat_completions(&body)?; - body = chat_body; - ai_path = chat_path; + body = fim_transform.body; + ai_path = fim_transform.path; } let proxy_mode = proxy_execution_mode(&provider); diff --git a/backend/windmill-worker/src/ai/mod.rs b/backend/windmill-worker/src/ai/mod.rs index 24e877ab13..ad0986bbea 100644 --- a/backend/windmill-worker/src/ai/mod.rs +++ b/backend/windmill-worker/src/ai/mod.rs @@ -1,6 +1,6 @@ // AI executor module structure // This module will contain all AI-related execution logic -pub mod query_builder; +pub mod stream_event_processor; pub mod tools; pub mod utils; diff --git a/backend/windmill-worker/src/ai/query_builder.rs b/backend/windmill-worker/src/ai/stream_event_processor.rs similarity index 100% rename from backend/windmill-worker/src/ai/query_builder.rs rename to backend/windmill-worker/src/ai/stream_event_processor.rs diff --git a/backend/windmill-worker/src/ai/tools.rs b/backend/windmill-worker/src/ai/tools.rs index fcc9cdf3c9..11100d4888 100644 --- a/backend/windmill-worker/src/ai/tools.rs +++ b/backend/windmill-worker/src/ai/tools.rs @@ -1,4 +1,4 @@ -use crate::ai::query_builder::StreamEventProcessor; +use crate::ai::stream_event_processor::StreamEventProcessor; use crate::ai::utils::{ add_message_to_conversation, execute_mcp_tool, get_step_name_from_flow, is_completed_input_transform, update_flow_status_module_with_actions, diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 49c24eac2d..b2478e3be1 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -45,7 +45,7 @@ use windmill_common::{ use windmill_queue::{cancel_single_job, CanceledBy, MiniPulledJob}; use crate::{ - ai::query_builder::StreamEventProcessor, + ai::stream_event_processor::StreamEventProcessor, common::{build_args_map, resolve_job_timeout, OccupancyMetrics, StreamNotifier}, handle_child::{run_future_with_polling_update_job_poller_graceful, GracefulPollOutcome}, }; diff --git a/docs/windmill-ai-refactor-plan.md b/docs/windmill-ai-refactor-plan.md deleted file mode 100644 index 6dcd2d7605..0000000000 --- a/docs/windmill-ai-refactor-plan.md +++ /dev/null @@ -1,441 +0,0 @@ -# Refactor Plan: `windmill-ai` Crate - -## Context - -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 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. 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 - -``` -windmill-ai → windmill-common (for DB, Error, AgentAction, AuthedClient, etc.) - → windmill-types (for S3Object) - → windmill-parser (for Typ, used in OpenAPISchema) - -windmill-api → windmill-ai -windmill-worker → windmill-ai -``` - -windmill-common does **NOT** re-export from windmill-ai (would be circular). All consumers update imports. - -## Reviewer Note: Keep API Proxy Unification Split - -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. -- Move the OpenAI-compatible proxy path into `windmill-ai` next, while keeping provider-native behavior unchanged. -- Move Anthropic/Vertex, Google AI, and Bedrock in separate follow-up PRs. -- Unify credential resolution only after all proxy request builders use the shared shape. - -Avoid adding modules whose only purpose is to re-export moved code. Direct imports from `windmill_ai` make ownership and dependency direction clearer at each call site. - -Also do not make `build_proxy_request(raw_body, path)` too narrow. The proxy path needs method, incoming headers, resolved credentials, base URL/platform, organization/user fields, custom headers, and Bedrock/Azure/Vertex-specific context. Introduce a structured `ProxyBuildArgs`/`ProviderCredentials` shape before deleting `AIRequestConfig::prepare_request`, `google.rs`, or `bedrock.rs`. - -## Completed Phase: Proxy Contract + OpenAI-Compatible Proxy ✅ - -Goal: introduce the shared API proxy contract in `windmill-ai` and move the OpenAI-compatible proxy request builder there without changing provider behavior. - -Suggested PR title: `refactor(ai): move openai-compatible proxy building to windmill-ai`. - -Scope: -- Add `windmill-ai/src/proxy.rs` and export it from `lib.rs`. -- Define `ProviderCredentials`, `ProxyBuildArgs`, and `ProxyRequest`. -- Include all context known to be needed by the current API proxy path: method, path, incoming headers, body, provider, base URL, API key, OAuth access token, organization/user fields, platform, 1M context flag, custom headers, region, and AWS credentials. -- Add a conversion from API-side `AIRequestConfig` to `ProviderCredentials`. -- Add `QueryBuilder::build_proxy_request` with a default unsupported-provider implementation. -- Implement `build_proxy_request` for OpenAI-compatible providers (`OpenAI`, `AzureOpenAI`, `Mistral`, `DeepSeek`, `Groq`, `OpenRouter`, `TogetherAI`, `CustomAI`). -- Route workspace and global API proxy requests for OpenAI-compatible providers through `windmill-ai`. -- Keep FIM transformation in `windmill-api` before calling the proxy builder. -- Keep `AIRequestConfig::prepare_request` for Anthropic/Vertex and remaining fallback paths. - -Out of scope: -- Do not move Anthropic/Vertex proxy behavior yet. -- Do not move Google AI or Bedrock proxy behavior yet. -- Do not change credential resolution, audit logging, cache behavior, SSE keepalive behavior, or Bedrock/Google special cases. -- Do not remove `windmill-api/src/google.rs`, `windmill-api/src/bedrock.rs`, or `AIRequestConfig::prepare_request`. - -Validation: -- `cargo test -p windmill-ai proxy` -- `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` - -Follow-up status: Anthropic/Vertex proxy handling has since moved into -`windmill-ai`, and the dead `AIRequestConfig::prepare_request` fallback has -been removed. - -## 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 -such as OpenAI-compatible providers and Anthropic, but Google AI also converts -responses back to OpenAI shape and Bedrock uses SDK execution. Model that split -explicitly before moving those providers, then move the Google AI proxy -transformation into `windmill-ai` as the first native-provider migration. - -Suggested PR title: `refactor(ai): add provider proxy execution mode`. - -Scope: -- Add `ProxyExecutionMode` in `windmill-ai::proxy`. -- Classify providers as HTTP-forwarding, native Google AI, or native Bedrock. -- Make `supports_query_builder_proxy` derive from the shared execution mode. -- Use the shared execution mode in `windmill-api/src/ai.rs` for workspace proxy routing. -- Move Google AI workspace proxy request conversion, streaming/non-streaming response conversion, and model-list normalization into `windmill-ai::providers::google_ai`. -- Share Google AI `GeminiTextRequest` and generation-config construction between worker agent requests and API proxy requests. -- Delete the API-local `windmill-api/src/google.rs` module. -- Keep global proxy behavior, Bedrock native handling, credential resolution, audit logging, caching, and SSE keepalive behavior unchanged. - -Out of scope: -- Do not move `windmill-api/src/bedrock.rs`. -- Do not unify `AIRequestConfig` and `ProviderWithResource`. - -Validation: -- `cargo test -p windmill-ai google_ai` -- `cargo test -p windmill-ai proxy` -- `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. - ---- - -### Step 1: Create `windmill-ai` crate, move base types from windmill-common ✅ - -Create `backend/windmill-ai/Cargo.toml` and `backend/windmill-ai/src/lib.rs`. - -Move from `windmill-common/src/` to `windmill-ai/src/`: -- `ai_types.rs` — OpenAI-compatible message types -- `ai_providers.rs` — `AIProvider` enum, `AIPlatform`, base URLs, `ProviderConfig` -- `ai_google.rs` — Gemini types and OpenAI↔Gemini conversion -- `ai_bedrock.rs` — Bedrock SDK wrapper (feature-gated on `bedrock`) -- `ai_cache.rs` — instance AI config revision tracking - -Update all imports (`windmill_common::ai_*` → `windmill_ai::ai_*`). - ---- - -### Step 2: Move worker AI types to windmill-ai ✅ - -Move from `windmill-worker/src/ai/types.rs` to `windmill-ai/src/types.rs`: -- `ProviderWithResource`, `ProviderResource` — credential types -- `TokenUsage` — token usage tracking -- `OutputType`, `SchemaType`, `AdditionalProperties` — output configuration -- `OpenAPISchema` — tool parameter schema (depends on `windmill-parser::Typ`) -- `Tool`, `Message`, `ResponseFormat`, `JsonSchemaFormat` — agent types -- `StreamingEvent` — SSE event enum -- `AIAgentArgs`, `AIAgentArgsRaw`, `AIAgentResult` — agent job args -- `Memory` — agent memory enum -- `S3ObjectWithType` — S3 image type -- `McpToolSource` stub (with same `#[cfg(feature = "mcp")]` pattern) - -Worker `ai/types.rs` becomes a re-export: `pub use windmill_ai::types::*`. - ---- - -### Step 3: Move QueryBuilder trait, ParsedResponse, and StreamEventSink abstraction to windmill-ai ✅ - -Move from `windmill-worker/src/ai/query_builder.rs` to `windmill-ai/src/query_builder.rs`: -- `BuildRequestArgs` struct -- `ParsedResponse` enum -- `QueryBuilder` trait (with all existing methods) - -New `StreamEventSink` trait in windmill-ai: -```rust -#[async_trait] -pub trait StreamEventSink: Send + Sync { - async fn send(&self, event: StreamingEvent, events_str: &mut String) -> Result<(), Error>; -} -``` - -`StreamEventSink` abstracts the worker's `StreamEventProcessor` so windmill-ai doesn't depend on windmill-queue or the worker's job logger. The worker's `StreamEventProcessor` implements `StreamEventSink`. All provider `parse_streaming_response` methods and SSE parsers accept `Box`. - ---- - -### Step 4: Move SSE parsers to windmill-ai ✅ - -Move from `windmill-worker/src/ai/sse.rs` to `windmill-ai/src/sse.rs`: -- `SSEParser` trait -- `OpenAISSEParser`, `AnthropicSSEParser`, `GeminiSSEParser`, `OpenAIResponsesSSEParser` -- All associated types (delta types, usage types, etc.) - ---- - -### Step 5: Move provider implementations to windmill-ai ✅ - -Move from `windmill-worker/src/ai/providers/` to `windmill-ai/src/providers/`: -- `anthropic.rs` — `AnthropicQueryBuilder` -- `openai.rs` — `OpenAIQueryBuilder` -- `google_ai.rs` — `GoogleAIQueryBuilder` -- `bedrock.rs` — `BedrockQueryBuilder` (feature-gated) -- `other.rs` — `OtherQueryBuilder` (Mistral, DeepSeek, Groq, TogetherAI, CustomAI) -- `openrouter.rs` — `OpenRouterQueryBuilder` -- `mod.rs` with `create_query_builder` factory - -Move utility functions providers depend on: -- `should_use_structured_output_tool` (from `utils.rs`) -- `extract_text_content` (from `utils.rs`) - ---- - -### Step 6: Move image_handler to windmill-ai ✅ - -Move from `windmill-worker/src/ai/image_handler.rs` to `windmill-ai/src/image_handler.rs`: -- `download_and_encode_s3_image` — no signature change needed -- `prepare_messages_for_api` — no signature change needed -- `upload_image_to_s3` — **refactor**: `(base64_image, workspace_id, job_id, client)` instead of `(base64_image, &MiniPulledJob, client)` to remove windmill-queue dependency - ---- - -### Step 7: Move shared utilities to windmill-ai ✅ - -Move `AI_HTTP_HEADERS` lazy_static (currently duplicated in `windmill-api/src/ai.rs` and `windmill-worker/src/ai_executor.rs`) to `windmill_ai::utils`. Both consumers import from windmill-ai. - ---- - -### Step 8: Add API proxy execution support to windmill-ai ✅ - -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. -/// Used by the API chat proxy. Handles format conversion for non-OpenAI providers. -fn build_proxy_request( - &self, - args: &ProxyBuildArgs<'_>, -) -> Result; -``` - -Where `ProxyBuildArgs` carries the API proxy context that provider implementations need: -```rust -pub struct ProxyBuildArgs<'a> { - pub method: &'a http::Method, - pub path: &'a str, - pub headers: &'a http::HeaderMap, - pub body: &'a [u8], - pub credentials: &'a ProviderCredentials, -} -``` - -And `ProxyRequest` contains the transformed request: -```rust -pub struct ProxyRequest { - pub method: http::Method, - pub url: String, - pub headers: Vec<(String, String)>, - pub body: Vec, -} -``` - -**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**: 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. 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 `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: -- 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`, `ExpiringProviderCredentials` caching types - ---- - -### Step 9: Unify credential resolution - -Make `ProviderCredentials` the single resolved runtime credential shape in -windmill-ai, while keeping raw API and worker input/deserialization types at -their boundaries. - -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 -pub struct ProviderCredentials { - pub provider: AIProvider, - pub base_url: String, - pub api_key: Option, - pub access_token: Option, - pub organization_id: Option, - pub user: Option, - pub platform: AIPlatform, - pub region: Option, - pub aws_access_key_id: Option, - pub aws_secret_access_key: Option, - pub aws_session_token: Option, - pub enable_1m_context: bool, - pub custom_headers: HashMap, -} -``` - -The `create_query_builder` factory takes `&ProviderCredentials` instead of `&ProviderWithResource`. - ---- - -## Final Crate Structure - -``` -windmill-ai/src/ -├── lib.rs # module exports -├── ai_types.rs # OpenAI-compatible message types -├── ai_providers.rs # AIProvider enum, base URLs, config -├── ai_google.rs # Gemini types and conversions -├── ai_bedrock.rs # Bedrock SDK wrapper (feature: bedrock) -├── ai_cache.rs # Instance AI config revision -├── types.rs # TokenUsage, Tool, OpenAPISchema, etc. -├── proxy.rs # ProviderCredentials, ProxyBuildArgs, ProxyRequest -├── query_builder.rs # QueryBuilder trait, BuildRequestArgs, ParsedResponse, StreamEventSink -├── sse.rs # SSE parsers (OpenAI, Anthropic, Gemini, Responses) -├── image_handler.rs # S3 image upload/download -├── utils.rs # extract_text_content, should_use_structured_output_tool -└── providers/ - ├── 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 + 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 -``` - -**windmill-worker** keeps: `ai_executor.rs`, `ai/tools.rs`, `ai/utils.rs` (flow/conversation/MCP logic), `StreamEventProcessor` (impl of `StreamEventSink`). - -**windmill-api** keeps: HTTP routes (`ai.rs` proxy endpoints), audit logging, caching, credential resolution from DB. `google.rs` and `bedrock.rs` deleted. From 2fdc51e62985fc755884436130bdd58e294247c8 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 28 May 2026 18:13:38 +0200 Subject: [PATCH 290/313] fix(git-sync): publish fork branch on only_create_branch from the CLI (#9366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [ee] fix(git-sync): publish fork branch on only_create_branch from the CLI Fixes WIN-1997. Forking a git-sync-configured workspace must push a `wm-fork//` branch to the repo, but the integration test `test_workspace_fork_creates_branch` failed: the fork callback job succeeded yet no branch appeared. Root cause: the fork-branch callback runs the sync script with `only_create_branch: true` and no items. The hub sync script delegates branch checkout to `wmill sync git-deploy --only-create-branch` and runs its own in-process commit+push ONLY for the `!only_create_branch` path (`if (!only_create_branch) git_push(...)`). #9284 had moved commit+push out of the CLI to the caller for the GPG-cache-warmth invariant (WIN-1974) — but it also dropped the CLI's push for the branch-only case. A branch-only publish has no commit, so no signing is involved and the GPG concern does not apply; with neither the CLI nor the hub script pushing, the empty fork branch was never published. Restore the CLI push for the `only_create_branch` path (a bare `git push --porcelain` of the checked-out branch ref). Adds a deterministic CLI regression test that runs `git-deploy --only-create-branch` for a fork workspace and asserts the branch reaches the remote with no caller-side push. EE companion: format the fork-branch commit message with Display instead of Debug (no more `Some("...")` leak). Co-Authored-By: Claude Opus 4.7 (1M context) * chore: update ee-repo-ref to a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd This commit updates the EE repository reference after PR #597 was merged in windmill-ee-private. Previous ee-repo-ref: 8b02336fcebdfae4b9d2795cbb74fa7046530bcb New ee-repo-ref: a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- cli/src/commands/sync/sync.ts | 16 ++++++- cli/test/gitsync_promotion.test.ts | 71 ++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 80a746c1c0..f457a34fb7 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -55c19293232be379a3044eb78f677b545882ffd6 \ No newline at end of file +a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 22cdd5900b..2632f6c80c 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -2504,8 +2504,20 @@ export async function pull( } if (opts.onlyCreateBranch) { - // Branch is checked out locally; the caller pushes it. Symmetric with - // the non-onlyCreateBranch path: CLI does branch + pull, never push. + // Branch-only publish: there is no commit here, so the GPG-cache-warmth + // invariant that motivated moving commit+push to the hub script (WIN-1974, + // #9284) does not apply — a bare `git push` of the (empty) branch ref needs + // no signing. The hub script only runs its in-process commit+push for the + // non-onlyCreateBranch path (`if (!only_create_branch) git_push(...)`), so + // the CLI MUST publish the fork branch here or it is never pushed at all. + gitSyncDeployPush({ + items: deployItems, + authorName: process.env["WM_USERNAME"] || "windmill", + authorEmail: process.env["WM_EMAIL"] || "windmill@windmill.dev", + committerName: opts.gitCommitterName, + committerEmail: opts.gitCommitterEmail, + onlyCreateBranch: true, + }); return; } } diff --git a/cli/test/gitsync_promotion.test.ts b/cli/test/gitsync_promotion.test.ts index 5b73e8f8e1..709ae11fc2 100644 --- a/cli/test/gitsync_promotion.test.ts +++ b/cli/test/gitsync_promotion.test.ts @@ -200,3 +200,74 @@ test.skipIf(shouldSkipOnCI())( }); }, ); + +/** + * Regression test for WIN-1997: forking a workspace with git sync configured + * must publish a `wm-fork//` branch to the remote. + * + * The fork-branch callback runs the sync script with `only_create_branch: + * true` and no items. The hub script delegates branch checkout + push of that + * empty ref to `wmill sync git-deploy --only-create-branch` — its own + * in-process commit+push runs ONLY for the `!only_create_branch` path. So if + * the CLI doesn't push the freshly checked-out branch here, nothing does and + * the fork branch never reaches the remote (the symptom that broke the e2e + * test after #9284 moved commit+push to the caller). This guards that the CLI + * owns the push for the branch-only case. + */ +test.skipIf(shouldSkipOnCI())( + "git-sync fork: only_create_branch publishes the wm-fork branch (CLI owns the push)", + async () => { + await withTestBackend(async (backend) => { + // Bare "remote" seeded with an initial `main` commit. + const bareDir = await mkdtemp(join(tmpdir(), "wmill_fork_bare_")); + execFileSync("git", ["init", "--bare", "--initial-branch=main", bareDir]); + const seedDir = await mkdtemp(join(tmpdir(), "wmill_fork_seed_")); + git(seedDir, "init", "--initial-branch=main"); + git(seedDir, "config", "user.email", "seed@windmill.dev"); + git(seedDir, "config", "user.name", "seed"); + await writeFile(join(seedDir, "README.md"), "# fork test\n"); + git(seedDir, "add", "-A"); + git(seedDir, "commit", "-m", "seed"); + git(seedDir, "remote", "add", "origin", `file://${bareDir}`); + git(seedDir, "push", "-u", "origin", "main"); + const seedMain = remoteHead(bareDir, "main"); + + // The CWD the hub script runs git-deploy in: a clone of the repo on main. + const work = await mkdtemp(join(tmpdir(), "wmill_fork_work_")); + git(work, "clone", `file://${bareDir}`, "."); + await writeFile( + join(work, "wmill.yaml"), + "defaultTs: bun\nincludes:\n - f/**\nexcludes: []\n", + ); + + // Branch creation happens BEFORE the fork workspace exists (step 1 of the + // fork flow), so we pass the fork workspace id straight through — whoami + // returns synthetic superadmin info for it. No items, only_create_branch. + const forkWs = "wm-fork-clitest"; + const res = await backend.runCLICommand( + [ + "sync", + "git-deploy", + "--repository", + "u/test/unused_on_branch_only_path", + "--git-deploy-items", + "[]", + "--only-create-branch", + ], + work, + { workspace: forkWs }, + ); + expect(res.code).toBe(0); + + // The regression: with NO caller-side commit/push, the fork branch must + // already be on the remote because the CLI pushed it. + expect(remoteBranches(bareDir)).toContain("refs/heads/wm-fork/main/clitest"); + // Base branch untouched — branch-only publish creates no commit. + expect(remoteHead(bareDir, "main")).toBe(seedMain); + + await rm(bareDir, { recursive: true, force: true }); + await rm(seedDir, { recursive: true, force: true }); + await rm(work, { recursive: true, force: true }); + }); + }, +); From 9a659b636d713ee8fdfbdad41c58bb3d7c79e0d9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 28 May 2026 18:16:12 +0200 Subject: [PATCH 291/313] fix(frontend): prevent duplicate asset node ids crashing flow graph (#9367) --- .../graph/renderers/nodes/AssetNode.svelte | 86 +++++++++---------- .../graph/renderers/nodes/assetNode.test.ts | 51 +++++++++++ 2 files changed, 94 insertions(+), 43 deletions(-) create mode 100644 frontend/src/lib/components/graph/renderers/nodes/assetNode.test.ts diff --git a/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte index 50e4f9c87a..0d5a9a14ea 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte @@ -63,7 +63,7 @@ type: 'asset' as const, parentId: node.id, data: { asset, displayedAccessType: 'r' }, - id: `${node.id}-asset-in-${asset.kind}-${asset.path}`, + id: `${node.id}-asset-in-${asset.kind}-${asset.path}-${i}`, width: inputAssetWidth, position: { x: @@ -100,7 +100,7 @@ type: 'asset' as const, parentId: node.id, data: { asset, displayedAccessType: 'w' }, - id: `${node.id}-asset-out-${asset.kind}-${asset.path}`, + id: `${node.id}-asset-out-${asset.kind}-${asset.path}-${i}`, width: outputAssetWidth, position: { x: @@ -136,7 +136,7 @@ allAssetNodes.push(...(inputAssetNodes ?? []), ...(outputAssetNodes ?? [])) // If there are more than 3 assets, we create an overflow node - if (overflowedInputAssets.length) + if (overflowedInputAssets.length) { allAssetNodes.push({ type: 'assetsOverflowed', data: { overflowedAssets: overflowedInputAssets, displayedAccessType: 'r' }, @@ -148,14 +148,15 @@ y: READ_ASSET_Y_OFFSET } } satisfies Node & AssetsOverflowedN) - allAssetEdges.push({ - id: `${node.id}-assets-overflowed-in-edge`, - source: `${node.id}-assets-overflowed-in`, - target: node.id, - type: 'empty', - data: { class: '!opacity-35 dark:!opacity-20' } - }) - if (overflowedOutputAssets.length) + allAssetEdges.push({ + id: `${node.id}-assets-overflowed-in-edge`, + source: `${node.id}-assets-overflowed-in`, + target: node.id, + type: 'empty', + data: { class: '!opacity-35 dark:!opacity-20' } + }) + } + if (overflowedOutputAssets.length) { allAssetNodes.push({ type: 'assetsOverflowed', data: { overflowedAssets: overflowedOutputAssets, displayedAccessType: 'w' }, @@ -167,13 +168,14 @@ y: WRITE_ASSET_Y_OFFSET } } satisfies Node & AssetsOverflowedN) - allAssetEdges.push({ - id: `${node.id}-assets-overflowed-out-edge`, - source: node.id, - target: `${node.id}-assets-overflowed-out`, - type: 'empty', - data: { class: '!opacity-35 dark:!opacity-25' } - }) + allAssetEdges.push({ + id: `${node.id}-assets-overflowed-out-edge`, + source: node.id, + target: `${node.id}-assets-overflowed-out`, + type: 'empty', + data: { class: '!opacity-35 dark:!opacity-25' } + }) + } } let ret: ReturnType = { @@ -274,8 +276,8 @@ {#snippet text()} - Could not find resource - {/snippet} + Could not find resource + {/snippet} {:else if isSelected && assetCanBeExplored(data.asset, cachedResourceMetadata) && !$userStore?.operator}
@@ -291,29 +293,27 @@ {/if}
{#snippet text()} - - {#if usageCount !== undefined} - Used in {pluralize(usageCount, 'step')}
- {/if} - { - if (data.asset.kind === 'resource') - flowGraphAssetsCtx?.val.resourceEditorDrawer?.initEdit(data.asset.path) - }} - > - {data.asset.path} -
- - {formatAssetKind({ ...data.asset, metadata: cachedResourceMetadata })} - - - {/snippet} + {#if usageCount !== undefined} + Used in {pluralize(usageCount, 'step')}
+ {/if} + { + if (data.asset.kind === 'resource') + flowGraphAssetsCtx?.val.resourceEditorDrawer?.initEdit(data.asset.path) + }} + > + {data.asset.path} +
+ + {formatAssetKind({ ...data.asset, metadata: cachedResourceMetadata })} + + {/snippet} {/snippet} diff --git a/frontend/src/lib/components/graph/renderers/nodes/assetNode.test.ts b/frontend/src/lib/components/graph/renderers/nodes/assetNode.test.ts new file mode 100644 index 0000000000..14fd5e24e3 --- /dev/null +++ b/frontend/src/lib/components/graph/renderers/nodes/assetNode.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from 'vitest' + +// Mock heavy transitive imports pulled in by AssetNode.svelte's instance script +vi.mock('monaco-editor', () => ({})) +vi.mock('$lib/components/meltComponents', () => ({ Tooltip: {} })) +vi.mock('../../../ExploreAssetButton.svelte', () => ({ + default: {}, + assetCanBeExplored: () => false +})) +vi.mock('$lib/components/icons/AssetGenericIcon.svelte', () => ({ default: {} })) +vi.mock('$lib/components/assets/AssetColumnBadges.svelte', () => ({ default: {} })) +vi.mock('./NodeWrapper.svelte', () => ({ default: {} })) + +import { computeAssetNodes } from './AssetNode.svelte' + +function nodeWithAssets(id: string, assets: any[]) { + return { id, position: { x: 0, y: 0 }, data: { assets } } +} + +describe('computeAssetNodes (WIN-1998)', () => { + it('produces unique node and edge ids when a module lists the same asset twice', () => { + // Two assets with identical kind+path (e.g. read twice, or r + rw) — both + // display as inputs. Before the fix these collided on the same node id and + // crashed SvelteFlow with `each_key_duplicate`. + const dup = { kind: 'resource', path: 'f/foo/bar', access_type: 'r' } + const { newAssetNodes, newAssetEdges } = computeAssetNodes([ + nodeWithAssets('moduleA', [{ ...dup }, { ...dup }]) + ]) + + const nodeIds = newAssetNodes.map((n) => n.id) + expect(new Set(nodeIds).size).toBe(nodeIds.length) + + const edgeIds = newAssetEdges.map((e) => e.id) + expect(new Set(edgeIds).size).toBe(edgeIds.length) + }) + + it('does not emit overflow edges when there is no overflow node (<=3 assets)', () => { + const { newAssetNodes, newAssetEdges } = computeAssetNodes([ + nodeWithAssets('moduleB', [{ kind: 'resource', path: 'f/a/x', access_type: 'r' }]) + ]) + + // No overflow node should be created for a single asset... + expect(newAssetNodes.some((n) => n.type === 'assetsOverflowed')).toBe(false) + // ...and therefore no dangling edge should reference a missing overflow node. + const nodeIdSet = new Set(newAssetNodes.map((n) => n.id).concat('moduleB')) + for (const e of newAssetEdges) { + expect(nodeIdSet.has(e.source as string)).toBe(true) + expect(nodeIdSet.has(e.target as string)).toBe(true) + } + }) +}) From 2553fbfe31417bd985e7994eac695bf918f97ce2 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Thu, 28 May 2026 18:52:26 +0200 Subject: [PATCH 292/313] feat: add deepseek fim support (#9365) --- backend/windmill-ai/src/ai_providers.rs | 3 +- backend/windmill-ai/src/proxy/fim.rs | 116 ++++++++++++++++-- backend/windmill-api/src/ai.rs | 24 +++- .../copilot/autocomplete/request.ts | 4 +- frontend/src/lib/components/copilot/fim.ts | 39 ++++++ .../src/lib/components/copilot/lib.test.ts | 44 +++++++ frontend/src/lib/components/copilot/lib.ts | 23 +--- frontend/src/lib/components/copilot/utils.ts | 5 +- .../workspaceSettings/AISettings.svelte | 5 +- 9 files changed, 219 insertions(+), 44 deletions(-) create mode 100644 frontend/src/lib/components/copilot/fim.ts diff --git a/backend/windmill-ai/src/ai_providers.rs b/backend/windmill-ai/src/ai_providers.rs index b568c0accb..fb29454ca5 100644 --- a/backend/windmill-ai/src/ai_providers.rs +++ b/backend/windmill-ai/src/ai_providers.rs @@ -27,6 +27,7 @@ lazy_static::lazy_static! { } pub const OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; +pub const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1"; pub const GOOGLE_AI_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta"; /// Empty string signals BedrockClient::from_env() to use the region from AWS environment/config @@ -106,7 +107,7 @@ impl AIProvider { Ok(azure_base_path.unwrap_or("https://api.openai.com/v1".to_string())) } - AIProvider::DeepSeek => Ok("https://api.deepseek.com/v1".to_string()), + AIProvider::DeepSeek => Ok(DEEPSEEK_BASE_URL.to_string()), AIProvider::GoogleAI => Ok(GOOGLE_AI_BASE_URL.to_string()), AIProvider::Groq => Ok("https://api.groq.com/openai/v1".to_string()), AIProvider::OpenRouter => Ok("https://openrouter.ai/api/v1".to_string()), diff --git a/backend/windmill-ai/src/proxy/fim.rs b/backend/windmill-ai/src/proxy/fim.rs index 2d14fd23ce..3645476441 100644 --- a/backend/windmill-ai/src/proxy/fim.rs +++ b/backend/windmill-ai/src/proxy/fim.rs @@ -3,12 +3,13 @@ use serde::Deserialize; use serde_json::json; use windmill_common::error::{Error, Result}; -use crate::ai_providers::AIProvider; +use crate::ai_providers::{AIProvider, DEEPSEEK_BASE_URL}; #[derive(Debug, Eq, PartialEq)] pub struct FimProxyTransform { pub body: Bytes, pub path: String, + pub base_url: Option, } #[derive(Deserialize)] @@ -22,19 +23,49 @@ struct FimRequest { } pub fn supports_native_fim(provider: &AIProvider) -> bool { - matches!(provider, AIProvider::Mistral) + matches!(provider, AIProvider::Mistral | AIProvider::DeepSeek) +} + +fn deepseek_fim_base_url(base_url: &str) -> String { + let trimmed = base_url.trim_end_matches('/'); + let deepseek_root_base_url = DEEPSEEK_BASE_URL + .strip_suffix("/v1") + .unwrap_or(DEEPSEEK_BASE_URL); + + if trimmed == DEEPSEEK_BASE_URL || trimmed == deepseek_root_base_url { + return format!("{deepseek_root_base_url}/beta"); + } + + if let Some(prefix) = trimmed.strip_suffix("/v1") { + return format!("{prefix}/beta"); + } + + trimmed.to_string() } pub fn maybe_transform_fim_request( provider: &AIProvider, path: &str, + base_url: &str, body: &[u8], ) -> Result> { - if path.contains("fim/completions") && !supports_native_fim(provider) { - transform_fim_to_chat_completions(body).map(Some) - } else { - Ok(None) + if !path.contains("fim/completions") { + return Ok(None); } + + if matches!(provider, AIProvider::DeepSeek) { + return Ok(Some(FimProxyTransform { + body: Bytes::copy_from_slice(body), + path: "completions".to_string(), + base_url: Some(deepseek_fim_base_url(base_url)), + })); + } + + if !supports_native_fim(provider) { + return transform_fim_to_chat_completions(body).map(Some); + } + + Ok(None) } fn transform_fim_to_chat_completions(body: &[u8]) -> Result { @@ -64,7 +95,11 @@ fn transform_fim_to_chat_completions(body: &[u8]) -> Result { let body = serde_json::to_vec(&chat_req) .map_err(|e| Error::internal_err(format!("Failed to serialize chat request: {}", e)))?; - Ok(FimProxyTransform { body: Bytes::from(body), path: "chat/completions".to_string() }) + Ok(FimProxyTransform { + body: Bytes::from(body), + path: "chat/completions".to_string(), + base_url: None, + }) } #[cfg(test)] @@ -73,11 +108,62 @@ mod tests { #[test] fn mistral_keeps_native_fim_request() { - let transformed = - maybe_transform_fim_request(&AIProvider::Mistral, "fim/completions", br#"{}"#).unwrap(); + let transformed = maybe_transform_fim_request( + &AIProvider::Mistral, + "fim/completions", + "https://api.mistral.ai/v1", + br#"{}"#, + ) + .unwrap(); assert!(transformed.is_none()); assert!(supports_native_fim(&AIProvider::Mistral)); + assert!(supports_native_fim(&AIProvider::DeepSeek)); + assert!(!supports_native_fim(&AIProvider::OpenAI)); + } + + #[test] + fn deepseek_fim_base_url_uses_beta_endpoint() { + assert_eq!( + deepseek_fim_base_url("https://api.deepseek.com/v1"), + "https://api.deepseek.com/beta" + ); + assert_eq!( + deepseek_fim_base_url("https://api.deepseek.com/v1/"), + "https://api.deepseek.com/beta" + ); + assert_eq!( + deepseek_fim_base_url("https://api.deepseek.com"), + "https://api.deepseek.com/beta" + ); + assert_eq!( + deepseek_fim_base_url("https://proxy.example/deepseek/v1"), + "https://proxy.example/deepseek/beta" + ); + assert_eq!( + deepseek_fim_base_url("https://proxy.example/deepseek/beta"), + "https://proxy.example/deepseek/beta" + ); + } + + #[test] + fn deepseek_fim_request_uses_beta_completions_endpoint() { + let body = br#"{"model":"deepseek-v4-pro","prompt":"return ","suffix":";"}"#; + let transformed = maybe_transform_fim_request( + &AIProvider::DeepSeek, + "fim/completions", + DEEPSEEK_BASE_URL, + body, + ) + .unwrap() + .expect("DeepSeek FIM should be routed to the beta completions endpoint"); + + assert_eq!(transformed.path, "completions"); + assert_eq!( + transformed.base_url.as_deref(), + Some("https://api.deepseek.com/beta") + ); + assert_eq!(transformed.body, Bytes::copy_from_slice(body)); } #[test] @@ -85,6 +171,7 @@ mod tests { let transformed = maybe_transform_fim_request( &AIProvider::OpenAI, "fim/completions", + "https://api.openai.com/v1", br#"{ "model": "gpt-4.1", "prompt": "fn main() {", @@ -96,6 +183,7 @@ mod tests { .expect("OpenAI FIM should be transformed"); assert_eq!(transformed.path, "chat/completions"); + assert_eq!(transformed.base_url, None); let body: serde_json::Value = serde_json::from_slice(&transformed.body).unwrap(); assert_eq!(body["model"], "gpt-4.1"); @@ -111,9 +199,13 @@ mod tests { #[test] fn invalid_fim_body_is_bad_request() { - let err = - maybe_transform_fim_request(&AIProvider::OpenAI, "fim/completions", br#"{"model": 1}"#) - .unwrap_err(); + let err = maybe_transform_fim_request( + &AIProvider::OpenAI, + "fim/completions", + "https://api.openai.com/v1", + br#"{"model": 1}"#, + ) + .unwrap_err(); assert!(matches!(err, Error::BadRequest(_))); } diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 81f601e53f..21059df18a 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -627,7 +627,7 @@ async fn proxy( check_scopes(&authed, || format!("resources:read:{}", resource_path))?; } - let credentials = match workspace_cache { + let mut credentials = match workspace_cache { Some(request_cache) if !request_cache.is_expired() && forced_resource_path.is_none() => { request_cache.credentials } @@ -758,11 +758,23 @@ async fn proxy( } }; - if let Some(fim_transform) = maybe_transform_fim_request(&provider, &ai_path, &body)? { - tracing::debug!( - "Transforming FIM request to chat/completions with FIM tokens for provider {:?}", - provider - ); + if let Some(fim_transform) = + maybe_transform_fim_request(&provider, &ai_path, &credentials.base_url, &body)? + { + if fim_transform.base_url.is_some() { + tracing::debug!( + "Routing native FIM request through provider-specific endpoint for {:?}", + provider + ); + } else { + tracing::debug!( + "Transforming FIM request to chat/completions with FIM tokens for provider {:?}", + provider + ); + } + if let Some(base_url) = fim_transform.base_url { + credentials.base_url = base_url; + } body = fim_transform.body; ai_path = fim_transform.path; } diff --git a/frontend/src/lib/components/copilot/autocomplete/request.ts b/frontend/src/lib/components/copilot/autocomplete/request.ts index ef29ba9a19..cc3987ebe5 100644 --- a/frontend/src/lib/components/copilot/autocomplete/request.ts +++ b/frontend/src/lib/components/copilot/autocomplete/request.ts @@ -30,9 +30,9 @@ export async function autocompleteRequest( throw new Error('No code completion model selected') } - // Only add context lines for Mistral (native FIM) - other providers use chat completion + // Only add context lines for native FIM providers - other providers use chat completion // too much context degrades significantly the quality of the completion - if (providerModel.provider === 'mistral') { + if (providerModel.provider === 'mistral' || providerModel.provider === 'deepseek') { let commentSymbol = getCommentSymbol(context.scriptLang) let contextLines = comment( commentSymbol, diff --git a/frontend/src/lib/components/copilot/fim.ts b/frontend/src/lib/components/copilot/fim.ts new file mode 100644 index 0000000000..16c5d40431 --- /dev/null +++ b/frontend/src/lib/components/copilot/fim.ts @@ -0,0 +1,39 @@ +import type { AIProvider } from '$lib/gen' +import { z } from 'zod' + +const chatFimResponseSchema = z.object({ + choices: z.array( + z.object({ + message: z.object({ + content: z.string().optional() + }), + finish_reason: z.string().optional() + }) + ) +}) + +const deepseekFimResponseSchema = z.object({ + choices: z.array( + z.object({ + text: z.string().optional(), + finish_reason: z.string().optional() + }) + ) +}) + +export function parseFimCompletionChoice( + body: unknown, + provider: AIProvider +): { content: string | undefined; finish_reason: string | undefined } | undefined { + if (provider === 'deepseek') { + const parsedBody = deepseekFimResponseSchema.parse(body) + const choice = parsedBody.choices[0] + return choice ? { content: choice.text, finish_reason: choice.finish_reason } : undefined + } + + const parsedBody = chatFimResponseSchema.parse(body) + const choice = parsedBody.choices[0] + return choice + ? { content: choice.message.content, finish_reason: choice.finish_reason } + : undefined +} diff --git a/frontend/src/lib/components/copilot/lib.test.ts b/frontend/src/lib/components/copilot/lib.test.ts index f1eec17cbb..369987d53f 100644 --- a/frontend/src/lib/components/copilot/lib.test.ts +++ b/frontend/src/lib/components/copilot/lib.test.ts @@ -9,7 +9,9 @@ import { buildAssistantToolCallMessage, getReasoningContentDelta } from './chat/openaiReasoning' +import { parseFimCompletionChoice } from './fim' import { getDefaultChatTemperature, modelDisallowsSamplingParams } from './modelConfig' +import { supportsAutocomplete } from './utils' type AssistantMessageWithReasoning = ChatCompletionMessageParam & { role: 'assistant' @@ -43,6 +45,48 @@ describe('modelConfig', () => { }) }) +describe('fim autocomplete', () => { + it('allows DeepSeek v4 pro and Codestral autocomplete models', () => { + expect(supportsAutocomplete('codestral-latest')).toBe(true) + expect(supportsAutocomplete('Codestral-2501')).toBe(true) + expect(supportsAutocomplete('codestral-embed')).toBe(false) + expect(supportsAutocomplete('deepseek-v4-pro')).toBe(true) + expect(supportsAutocomplete('deepseek-chat')).toBe(false) + }) + + it('parses chat-shaped native FIM responses', () => { + expect( + parseFimCompletionChoice( + { + choices: [ + { + message: { content: 'cache[key] = factory()' }, + finish_reason: 'stop' + } + ] + }, + 'mistral' + ) + ).toEqual({ content: 'cache[key] = factory()', finish_reason: 'stop' }) + }) + + it('parses DeepSeek native FIM completion responses', () => { + expect( + parseFimCompletionChoice( + { + choices: [ + { + text: 'items?.length ?? 0', + finish_reason: 'stop' + } + ] + }, + 'deepseek' + ) + ).toEqual({ content: 'items?.length ?? 0', finish_reason: 'stop' }) + }) +}) + describe('openaiReasoning', () => { it('reads provider-specific reasoning_content deltas', () => { expect( diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index de87579a23..00d2334acc 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -16,7 +16,6 @@ import { OpenAPI, ResourceService, type Script } from '../../gen' import { EDIT_CONFIG, FIX_CONFIG, GEN_CONFIG } from './prompts' import { getDefaultChatTemperature } from './modelConfig' import { formatResourceTypes } from './utils' -import { z } from 'zod' import { processToolCall, type Tool, type ToolCallbacks } from './chat/shared' import { getNonStreamingOpenAIResponsesCompletion, @@ -36,6 +35,7 @@ import { buildAssistantToolCallMessage, getReasoningContentDelta } from './chat/openaiReasoning' +import { parseFimCompletionChoice } from './fim' export const SUPPORTED_LANGUAGES = new Set(Object.keys(GEN_CONFIG.prompts)) @@ -74,7 +74,7 @@ export const AI_PROVIDERS: Record = { }, deepseek: { label: 'DeepSeek', - defaultModels: ['deepseek-chat', 'deepseek-reasoner'] + defaultModels: ['deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'] }, googleai: { label: 'Google AI', @@ -816,17 +816,6 @@ export async function getNonStreamingCompletion( return response } -const mistralFimResponseSchema = z.object({ - choices: z.array( - z.object({ - message: z.object({ - content: z.string().optional() - }), - finish_reason: z.string() - }) - ) -}) - export const FIM_MAX_TOKENS = 256 const FIM_MAX_LINES = 8 export async function getFimCompletion( @@ -864,12 +853,10 @@ export async function getFimCompletion( ) const body = await response.json() - const parsedBody = mistralFimResponseSchema.parse(body) + const choice = parseFimCompletionChoice(body, providerModel.provider) - const choice = parsedBody.choices[0] - - if (choice && choice.message.content !== undefined) { - let lines = choice.message.content.split('\n') + if (choice?.content !== undefined) { + let lines = choice.content.split('\n') // If finish_reason is 'length', remove the last line if (choice.finish_reason === 'length') { diff --git a/frontend/src/lib/components/copilot/utils.ts b/frontend/src/lib/components/copilot/utils.ts index 8e5150cda6..50f6513b26 100644 --- a/frontend/src/lib/components/copilot/utils.ts +++ b/frontend/src/lib/components/copilot/utils.ts @@ -171,10 +171,9 @@ export function yamlStringifyExceptKeys(obj: any, keys: string[]) { /** * Checks if a model supports FIM (Fill-in-the-Middle) autocomplete. - * Currently only Codestral models (non-embedding) support this. + * Currently Codestral models (non-embedding) and DeepSeek FIM support this. */ export function supportsAutocomplete(model: string): boolean { const lower = model.toLowerCase() - return lower.includes('codestral') && !lower.includes('embed') + return (lower.includes('codestral') && !lower.includes('embed')) || lower === 'deepseek-v4-pro' } - diff --git a/frontend/src/lib/components/workspaceSettings/AISettings.svelte b/frontend/src/lib/components/workspaceSettings/AISettings.svelte index dd3cc6e0d0..922492d6ac 100644 --- a/frontend/src/lib/components/workspaceSettings/AISettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/AISettings.svelte @@ -353,7 +353,7 @@ {#if showWorkspaceOverrideEditor}
- {#each Object.entries(AI_PROVIDERS) as [provider, details]} + {#each Object.entries(AI_PROVIDERS) as [provider, details] (provider)}
From 889101b7f04884408833beb05f813e78f9a9862b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 28 May 2026 19:52:36 +0200 Subject: [PATCH 293/313] chore(main): release 1.712.0 (#9340) * chore(main): release 1.712.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 23 ++ backend/Cargo.lock | 366 ++++++++---------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +-- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 232 insertions(+), 237 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebbf6cb922..583a79cd82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [1.712.0](https://github.com/windmill-labs/windmill/compare/v1.711.0...v1.712.0) (2026-05-28) + + +### Features + +* add deepseek fim support ([#9365](https://github.com/windmill-labs/windmill/issues/9365)) ([2553fbf](https://github.com/windmill-labs/windmill/commit/2553fbfe31417bd985e7994eac695bf918f97ce2)) +* deploy raw apps from global chat ([#9349](https://github.com/windmill-labs/windmill/issues/9349)) ([dec58e6](https://github.com/windmill-labs/windmill/commit/dec58e6c4f55062b42a752c43c89ef05903e713a)) +* inject active editor into global chat ([#9361](https://github.com/windmill-labs/windmill/issues/9361)) ([9e7eaf3](https://github.com/windmill-labs/windmill/commit/9e7eaf36847ad3a004ec84e8b7d4784771b7b451)) +* **queue:** duration-weighted fairness admission ([#9334](https://github.com/windmill-labs/windmill/issues/9334)) ([045d120](https://github.com/windmill-labs/windmill/commit/045d12043e7c99830ef90bc0da798c94e2094711)) +* warn when custom instance db is shared across workspaces ([#9359](https://github.com/windmill-labs/windmill/issues/9359)) ([a9e5140](https://github.com/windmill-labs/windmill/commit/a9e514099585e5ee72df21bd551a223cceb20fb0)) + + +### Bug Fixes + +* **cli:** redact encryption_key diff in stdout by default ([#9347](https://github.com/windmill-labs/windmill/issues/9347)) ([88056f8](https://github.com/windmill-labs/windmill/commit/88056f8d4c91c1d14d85a08851ecf0bd97e2260d)) +* **cli:** stop re-prompting on wmill refresh prompts ([#9357](https://github.com/windmill-labs/windmill/issues/9357)) ([c2b5ba8](https://github.com/windmill-labs/windmill/commit/c2b5ba8871abbbcff6de69c90e2f09fee70586c1)) +* **frontend:** close other sidebar menus when hovering Help ([#9354](https://github.com/windmill-labs/windmill/issues/9354)) ([da882c5](https://github.com/windmill-labs/windmill/commit/da882c54b21e3eaf2c1d1abccd0996b243d96dce)) +* **frontend:** prevent duplicate asset node ids crashing flow graph ([#9367](https://github.com/windmill-labs/windmill/issues/9367)) ([9a659b6](https://github.com/windmill-labs/windmill/commit/9a659b636d713ee8fdfbdad41c58bb3d7c79e0d9)) +* **frontend:** prevent MultiSelect crash on undefined value ([#9364](https://github.com/windmill-labs/windmill/issues/9364)) ([aea0061](https://github.com/windmill-labs/windmill/commit/aea00611c41379be2afdad0eedd608c9537d03f7)) +* **git-sync:** publish fork branch on only_create_branch from the CLI ([#9366](https://github.com/windmill-labs/windmill/issues/9366)) ([2fdc51e](https://github.com/windmill-labs/windmill/commit/2fdc51e62985fc755884436130bdd58e294247c8)) +* infer script arg schema when deploying via AI chat ([#9356](https://github.com/windmill-labs/windmill/issues/9356)) ([4efc372](https://github.com/windmill-labs/windmill/commit/4efc37212a98571214aba135b0fbb10dc263fd4f)) +* **monitor:** cleanup stale server_heartbeat background_task_state rows ([#9338](https://github.com/windmill-labs/windmill/issues/9338)) ([59ab038](https://github.com/windmill-labs/windmill/commit/59ab038d7718d8a4c25efa5928f42e1393ebbf40)) + ## [1.711.0](https://github.com/windmill-labs/windmill/compare/v1.710.1...v1.711.0) (2026-05-26) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index c32e1d9c67..37fa905cb0 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1141,7 +1141,7 @@ dependencies = [ "http 1.4.1", "http-body 0.4.6", "hyper 0.14.32", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-rustls 0.24.2", "hyper-rustls 0.27.9", "hyper-util", @@ -1335,7 +1335,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "itoa", "matchit 0.8.4", @@ -1684,7 +1684,7 @@ dependencies = [ "hex", "http 1.4.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-named-pipe", "hyper-util", "hyperlocal", @@ -1786,13 +1786,13 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", - "brotli-decompressor 5.0.0", + "brotli-decompressor 5.0.1", ] [[package]] @@ -1807,9 +1807,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -2202,7 +2202,7 @@ version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -3599,7 +3599,7 @@ dependencies = [ "hickory-resolver", "http 1.4.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-rustls 0.27.9", "hyper-util", "ipnet", @@ -3731,8 +3731,8 @@ dependencies = [ "proc-macro2", "quote", "stringcase", - "strum 0.27.2", - "strum_macros 0.27.2", + "strum", + "strum_macros", "syn 2.0.117", "thiserror 2.0.18", ] @@ -3802,7 +3802,7 @@ dependencies = [ "deno_error 0.6.1", "deno_tls", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-rustls 0.27.9", "hyper-util", "log", @@ -4155,9 +4155,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -4364,7 +4364,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -5236,13 +5236,13 @@ dependencies = [ [[package]] name = "gosyn" -version = "0.2.9" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb37859fda6792e95231aef1c5838f4043ec0ee352d8313421e311c606df612" +checksum = "c99c1502d84229dc7ddb6af755f40ebe80e7e932fa78ecef979cedcf9999ba93" dependencies = [ "anyhow", - "strum 0.25.0", - "thiserror 1.0.69", + "strum", + "thiserror 2.0.18", "unic-ucd-category", ] @@ -5418,12 +5418,6 @@ dependencies = [ "http 1.4.1", ] -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -5649,7 +5643,7 @@ dependencies = [ "futures", "http 1.4.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-rustls 0.26.0", "hyper-tls", "hyper-tungstenite", @@ -5699,9 +5693,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" dependencies = [ "atomic-waker", "bytes", @@ -5729,7 +5723,7 @@ dependencies = [ "futures-util", "headers", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-rustls 0.27.9", "hyper-tls", "hyper-util", @@ -5749,7 +5743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" dependencies = [ "hex", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "pin-project-lite", "tokio", @@ -5781,7 +5775,7 @@ checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c" dependencies = [ "futures-util", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "log", "rustls 0.22.4", @@ -5799,7 +5793,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "log", "rustls 0.23.35", @@ -5816,7 +5810,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "pin-project-lite", "tokio", @@ -5831,7 +5825,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "native-tls", "tokio", @@ -5846,7 +5840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a343d17fe7885302ed7252767dc7bb83609a874b6ff581142241ec4b73957ad" dependencies = [ "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "pin-project-lite", "tokio", @@ -5866,12 +5860,12 @@ dependencies = [ "futures-util", "http 1.4.1", "http-body 1.0.1", - "hyper 1.9.0", + "hyper 1.10.0", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.6.4", "system-configuration", "tokio", "tower-service", @@ -5887,7 +5881,7 @@ checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" dependencies = [ "hex", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "pin-project-lite", "tokio", @@ -6116,7 +6110,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2 0.6.3", + "socket2 0.6.4", "widestring", "windows-registry", "windows-result 0.4.1", @@ -6144,7 +6138,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -6406,7 +6400,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-http-proxy", "hyper-rustls 0.27.9", "hyper-timeout", @@ -6638,14 +6632,14 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "bitflags 2.11.1", "libc", "plain", - "redox_syscall 0.7.5", + "redox_syscall 0.8.0", ] [[package]] @@ -7022,9 +7016,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memmap2" @@ -7117,9 +7111,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -7198,7 +7192,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66f62cad7623a9cb6f8f64037f0c4f69c8db8e82914334a83c9788201c2c1bfa" dependencies = [ "darling 0.20.11", - "heck 0.5.0", + "heck", "num-bigint", "proc-macro-crate", "proc-macro-error2", @@ -7230,7 +7224,7 @@ dependencies = [ "percent-encoding", "rand 0.10.1", "serde", - "socket2 0.6.3", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tokio-native-tls", @@ -7396,7 +7390,7 @@ version = "0.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71f7c8ed6ba88a567ec6f7c4cad4a7a8465ab93b8cdaf89d3dc72347a83c2d1f" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro-error", "proc-macro2", "quote", @@ -7464,7 +7458,7 @@ dependencies = [ "dirs 5.0.1", "dirs-sys 0.4.1", "fancy-regex 0.14.0", - "heck 0.5.0", + "heck", "indexmap 2.14.0", "log", "lru 0.12.5", @@ -7720,7 +7714,7 @@ dependencies = [ "http-body-util", "httparse", "humantime", - "hyper 1.9.0", + "hyper 1.10.0", "itertools 0.14.0", "md-5 0.10.6", "parking_lot", @@ -8245,7 +8239,7 @@ dependencies = [ "arrow-schema", "arrow-select", "base64 0.22.1", - "brotli 8.0.2", + "brotli 8.0.3", "bytes", "chrono", "flate2", @@ -8698,7 +8692,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -8979,7 +8973,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls 0.23.35", - "socket2 0.6.3", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -9017,7 +9011,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -9302,9 +9296,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +checksum = "7c7591fa2c6b601dfcfe5f043f65a1c39fcdf50efefcd7f1572e538c1f4b398d" dependencies = [ "bitflags 2.11.1", ] @@ -9426,7 +9420,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-rustls 0.27.9", "hyper-tls", "hyper-util", @@ -9474,7 +9468,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-rustls 0.27.9", "hyper-util", "js-sys", @@ -9530,7 +9524,7 @@ dependencies = [ "futures", "getrandom 0.2.17", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "reqwest 0.13.1", "reqwest-middleware", "retry-policies", @@ -10862,9 +10856,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -11052,7 +11046,7 @@ checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", - "heck 0.5.0", + "heck", "hex", "once_cell", "proc-macro2", @@ -11268,35 +11262,13 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" -dependencies = [ - "strum_macros 0.25.3", -] - [[package]] name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.27.2", -] - -[[package]] -name = "strum_macros" -version = "0.25.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.117", + "strum_macros", ] [[package]] @@ -11305,7 +11277,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -12397,7 +12369,7 @@ dependencies = [ "postgres-protocol", "postgres-types", "rand 0.9.0", - "socket2 0.6.3", + "socket2 0.6.4", "tokio", "tokio-util", "whoami", @@ -12594,9 +12566,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", @@ -12629,7 +12601,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-timeout", "hyper-util", "percent-encoding", @@ -12661,7 +12633,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-timeout", "hyper-util", "percent-encoding", @@ -13802,7 +13774,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-nats", @@ -13836,7 +13808,7 @@ dependencies = [ "sha2 0.10.9", "sql-builder", "sqlx", - "strum 0.27.2", + "strum", "tar", "tempfile", "tikv-jemalloc-ctl", @@ -13883,7 +13855,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.711.0" +version = "1.712.0" dependencies = [ "async-stream", "async-trait", @@ -13916,7 +13888,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13929,7 +13901,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "argon2", @@ -13959,7 +13931,7 @@ dependencies = [ "hex", "hmac", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "indexmap 2.14.0", "itertools 0.14.0", "jsonwebtoken 8.3.0", @@ -13990,7 +13962,7 @@ dependencies = [ "sha2 0.10.9", "sql-builder", "sqlx", - "strum 0.27.2", + "strum", "tar", "tempfile", "time", @@ -14067,12 +14039,12 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "lazy_static", "quick_cache", "serde", @@ -14090,7 +14062,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14103,7 +14075,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14129,7 +14101,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.711.0" +version = "1.712.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14139,7 +14111,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14156,7 +14128,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14178,7 +14150,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14201,7 +14173,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14217,11 +14189,11 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", - "hyper 1.9.0", + "hyper 1.10.0", "serde", "serde_json", "sql-builder", @@ -14238,7 +14210,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14259,7 +14231,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14273,7 +14245,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-nats", @@ -14305,14 +14277,14 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "axum 0.8.9", "base64 0.22.1", "chrono", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "lazy_static", "serde", "serde_json", @@ -14330,7 +14302,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14348,7 +14320,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14370,7 +14342,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14390,13 +14362,13 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", "futures", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "itertools 0.14.0", "lazy_static", "quick_cache", @@ -14420,7 +14392,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14448,7 +14420,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.711.0" +version = "1.712.0" dependencies = [ "lazy_static", "serde", @@ -14460,14 +14432,14 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.711.0" +version = "1.712.0" dependencies = [ "argon2", "axum 0.8.9", "chrono", "dashmap", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "lazy_static", "serde", "serde_json", @@ -14485,7 +14457,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14499,13 +14471,13 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", "hex", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "lazy_static", "magic-crypt", "regex", @@ -14513,7 +14485,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sqlx", - "strum 0.27.2", + "strum", "tokio", "tracing", "uuid", @@ -14532,7 +14504,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.711.0" +version = "1.712.0" dependencies = [ "chrono", "lazy_static", @@ -14546,7 +14518,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14565,7 +14537,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.711.0" +version = "1.712.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14601,7 +14573,7 @@ dependencies = [ "globset", "hex", "hmac", - "hyper 1.9.0", + "hyper 1.10.0", "indexmap 2.14.0", "itertools 0.14.0", "jsonwebtoken 8.3.0", @@ -14636,8 +14608,8 @@ dependencies = [ "sha2 0.10.9", "size", "sqlx", - "strum 0.27.2", - "strum_macros 0.27.2", + "strum", + "strum_macros", "sysinfo", "systemstat", "tar", @@ -14666,7 +14638,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.711.0" +version = "1.712.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14685,7 +14657,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.711.0" +version = "1.712.0" dependencies = [ "regex", "serde", @@ -14700,7 +14672,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14724,7 +14696,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "futures", @@ -14741,7 +14713,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.711.0" +version = "1.712.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14757,7 +14729,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -14778,7 +14750,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -14795,7 +14767,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sqlx", - "strum 0.27.2", + "strum", "tokio", "tracing", "urlencoding", @@ -14809,7 +14781,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "arc-swap", @@ -14834,7 +14806,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-stream", @@ -14868,7 +14840,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "futures", @@ -14886,7 +14858,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.711.0" +version = "1.712.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14895,7 +14867,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -14907,7 +14879,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde_json", @@ -14919,7 +14891,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "gosyn", @@ -14931,7 +14903,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -14943,7 +14915,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde_json", @@ -14955,7 +14927,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "nu-parser", @@ -14966,7 +14938,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14977,7 +14949,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14989,7 +14961,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15000,7 +14972,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-recursion", @@ -15022,7 +14994,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde_json", @@ -15034,7 +15006,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -15048,7 +15020,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15065,7 +15037,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -15078,7 +15050,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde", @@ -15090,7 +15062,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -15108,7 +15080,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15124,7 +15096,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15140,7 +15112,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde", @@ -15151,7 +15123,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-recursion", @@ -15189,7 +15161,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "const_format", @@ -15227,7 +15199,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.711.0" +version = "1.712.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15238,7 +15210,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-recursion", @@ -15246,7 +15218,7 @@ dependencies = [ "chrono", "futures", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "lazy_static", "quick_cache", "reqwest 0.13.1", @@ -15268,7 +15240,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15292,14 +15264,14 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", "axum 0.8.9", "chrono", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "itertools 0.14.0", "lazy_static", "rand 0.9.0", @@ -15325,7 +15297,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15358,7 +15330,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15378,7 +15350,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15412,7 +15384,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15424,7 +15396,7 @@ dependencies = [ "hex", "hmac", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "itertools 0.14.0", "lazy_static", "matchit 0.7.3", @@ -15448,7 +15420,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15471,7 +15443,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15495,7 +15467,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-nats", @@ -15519,7 +15491,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15554,7 +15526,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15582,7 +15554,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15607,7 +15579,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -15618,7 +15590,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "strum 0.27.2", + "strum", "tracing", "uuid", "windmill-parser", @@ -15626,7 +15598,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-once-cell", @@ -15736,7 +15708,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.711.0" +version = "1.712.0" dependencies = [ "bytes", "futures", @@ -16371,7 +16343,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "wit-parser", ] @@ -16382,7 +16354,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "indexmap 2.14.0", "prettyplease", "syn 2.0.117", @@ -16550,18 +16522,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" dependencies = [ "proc-macro2", "quote", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e296f9fb6a..a4950571f2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.711.0" +version = "1.712.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.711.0" +version = "1.712.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index f06ab454a3..06f936e955 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.711.0" +version = "1.712.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.711.0" +version = "1.712.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.711.0" +version = "1.712.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index f49ea30ecd..2b0e060503 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.711.0" +version = "1.712.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 4576f655ea..04117d640d 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.711.0 + version: 1.712.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index deb8f1cf66..924052b688 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.711.0"; +export const VERSION = "v1.712.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index cc8f9e80b7..34c186182d 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -89,7 +89,7 @@ export { token, }; -export const VERSION = "1.711.0"; +export const VERSION = "1.712.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 67be947bde..c59d3e5b35 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.711.0", + "version": "1.712.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.711.0", + "version": "1.712.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index c3651f7daf..4f0c9ada75 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.711.0", + "version": "1.712.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", diff --git a/lsp/Pipfile b/lsp/Pipfile index 199fbdf672..a8f34d6189 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.711.0" +wmill = ">=1.712.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 0ef3bbaa85..cb0f7d33d9 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.711.0 + version: 1.712.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index ee0f88f1b2..17ed31216b 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.711.0' + ModuleVersion = '1.712.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index ecc68d1c18..abbbed9857 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.711.0" +version = "1.712.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/typescript-client/jsr.json b/typescript-client/jsr.json index c68b80d575..434effa5e0 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.711.0", + "version": "1.712.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 b4535d1f7b..c429b01434 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.711.0", + "version": "1.712.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 272c83ab90..9a8f9c885c 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.711.0 +1.712.0 From 2bf11dcb15540c538ea2ac3cf70dcbe589060b4e Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 29 May 2026 00:33:44 +0200 Subject: [PATCH 294/313] feat(oauth): support per-provider sandbox URLs (#9358) * feat(oauth): support per-provider sandbox URLs in registry + instance settings * fix(oauth): polish sandbox review nits (cc lookup, header label, ee ref) * refactor(oauth): drop dead build_oauth_clients duplicate in windmill-oauth * refactor(oauth): derive sandbox-capable provider list from registry * chore(docker): copy oauth_connect.json into frontend build stage * test(oauth): cover sandbox helpers (as_sandbox, canonical_name, resolve) * chore: update ee-repo-ref to 9297d8f790346e6a6ad540c7bca1a67f91ec11a2 This commit updates the EE repository reference after PR #595 was merged in windmill-ee-private. Previous ee-repo-ref: 3ab3eca9ac15ebab6db991e7964bc5e48ce21f42 New ee-repo-ref: 9297d8f790346e6a6ad540c7bca1a67f91ec11a2 Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] --- Dockerfile | 1 + backend/ee-repo-ref.txt | 2 +- backend/oauth_connect.json | 6 +- .../windmill-common/src/instance_config.rs | 15 + backend/windmill-oauth/src/lib.rs | 393 +++++++++--------- docker/RHEL8/Dockerfile | 1 + docker/RHEL9/Dockerfile | 1 + .../src/lib/components/AppConnectInner.svelte | 35 +- .../src/lib/components/AuthSettings.svelte | 50 ++- frontend/svelte.config.js | 3 +- 10 files changed, 282 insertions(+), 225 deletions(-) diff --git a/Dockerfile b/Dockerfile index e11cf9cecd..9062a4d9d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,6 +66,7 @@ RUN npm ci COPY frontend . RUN mkdir /backend COPY /backend/windmill-api/openapi.yaml /backend/windmill-api/openapi.yaml +COPY /backend/oauth_connect.json /backend/oauth_connect.json COPY /openflow.openapi.yaml /openflow.openapi.yaml COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh COPY /system_prompts/auto-generated /system_prompts/auto-generated diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index f457a34fb7..d4a79d49d1 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd +9297d8f790346e6a6ad540c7bca1a67f91ec11a2 diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index c9693b2311..d18c8c8d24 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -176,6 +176,10 @@ "token_url": "https://account.docusign.com/oauth/token", "scopes": [ "signature" - ] + ], + "sandbox": { + "auth_url": "https://account-d.docusign.com/oauth/auth", + "token_url": "https://account-d.docusign.com/oauth/token" + } } } diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 1872b52140..2239868982 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -586,6 +586,21 @@ pub struct OAuthConfig { pub req_body_auth: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub grant_types: Vec, + /// Optional URL overrides for the provider's sandbox environment. + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox: Option, +} + +/// URL overrides for an OAuth provider's sandbox environment. +#[derive(Deserialize, Serialize, Clone, Debug, Default)] +#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))] +pub struct OAuthSandboxOverride { + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub userinfo_url: Option, } // --------------------------------------------------------------------------- diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index ea65a83ba4..874a859200 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -18,9 +18,7 @@ use std::collections::HashMap; use std::fmt::Debug; use anyhow::anyhow; -use base64::Engine; use hmac::Mac; -use itertools::Itertools; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use sqlx::{Postgres, Transaction}; use tower_cookies::{Cookie, Cookies}; @@ -89,6 +87,76 @@ pub struct OAuthConfig { pub req_body_auth: Option, #[serde(default = "default_grant_types")] pub grant_types: Vec, + /// Optional URL overrides for the provider's sandbox environment. When + /// present and the admin has configured a `_sandbox` credentials + /// entry, `build_oauth_clients` registers a second client under that key. + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox: Option, +} + +/// URL overrides for an OAuth provider's sandbox environment. Inherits +/// scopes, extra_params, etc. from the parent [`OAuthConfig`]. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct OAuthSandboxOverride { + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub userinfo_url: Option, +} + +impl OAuthConfig { + /// Returns a copy of this config with sandbox URL overrides applied and + /// the nested `sandbox` field cleared. Returns `None` if no overrides are + /// set. + pub fn as_sandbox(&self) -> Option { + let sb = self.sandbox.as_ref()?; + let mut out = self.clone(); + out.sandbox = None; + if let Some(u) = &sb.auth_url { + out.auth_url = u.clone(); + } + if let Some(u) = &sb.token_url { + out.token_url = u.clone(); + } + if sb.userinfo_url.is_some() { + out.userinfo_url = sb.userinfo_url.clone(); + } + Some(out) + } +} + +/// Suffix appended to a provider name to identify its sandbox variant in the +/// instance credentials map and in `account.client`. +pub const SANDBOX_SUFFIX: &str = "_sandbox"; + +/// Strips [`SANDBOX_SUFFIX`] from a client name, returning the canonical +/// provider name. Returns the input unchanged if no suffix is present. +pub fn canonical_provider_name(client_name: &str) -> &str { + client_name + .strip_suffix(SANDBOX_SUFFIX) + .unwrap_or(client_name) +} + +/// Resolves a registry [`OAuthConfig`] for `client_name`, transparently +/// applying the `sandbox` override block when the name carries the sandbox +/// suffix (e.g. `docusign_sandbox` resolves to `docusign` with sandbox URLs +/// applied). Used so callers don't need to know whether a name is a sandbox +/// variant before looking it up. +pub fn resolve_registry_config( + static_configs: &HashMap, + client_name: &str, +) -> Option { + if let Some(cfg) = static_configs.get(client_name) { + return Some(cfg.clone()); + } + if client_name.ends_with(SANDBOX_SUFFIX) { + return static_configs + .get(canonical_provider_name(client_name)) + .and_then(|cfg| cfg.as_sandbox()); + } + None } /// OAuth client credentials @@ -181,181 +249,6 @@ pub struct OAuthCallback { pub state: String, } -/// Build all OAuth clients from configuration -pub async fn build_oauth_clients( - base_url: &str, - oauths_from_config: Option>, - connect_configs_json: &str, - login_configs_json: &str, -) -> anyhow::Result { - let connect_configs = - serde_json::from_str::>(connect_configs_json)?; - let login_configs = serde_json::from_str::>(login_configs_json)?; - - let oauths = if let Some(oauths) = oauths_from_config { - tracing::info!("Using OAuth clients from config: {oauths:?}"); - oauths - } else { - let path = "./oauth.json"; - let content: String = if let Ok(e) = std::env::var("OAUTH_JSON_AS_BASE64") { - std::str::from_utf8( - &base64::engine::general_purpose::STANDARD - .decode(e) - .map_err(to_anyhow)?, - )? - .to_string() - } else if std::path::Path::new(path).exists() { - std::fs::read_to_string(path).map_err(to_anyhow)? - } else { - tracing::warn!("oauth.json not found, no OAuth clients loaded"); - return Ok(AllClients { - logins: HashMap::new(), - connects: HashMap::new(), - slack: None, - }); - }; - - if content.is_empty() { - tracing::warn!("oauth.json is empty, no OAuth clients loaded"); - return Ok(AllClients { - logins: HashMap::new(), - connects: HashMap::new(), - slack: None, - }); - }; - match serde_json::from_str::>(&content) { - Ok(clients) => clients, - Err(e) => { - tracing::error!("deserializing oauth.json: {e}"); - HashMap::new() - } - } - .into_iter() - .collect() - }; - - tracing::info!("OAuth loaded clients: {}", oauths.keys().join(", ")); - - let logins = login_configs - .into_iter() - .filter_map(|x| oauths.get(&x.0).map(|c| (x.0, (c, x.1)))) - .chain(oauths.iter().filter_map(|x| { - x.1.login_config - .as_ref() - .map(|c| (x.0.clone(), (x.1, c.clone()))) - })) - .filter_map(|(k, (client_params, config))| { - let named_client = build_basic_client( - k.clone(), - config.clone(), - client_params.clone(), - true, - base_url, - None, - ); - named_client - .map(|named_client| { - ( - named_client.0, - ClientWithScopes { - client: named_client.1, - scopes: config.scopes.unwrap_or(vec![]), - extra_params: config.extra_params, - extra_params_callback: config.extra_params_callback, - allowed_domains: client_params.allowed_domains.clone(), - userinfo_url: config.userinfo_url, - display_name: client_params.display_name.clone(), - grant_types: client_params.grant_types.clone(), - }, - ) - }) - .map_err(|e| { - tracing::error!("Error building oauth client {k}: {e}"); - e - }) - .ok() - }) - .collect(); - - let connects = connect_configs - .into_iter() - .filter_map(|x| oauths.get(&x.0).map(|c| (x.0, (c, x.1)))) - .chain(oauths.iter().filter_map(|x| { - x.1.connect_config - .as_ref() - .map(|c| (x.0.clone(), (x.1, c.clone()))) - })) - .filter_map(|(k, (client_params, config))| { - let named_client = build_basic_client( - k.clone(), - config.clone(), - client_params.clone(), - false, - base_url, - if k == "supabase_wizard" { - Some(format!("{base_url}/oauth/callback_supabase")) - } else { - None - }, - ); - named_client - .map(|named_client| { - ( - named_client.0, - ClientWithScopes { - client: named_client.1, - scopes: config.scopes.unwrap_or(vec![]), - extra_params: config.extra_params, - extra_params_callback: config.extra_params_callback, - allowed_domains: None, - userinfo_url: None, - display_name: client_params.display_name.clone(), - grant_types: client_params.grant_types.clone(), - }, - ) - }) - .map_err(|e| { - tracing::error!("Error building oauth client {k}: {e}"); - e - }) - .ok() - }) - .collect(); - - let slack = oauths - .get("slack") - .map(|v| { - build_basic_client( - "slack".to_string(), - OAuthConfig { - auth_url: "https://slack.com/oauth/v2/authorize".to_string(), - token_url: "https://slack.com/api/oauth.v2.access".to_string(), - userinfo_url: None, - scopes: None, - extra_params: None, - extra_params_callback: None, - req_body_auth: None, - grant_types: vec!["authorization_code".to_string()], - }, - v.clone(), - false, - base_url, - Some(format!("{base_url}/oauth/callback_slack")), - ) - .map(|x| x.1) - .map_err(|e| { - tracing::error!("Error building oauth slack client: {e}"); - e - }) - .ok() - }) - .flatten(); - - let all_clients = AllClients { logins, connects, slack }; - tracing::debug!("Final oauth config: {all_clients:#?}"); - Ok(all_clients) -} - /// Build a basic OAuth client from configuration pub fn build_basic_client( name: String, @@ -433,38 +326,29 @@ pub async fn build_client_credentials_oauth_client( let oauth_client_config: OAuthClient = serde_json::from_value(oauth_config.clone()) .map_err(|e| error::Error::BadRequest(format!("Invalid OAuth config: {}", e)))?; - let mut connect_config = if let Some(ref config) = oauth_client_config.connect_config { - if !config.auth_url.is_empty() && !config.token_url.is_empty() { - config.clone() - } else { - let static_configs = - serde_json::from_str::>(connect_configs_json) - .map_err(|e| { - error::Error::InternalErr(format!( - "Failed to parse oauth_connect.json: {}", - e - )) - })?; - - static_configs.get(client_name).cloned().ok_or_else(|| { - error::Error::BadRequest(format!( - "OAuth configuration not found for '{}' in either global settings or static config", - client_name - )) - })? - } - } else { - let static_configs = - serde_json::from_str::>(connect_configs_json).map_err( - |e| error::Error::InternalErr(format!("Failed to parse oauth_connect.json: {}", e)), - )?; - - static_configs.get(client_name).cloned().ok_or_else(|| { + let parse_static_configs = || { + serde_json::from_str::>(connect_configs_json).map_err(|e| { + error::Error::InternalErr(format!("Failed to parse oauth_connect.json: {}", e)) + }) + }; + let resolve_from_registry = |client_name: &str| -> error::Result { + let static_configs = parse_static_configs()?; + resolve_registry_config(&static_configs, client_name).ok_or_else(|| { error::Error::BadRequest(format!( "OAuth configuration not found for '{}' in either global settings or static config", client_name )) - })? + }) + }; + + let mut connect_config = if let Some(ref config) = oauth_client_config.connect_config { + if !config.auth_url.is_empty() && !config.token_url.is_empty() { + config.clone() + } else { + resolve_from_registry(client_name)? + } + } else { + resolve_from_registry(client_name)? }; if let Some(override_url) = cc_token_url_override { @@ -905,4 +789,103 @@ mod tests { let verifier = SlackVerifier::new("test_secret").unwrap(); assert!(verifier.verify("123", "body", "wrong_sig").is_err()); } + + #[test] + fn canonical_provider_name_strips_sandbox_suffix() { + assert_eq!(canonical_provider_name("docusign_sandbox"), "docusign"); + assert_eq!(canonical_provider_name("docusign"), "docusign"); + assert_eq!(canonical_provider_name(""), ""); + // Only strips the suffix once; trailing suffix on already-canonical name. + assert_eq!( + canonical_provider_name("foo_sandbox_sandbox"), + "foo_sandbox" + ); + } + + fn sample_oauth_config(with_sandbox: bool) -> OAuthConfig { + OAuthConfig { + auth_url: "https://account.example.com/oauth/auth".to_string(), + token_url: "https://account.example.com/oauth/token".to_string(), + userinfo_url: Some("https://account.example.com/userinfo".to_string()), + scopes: Some(vec!["signature".to_string()]), + extra_params: None, + extra_params_callback: None, + req_body_auth: None, + grant_types: default_grant_types(), + sandbox: with_sandbox.then(|| OAuthSandboxOverride { + auth_url: Some("https://account-d.example.com/oauth/auth".to_string()), + token_url: Some("https://account-d.example.com/oauth/token".to_string()), + userinfo_url: None, + }), + } + } + + #[test] + fn as_sandbox_returns_none_when_no_override() { + assert!(sample_oauth_config(false).as_sandbox().is_none()); + } + + #[test] + fn as_sandbox_overlays_urls_and_inherits_rest() { + let resolved = sample_oauth_config(true).as_sandbox().unwrap(); + // URLs overridden by sandbox block + assert_eq!( + resolved.auth_url, + "https://account-d.example.com/oauth/auth" + ); + assert_eq!( + resolved.token_url, + "https://account-d.example.com/oauth/token" + ); + // userinfo_url not in override → inherits from parent + assert_eq!( + resolved.userinfo_url, + Some("https://account.example.com/userinfo".to_string()) + ); + // Scopes/grant_types inherited from parent + assert_eq!(resolved.scopes, Some(vec!["signature".to_string()])); + assert_eq!(resolved.grant_types, default_grant_types()); + // Nested sandbox field cleared on the resolved config + assert!(resolved.sandbox.is_none()); + } + + #[test] + fn resolve_registry_config_direct_lookup() { + let mut registry = HashMap::new(); + registry.insert("docusign".to_string(), sample_oauth_config(true)); + + let resolved = resolve_registry_config(®istry, "docusign").unwrap(); + assert_eq!(resolved.auth_url, "https://account.example.com/oauth/auth"); + // Direct lookup returns the entry as-is (sandbox block still attached). + assert!(resolved.sandbox.is_some()); + } + + #[test] + fn resolve_registry_config_sandbox_fallback() { + let mut registry = HashMap::new(); + registry.insert("docusign".to_string(), sample_oauth_config(true)); + + let resolved = resolve_registry_config(®istry, "docusign_sandbox").unwrap(); + // Sandbox-suffixed lookup resolves to parent's sandbox-overlaid config. + assert_eq!( + resolved.auth_url, + "https://account-d.example.com/oauth/auth" + ); + assert!(resolved.sandbox.is_none()); + } + + #[test] + fn resolve_registry_config_missing_returns_none() { + let registry: HashMap = HashMap::new(); + assert!(resolve_registry_config(®istry, "docusign").is_none()); + assert!(resolve_registry_config(®istry, "docusign_sandbox").is_none()); + } + + #[test] + fn resolve_registry_config_sandbox_without_block_returns_none() { + let mut registry = HashMap::new(); + // Parent exists but has no sandbox override. + registry.insert("docusign".to_string(), sample_oauth_config(false)); + assert!(resolve_registry_config(®istry, "docusign_sandbox").is_none()); + } } diff --git a/docker/RHEL8/Dockerfile b/docker/RHEL8/Dockerfile index cb5f36cef5..500050de67 100644 --- a/docker/RHEL8/Dockerfile +++ b/docker/RHEL8/Dockerfile @@ -30,6 +30,7 @@ RUN npm ci COPY frontend . RUN mkdir /backend COPY /backend/windmill-api/openapi.yaml /backend/windmill-api/openapi.yaml +COPY /backend/oauth_connect.json /backend/oauth_connect.json COPY /openflow.openapi.yaml /openflow.openapi.yaml COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh COPY /system_prompts/auto-generated /system_prompts/auto-generated diff --git a/docker/RHEL9/Dockerfile b/docker/RHEL9/Dockerfile index 6d96804381..a0fff8dd91 100644 --- a/docker/RHEL9/Dockerfile +++ b/docker/RHEL9/Dockerfile @@ -30,6 +30,7 @@ RUN npm ci COPY frontend . RUN mkdir /backend COPY /backend/windmill-api/openapi.yaml /backend/windmill-api/openapi.yaml +COPY /backend/oauth_connect.json /backend/oauth_connect.json COPY /openflow.openapi.yaml /openflow.openapi.yaml COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh COPY /system_prompts/auto-generated /system_prompts/auto-generated diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 4f0a5a76b2..7b6ed37ecc 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -74,6 +74,16 @@ let value: string = $state('') let valueToken: TokenResponse | undefined = undefined let connects: string[] | undefined = $state(undefined) + + const SANDBOX_SUFFIX = '_sandbox' + function stripSandboxSuffix(name: string): string { + return name.endsWith(SANDBOX_SUFFIX) ? name.slice(0, -SANDBOX_SUFFIX.length) : name + } + // `resourceType` is always the canonical type (e.g. `docusign`) so resource + // rows are uniform. `connectClient` carries the suffixed OAuth client name + // (e.g. `docusign_sandbox`) used to look up credentials/URLs at runtime + // and stored on `account.client` so token refresh hits the right endpoint. + let connectClient: string = $state('') let connectsManual: { key: string; img?: string; instructions: string[] }[] | undefined = $state(undefined) let args: any = $state({}) @@ -152,7 +162,9 @@ description = '' labels = undefined wsSpecific = false - resourceType = rt ?? '' + const rawRt = rt ?? '' + connectClient = rawRt + resourceType = stripSandboxSuffix(rawRt) valueToken = undefined // Reset client credentials state @@ -163,7 +175,7 @@ tokenUrl = '' await loadConnects() - manual = !connects?.includes(resourceType) + manual = !connects?.includes(connectClient) if (manual && express) { dispatch('error', 'Express OAuth setup is not available for non OAuth resource types') return @@ -312,7 +324,8 @@ sendUserToast(data.error, true) step = 2 } else if (data.type === 'success') { - resourceType = data.resource_type + connectClient = data.resource_type + resourceType = stripSandboxSuffix(connectClient) value = data.res.access_token! valueToken = data.res responseExtra = data.extra ?? {} @@ -325,7 +338,7 @@ } async function getScopesAndParams() { - const connect = await OauthService.getOauthConnect({ client: resourceType }) + const connect = await OauthService.getOauthConnect({ client: connectClient }) scopes = connect.scopes ?? [] extra_params = Object.entries(connect.extra_params ?? {}) as [string, string][] @@ -401,7 +414,7 @@ } const tokenResponse = await OauthService.connectClientCredentials({ - client: resourceType, + client: connectClient, requestBody }) @@ -428,7 +441,7 @@ * Requires user interaction and consent * Opens popup for user to authenticate with OAuth provider */ - const url = new URL(`/api/oauth/connect/${resourceType}`, window.location.origin) + const url = new URL(`/api/oauth/connect/${connectClient}`, window.location.origin) url.searchParams.append('scopes', scopes.join('+')) if (extra_params.length > 0) { extra_params.forEach(([key, value]) => url.searchParams.append(key, value)) @@ -490,7 +503,7 @@ const accountData: any = { refresh_token: valueToken.refresh_token ?? '', expires_in: valueToken.expires_in, - client: resourceType, + client: connectClient, grant_type: valueToken.grant_type || 'authorization_code' } @@ -602,6 +615,7 @@ ) step = 1 resourceType = '' + connectClient = '' } } @@ -660,10 +674,11 @@ +
{/if} -
- - -
{#if isSideBySide} -
-
- - {#snippet leftHeader()} - Before - {/snippet} - -
+
+ {#if beforeMissing} + + Before (no prior version) + + {:else} +
+ (beforeContentHeight = h)} + > + {#snippet leftHeader()} + Before + {/snippet} + +
+ {/if}
-
-
- - {#snippet leftHeader()} - After - {/snippet} - -
+
+ {#if afterMissing} + + After (flow deleted) + + {:else} +
+ (afterContentHeight = h)} + > + {#snippet leftHeader()} + After + {/snippet} + +
+ {/if}
@@ -219,7 +299,7 @@ editMode={false} download={false} scroll={false} - minHeight={400} + minHeight={Math.max(contentAreaHeight, SHARED_MIN_HEIGHT)} triggerNode={false} />
@@ -231,3 +311,31 @@

Loading graphs...

{/if} + + diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 710ce9bd5d..9dc36e3503 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -81,7 +81,7 @@ import { writable } from 'svelte/store' import { defaultScriptLanguages, processLangs } from '$lib/scripts' import DefaultScripts from './DefaultScripts.svelte' - import { onMount, setContext, untrack } from 'svelte' + import { getContext, onMount, setContext, untrack } from 'svelte' import EditorHeader from './EditorHeader.svelte' import LabelsInput from './LabelsInput.svelte' @@ -134,7 +134,9 @@ onSaveDraftError, onSaveDraft, onNavigate, - disableAi + disableAi, + initialTestPanelCollapsed = false, + initialPathChosen = false }: ScriptBuilderProps = $props() export function getInitialAndModifiedValues(): SavedAndModifiedValue { @@ -626,17 +628,23 @@ if (!disableHistoryChange) { history.replaceState(history.state, '', `/scripts/edit/${script.path}`) } - if ( + // "Stay" deploys (explicit "Deploy & Stay here" or lib scripts) keep the + // editor in place rather than navigating to the deployed item. + const stayHere = stay || (script.auto_kind === 'lib' && script.kind !== 'preprocessor' && !isWorkflowAsCode(script.content, script.language)) - ) { + if (stayHere) { + // Re-pin parent_hash so the next deploy's conflict check is against + // the version we just wrote. script.parent_hash = newHash - sendUserToast('Deployed') - } else { - onDeploy?.({ path: script.path, hash: newHash }) } + // Always notify on a successful deploy; the consumer decides whether to + // navigate (route) or stay + sync the preview (session). Previously the + // stay/lib branch skipped onDeploy, so session previews didn't sync after + // a "Deploy & Stay here" or lib-script deploy. + onDeploy?.({ path: script.path, hash: newHash, stay: stayHere }) } catch (error) { onDeployError?.({ path: script.path, error }) sendUserToast(`Error while saving the script: ${error.body || error.message}`, true) @@ -793,6 +801,12 @@ loadingDraft = false } + // Inside an AI session pane (which injects an aiChatManager via context) the + // extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace + // fork, Exit & See details, Export — don't make sense: the session always + // stays put and is already scoped to a fork. Only "Show diff" is kept. + const inSessionPane = !!getContext('aiChatManager') + function computeDropdownItems( initialPath: string, savedScript: NewScriptWithDraftAndDraftTriggers | undefined, @@ -801,26 +815,30 @@ let dropdownItems: { label: string; onClick: () => void }[] = initialPath != '' && customUi?.topBar?.extraDeployOptions != false ? [ - { - label: 'Deploy & Stay here', - onClick: () => { - handleEditScript(true) - } - }, - { - label: 'Fork', - onClick: () => { - window.open(`/scripts/add?template=${initialPath}`) - } - }, - ...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') + ...(!inSessionPane ? [ { - label: 'Edit in workspace fork', + label: 'Deploy & Stay here', onClick: () => { - window.open(buildForkEditUrl('script', initialPath)) + handleEditScript(true) } - } + }, + { + label: 'Fork', + onClick: () => { + window.open(`/scripts/add?template=${initialPath}`) + } + }, + ...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') + ? [ + { + label: 'Edit in workspace fork', + onClick: () => { + window.open(buildForkEditUrl('script', initialPath)) + } + } + ] + : []) ] : []), ...(customUi?.topBar?.diff !== false && savedScript && diffDrawer @@ -852,7 +870,10 @@ } ] : []), - ...(!script.draft_only && script.kind === 'script' && !script.auto_kind + ...(!inSessionPane && + !script.draft_only && + script.kind === 'script' && + !script.auto_kind ? [ { label: 'Exit & See details', @@ -862,7 +883,7 @@ } ] : []), - ...(isWorkflowAsCode(script.content, script.language) + ...(!inSessionPane && isWorkflowAsCode(script.content, script.language) ? [ { label: 'Export as YAML/JSON', @@ -875,7 +896,11 @@ ] : [] - if (dropdownItems.length === 0 && isWorkflowAsCode(script.content, script.language)) { + if ( + !inSessionPane && + dropdownItems.length === 0 && + isWorkflowAsCode(script.content, script.language) + ) { dropdownItems = [ { label: 'Export as YAML/JSON', @@ -901,7 +926,11 @@ } let path: Path | undefined = $state(undefined) - let dirtyPath = $state(false) + // Seed "path is already chosen" so the summary→path auto-slug (which only + // runs for new scripts with initialPath == '') doesn't clobber a path the + // caller pre-assigned. The session preview opens AI-created scripts as new + // (empty initialPath) but with a path the AI already picked. + let dirtyPath = $state(initialPathChosen) let selectedTab: 'metadata' | 'runtime' | 'ui' | 'triggers' = $state( (() => { @@ -2091,6 +2120,7 @@ bind:assets={script.assets} bind:modules={script.modules} enablePreprocessorSnippet + {initialTestPanelCollapsed} />
{:else} diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 9fe173f0ba..a281b266da 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -160,6 +160,11 @@ modules?: { [key: string]: ScriptModule } | null editorBarRight?: import('svelte').Snippet enablePreprocessorSnippet?: boolean + // When true the right-hand test/run pane mounts collapsed. The user + // can still expand it via `toggleTestPanel`. Defaults to false so the + // regular /scripts/edit route keeps its current open-by-default UX; + // the session preview opts in to save vertical real estate. + initialTestPanelCollapsed?: boolean } let { @@ -193,7 +198,8 @@ assets = $bindable(), modules = $bindable(undefined), editorBarRight, - enablePreprocessorSnippet = false + enablePreprocessorSnippet = false, + initialTestPanelCollapsed = false }: Props = $props() let initialArgs = structuredClone($state.snapshot(args)) @@ -1360,8 +1366,11 @@ // dynamic minimum below — so when the editor shrinks, the displayed test // pane grows to honor the new minimum without needing an effect. The code // pane's size is purely derived from it (100 - test). - let rawTestPanelSize = $state(30) - let storedTestPanelSize = untrack(() => rawTestPanelSize) + // `initialTestPanelCollapsed` seeds the raw value at 0 (collapsed) while + // keeping the "remembered" size at 30, so the user's first toggle expands + // the pane to a sensible width rather than 0. + let rawTestPanelSize = $state(untrack(() => (initialTestPanelCollapsed ? 0 : 30))) + let storedTestPanelSize = 30 const testPanelSize = $derived( rawTestPanelSize === 0 ? 0 : Math.max(rawTestPanelSize, testPaneMinPercent) ) diff --git a/frontend/src/lib/components/WorkspaceItemDiffViewer.svelte b/frontend/src/lib/components/WorkspaceItemDiffViewer.svelte new file mode 100644 index 0000000000..2ee01b4607 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceItemDiffViewer.svelte @@ -0,0 +1,162 @@ + + + +{#if kind === 'flow'} +
+ +
+{:else if hasContent} +
+ + + + +
+ {#if contentTab === 'content'} + {#await import('$lib/components/DiffEditor.svelte')} +
+ {:then Module} + + {/await} + {:else} + {#await import('$lib/components/DiffEditor.svelte')} +
+ {:then Module} + + {/await} + {/if} +
+
+{:else} + {#await import('$lib/components/DiffEditor.svelte')} +
+ {:then Module} +
+ +
+ {/await} +{/if} diff --git a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte index abee584afa..9136f52205 100644 --- a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte +++ b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte @@ -17,6 +17,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level import { ChevronLeft, ChevronRight, Folder, Layers, Loader2, User } from 'lucide-svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import RowIcon from '$lib/components/common/table/RowIcon.svelte' + import WorkspaceItemRow from '$lib/components/WorkspaceItemRow.svelte' import SearchItems from '$lib/components/SearchItems.svelte' import { onMount, untrack } from 'svelte' import { @@ -30,6 +31,8 @@ Clicking a row drills *down*; the chevron-left in the header walks one level type WorkspaceItem, type WorkspaceItemKind } from './workspacePicker' + import { listGlobalDrafts } from '$lib/components/copilot/chat/global/userDraftAdapter' + import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate' type Kind = WorkspaceItemKind type Item = WorkspaceItem @@ -72,8 +75,16 @@ Clicking a row drills *down*; the chevron-left in the header walks one level // Sibling-popover open: melt-ui's `openFocus` runs once during the close→open // transition; the picker may not be mounted yet. Retry after settle. + // Also kicks off the initial scope's fetch — drill/goUp do the same from + // their respective branches, so `ensureLoaded` is always a callback + // reaction to user navigation, never a reactive consequence. onMount(() => { const t = setTimeout(focus, 50) + const initial = untrack(() => scope) + if (initial) { + if (initial.kind === 'all') for (const k of kinds) ensureLoaded(k) + else ensureLoaded(initial.kind) + } return () => clearTimeout(t) }) @@ -82,6 +93,22 @@ Clicking a row drills *down*; the chevron-left in the header walks one level let scope = $state(untrack(() => initialScope)) let filter = $state('') + /** + * Canonical entry point for changing the picker's scope. Triggers the + * fetch for the kind(s) the new scope needs at the same point in time. + * Replaces the older "react to `scope` change via `$effect`" wiring, + * which had a subtle bug: `ensureLoaded` reads `loaded[kind]`, so the + * effect ended up subscribed to the signal it fills — every fetch + * result re-fired it. With explicit callbacks the fetch is tied to + * the user's action, never to a reactive consequence of that action. + */ + function setScope(next: Scope) { + scope = next + if (!next) return + if (next.kind === 'all') for (const k of kinds) ensureLoaded(k) + else ensureLoaded(next.kind) + } + /** Tracks whether the last user action was mouse movement (true) or * keyboard nav (false). When false, row `mouseenter` events are ignored * — prevents the cursor from stealing the keyboard-driven highlight as @@ -90,10 +117,11 @@ Clicking a row drills *down*; the chevron-left in the header walks one level * mounts under a stationary cursor doesn't clobber `initialHighlight`. */ let mouseActive = $state(false) - // Seed from cache so kinds already fetched in this session render on the - // first frame. Read once at mount: melt-ui mounts a fresh picker per - // popover open, so workspace changes are picked up at the next open - // without needing this seed to be reactive. + // Seed from the last fetched snapshot so kinds already fetched in this + // session render on the first frame. Each entry is replaced once + // `loadKind` returns fresh data — stale-while-revalidate, so deploys and + // AI-created drafts surface on the next open without explicit cache + // busting. let loaded = $state>>( (() => { if (!$workspaceStore) return {} @@ -109,8 +137,15 @@ Clicking a row drills *down*; the chevron-left in the header walks one level async function ensureLoaded(kind: Kind) { if (!$workspaceStore) return - if (loaded[kind]) return - loadingKind[kind] = true + // Always re-fetch. If we have nothing cached, show a spinner; if we do, + // keep displaying it and quietly swap to fresh data when it lands. + // `loaded[kind]` is read inside `untrack(...)` because this function is + // reachable from the search `$effect` below — without the untrack, + // that effect would subscribe to the signal `ensureLoaded` fills, and + // each `loaded[kind] = items` (proxy `set` notifies even when the ref + // is unchanged from cache) would refire it → runaway loop. Drill + // navigation goes through `setScope` directly so it isn't affected. + if (!untrack(() => loaded[kind])) loadingKind[kind] = true try { const items = await loadKind($workspaceStore, kind) loaded[kind] = items @@ -119,13 +154,31 @@ Clicking a row drills *down*; the chevron-left in the header walks one level } } - // Fetch the scope's kind on entry to a non-root level. The `'all'` scope - // needs every kind loaded since it merges items across them. - $effect(() => { - if (!scope) return - if (scope.kind === 'all') for (const k of kinds) ensureLoaded(k) - else ensureLoaded(scope.kind) - }) + // Chat tools and session editor previews write drafts through + // `UserDraft` (workspace-scoped, localStorage-backed). Merge those into + // the picker so users can navigate to in-flight items that haven't been + // deployed yet. Filter to kinds the picker actually displays. + // + // Gated on the same dev flag as the rest of the sessions feature: without + // it there are no sessions, so the only UserDrafts present are the + // standalone editors' autosaves — surfacing those in the breadcrumb picker + // would be surprising (they'd appear as navigable items that 404 on the + // backend draft fetch). When the flag is off this is a no-op. + const KIND_TO_DRAFT_TYPE = { flow: 'flow', script: 'script', app: 'app' } as const + function aiDraftsForKind(k: Kind): Item[] { + if (!isGlobalAiEnabled()) return [] + if (!$workspaceStore) return [] + const targetType = KIND_TO_DRAFT_TYPE[k] + return listGlobalDrafts($workspaceStore) + .filter((d) => d.type === targetType) + .map((d) => ({ + path: d.path, + summary: d.summary ?? '', + kind: k, + // `raw_app` lives on the draft envelope for legacy/raw-app distinction. + raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined + })) + } // Searching is global → load every kind. $effect(() => { @@ -140,6 +193,17 @@ Clicking a row drills *down*; the chevron-left in the header walks one level leaves: Item[] } + /** Merge AI-created in-memory drafts into a kind's list. The AI may have + * scaffolded a script/flow/app via chat tools without the user saving + * yet — those drafts should be navigable from the picker. Existing items + * (same path) win to keep the backend's metadata (summary etc.). */ + function withAiDrafts(items: Item[], k: Kind): Item[] { + const ai = aiDraftsForKind(k) + if (ai.length === 0) return items + const known = new Set(items.map((it) => it.path)) + return items.concat(ai.filter((d) => !known.has(d.path))) + } + /** Inject the currently-edited item into a kind's list at its live path, * dropping the saved entry when a draft rename is in progress. Other kinds * pass through untouched. */ @@ -207,7 +271,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level * cached. */ function buildIfActive(k: Kind, list: Item[] | undefined): DirNode[] { if (!kinds.includes(k)) return [] - const items = withCurrent(list ?? [], k) + const items = withAiDrafts(withCurrent(list ?? [], k), k) if (items.length === 0) return [] return buildTreeFromItems(items) } @@ -219,7 +283,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level * one folder hierarchy. Each leaf still carries its real kind, so the row * icon and `editPathFor` routing still work; folders contain a mix. */ const allTree = $derived.by(() => { - const merged = kinds.flatMap((k) => withCurrent(loaded[k] ?? [], k)) + const merged = kinds.flatMap((k) => withAiDrafts(withCurrent(loaded[k] ?? [], k), k)) return merged.length === 0 ? [] : buildTreeFromItems(merged) }) @@ -255,7 +319,10 @@ Clicking a row drills *down*; the chevron-left in the header walks one level let allItems = $derived( kinds.flatMap((k) => - withCurrent(loaded[k] ?? [], k).map((it) => ({ ...it, _key: `${k}:${it.path}` })) + withAiDrafts(withCurrent(loaded[k] ?? [], k), k).map((it) => ({ + ...it, + _key: `${k}:${it.path}` + })) ) ) @@ -383,9 +450,9 @@ Clicking a row drills *down*; the chevron-left in the header walks one level function drill(entry: Entry) { if (entry.type === 'kind') { - scope = { kind: entry.kind } + setScope({ kind: entry.kind }) } else if (entry.type === 'dir') { - scope = { kind: entry.kind, dir: entry.node.fullPath } + setScope({ kind: entry.kind, dir: entry.node.fullPath }) } else { pick(entry.item) } @@ -397,13 +464,13 @@ Clicking a row drills *down*; the chevron-left in the header walks one level // just left, so the user sees where they came from. if (!scope.dir) { const leaving = kindKey(scope.kind) - scope = undefined + setScope(undefined) highlightedKey = leaving return } const leaving = dirKey(scope.kind, scope.dir) const parent = parentDirPath(scope.dir) - scope = parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind } + setScope(parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind }) highlightedKey = leaving } @@ -528,32 +595,18 @@ Clicking a row drills *down*; the chevron-left in the header walks one level {#snippet leafRow(it: Item, secondary: string, baseClass: string)} {@const key = leafKey(it)} - {@const isHl = key === highlightedKey} - {@const isCur = isCurrent(it)} - + /> {/snippet} diff --git a/frontend/src/lib/components/WorkspaceItemRow.svelte b/frontend/src/lib/components/WorkspaceItemRow.svelte new file mode 100644 index 0000000000..8ddd3fa317 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceItemRow.svelte @@ -0,0 +1,148 @@ + + + + + +{#if href} + + +
+ {#if summary} +
{summary}
+
{secondary}
+ {:else} +
{secondary}
+ {/if} +
+ {#if extras} +
+ {@render extras()} +
+ {/if} +
+{:else} + +{/if} diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index d6b2a7818d..cd9acdbffa 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -3,7 +3,7 @@ const bubble = createBubbler() import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte' - import { onMount, setContext, untrack } from 'svelte' + import { getContext, onMount, setContext, untrack } from 'svelte' import { twMerge } from 'tailwind-merge' import { Pane, Splitpanes } from 'svelte-splitpanes' @@ -79,20 +79,29 @@ gotoFn = (path: string, opt?: Record) => window.history.pushState(null, '', path), unsavedConfirmationModal, onSavedNewAppPath, + onNavigate, initialRevs }: AppEditorProps = $props() migrateApp(untrack(() => app)) + // Inside a session pane the AIChatManager is injected via context. Sessions + // have their own state machinery (sessionRuntime + per-fork backend), and + // the user-facing $workspaceStore stays on the main workspace even when + // the session is editing in a fork — so a UserDraft handle here would + // share its LS key with the regular /apps/edit route and clobber both + // sides' autosaves. Skip UserDraft entirely in that case. + const inSessionPane = !!getContext('aiChatManager') + const appDraftPath = newApp ? '' : (path ?? '') - const appDraftHandle = UserDraft.use('app', appDraftPath) + const appDraftHandle = inSessionPane ? undefined : UserDraft.use('app', appDraftPath) // Prefer the persisted autosave over the prop when both exist (e.g. // /apps/add reload: the route always initializes `app` to an empty // template, but the user's last session is sitting in LS under the // empty-path entry). The route is responsible for wiping the entry // (`UserDraft.remove`) when it wants to force a fresh start — // `?nodraft=true`, template/hub loads, etc. - const stateApp = $state(untrack(() => appDraftHandle.draft ?? app)) + const stateApp = $state(untrack(() => appDraftHandle?.draft ?? app)) const appStore = writable(stateApp) // Captured once on mount: the load-time revs are only used as the // seed meta on the very first persist of this entry. After that the @@ -112,6 +121,7 @@ let firstMirror = true $effect(() => { readFieldsRecursively(stateApp) + if (!appDraftHandle) return untrack(() => { // Resolve the meta to attach BEFORE the wipe — the wipe clears // in-memory meta and would otherwise force-seed `initialRevs` @@ -884,6 +894,7 @@ rightPanelHidden={rightPanelSize === 0} bottomPanelHidden={runnablePanelSize === 0} {onSavedNewAppPath} + {onNavigate} onShowLeftPanel={() => showLeftPanel()} onShowRightPanel={() => showRightPanel()} onShowBottomPanel={() => showBottomPanel()} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index 7a55f28860..5bbd0d17c7 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -64,7 +64,7 @@ import DebugPanel from './contextPanel/DebugPanel.svelte' import EditorHeader from '$lib/components/EditorHeader.svelte' - import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker' + import { editPathFor } from '$lib/components/workspacePicker' import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte' import { goto } from '$app/navigation' import HideButton from './settingsPanel/HideButton.svelte' @@ -110,6 +110,7 @@ onHideRightPanel?: () => void onHideLeftPanel?: () => void onHideBottomPanel?: () => void + onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void } let { @@ -130,7 +131,8 @@ onShowBottomPanel, onHideLeftPanel, onHideRightPanel, - onHideBottomPanel + onHideBottomPanel, + onNavigate = undefined }: Props = $props() /** Mirror of the path the user is editing in the pen popover. Initialized @@ -170,6 +172,14 @@ const { history, jobsDrawerOpen, refreshComponents } = getContext('AppEditorContext') + // Sessions inject an AIChatManager via context; AppEditor skips its + // UserDraft handle in that case, so the cleanup calls here must skip too + // (otherwise we'd wipe a non-session tab's autosave at the same path). The + // session-side equivalent is the View's `onDeploy` → + // `runtime.syncPreviewWithDeployed`, which discards the fork draft + reloads + // the preview to the deployed version. + const inSessionPane = !!getContext('aiChatManager') + const loading = $state({ publish: false, save: false, @@ -229,7 +239,7 @@ } closeSaveDrawer() sendUserToast('App deployed successfully') - UserDraft.remove('app', path) + if (!inSessionPane) UserDraft.remove('app', path) onSavedNewAppPath?.(path) } catch (e) { sendUserToast('Error creating app', e) @@ -313,7 +323,6 @@ preserve_on_behalf_of: preserveOnBehalfOf || undefined } }) - invalidatePicker($workspaceStore!, 'app') invalidateWorkspacePaths($workspaceStore!) savedApp = { summary: $summary, @@ -330,7 +339,7 @@ closeSaveDrawer() sendUserToast('App deployed successfully') - UserDraft.remove('app', $appPath) + if (!inSessionPane) UserDraft.remove('app', $appPath) if ($appPath !== npath) { onSavedNewAppPath?.(npath) } @@ -406,7 +415,7 @@ // The initial draft was promoted to a real path on the backend — // drop the autosave keyed on the prior (possibly empty) path so // a future "+ App" click opens on a clean slate. - UserDraft.remove('app', $appPath) + if (!inSessionPane) UserDraft.remove('app', $appPath) onSavedNewAppPath?.(newEditedPath) } catch (e) { sendUserToast('Error saving initial draft', e) @@ -497,7 +506,7 @@ } sendUserToast('Draft saved') - UserDraft.remove('app', path) + if (!inSessionPane) UserDraft.remove('app', path) loading.saveDraft = false if (newApp || savedApp.draft_only) { onSavedNewAppPath?.(newEditedPath || path) @@ -1006,7 +1015,7 @@ bind:path={newEditedPath} savedPath={$appPath || newPath || undefined} kind="app" - onNavigate={(item) => goto(editPathFor(item))} + onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))} />
{#if $app} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index 02ce70f64f..2294554f3e 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -139,7 +139,7 @@ }) $effect(() => { - appPath && appPath != '' && secretUrl == undefined && untrack(() => getSecretUrl()) + appPath && appPath != '' && savedApp && secretUrl == undefined && untrack(() => getSecretUrl()) }) @@ -264,10 +264,10 @@ policy.execution_mode = e.detail ? 'anonymous' : 'publisher' setPublishState() }} - disabled={appPath == ''} + disabled={!savedApp} />
- {#if appPath == ''} + {#if !savedApp} {:else if secretUrlHref}
diff --git a/frontend/src/lib/components/apps/types.ts b/frontend/src/lib/components/apps/types.ts index 2fe6536aa8..64b08d621e 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -164,6 +164,8 @@ export interface AppEditorProps { gotoFn?: (path: string, opt?: Record | undefined) => void unsavedConfirmationModal?: import('svelte').Snippet<[any]> onSavedNewAppPath?: (path: string) => void + /** Override breadcrumb-picker navigation. Defaults to goto(editPathFor(item)). */ + onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void /** * Backend revs at the load that produced `app`. Used as the seed * `UserDraft` meta on the first local autosave: until the handle has diff --git a/frontend/src/lib/components/common/EditableInput.svelte b/frontend/src/lib/components/common/EditableInput.svelte index e772247c0a..e85625c58f 100644 --- a/frontend/src/lib/components/common/EditableInput.svelte +++ b/frontend/src/lib/components/common/EditableInput.svelte @@ -78,6 +78,15 @@ this component just proposes new values. }) } + // External trigger (e.g. from a Melt dropdown menu item). Melt's focus trap + // stays active for a brief window after the menu closes — focusing our + // input during that window causes checkFocusIn to slam focus back out, which + // fires onblur=save and instantly closes the edit. A 50ms defer is enough + // for Melt's trap to release. + export function edit() { + setTimeout(startEditing, 50) + } + function save() { // Re-entry guard: Enter calls `save()` and sets `editing = false`, // which unmounts the `` and synchronously fires its `blur` diff --git a/frontend/src/lib/components/copilot/chat/AIChat.svelte b/frontend/src/lib/components/copilot/chat/AIChat.svelte index 07ccd1efb1..c6366113c9 100644 --- a/frontend/src/lib/components/copilot/chat/AIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChat.svelte @@ -3,30 +3,61 @@ import { untrack } from 'svelte' import { type ScriptLang } from '$lib/gen' import { dbSchemas, userStore, workspaceStore } from '$lib/stores' - import { aiChatManager, AIMode } from './AIChatManager.svelte' + import { AIMode } from './AIChatManager.svelte' + import { getAiChatManager } from './aiChatManagerContext' + + const aiChatManager = getAiChatManager() import { base } from '$lib/base' import HideButton from '$lib/components/apps/editor/settingsPanel/HideButton.svelte' import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './script/core' import { copilotInfo, copilotSessionModel } from '$lib/aiStore' + let { + hideHeader = false, + hideModeSelector = false, + forceDisabled = false, + forceDisabledMessage = '', + wideLayout = false, + emptyHint, + inputPreface + }: { + hideHeader?: boolean + hideModeSelector?: boolean + // External "you can't type here" override. Used by sessions when + // the session's committed workspace was deleted/archived so the + // chat is effectively read-only until the user moves or discards + // the session. Wins over the internal disabled derivation. + forceDisabled?: boolean + forceDisabledMessage?: string + // Forwarded to AIChatDisplay. When true, the messages / input + // columns are centered in a max-w-3xl px-8 box. Sessions opt + // in; the narrow global-chat panel leaves it off. + wideLayout?: boolean + emptyHint?: import('svelte').Snippet + inputPreface?: import('svelte').Snippet + } = $props() + const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin) const hasCopilot = $derived($copilotInfo.enabled) const disabled = $derived( - !hasCopilot || + forceDisabled || + !hasCopilot || (aiChatManager.mode === AIMode.SCRIPT && aiChatManager.scriptEditorOptions?.lang && !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)) ) const disabledMessage = $derived( - !hasCopilot - ? isAdmin - ? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat` - : 'Ask an admin to enable Windmill AI in this workspace to use this chat' - : aiChatManager.mode === AIMode.SCRIPT && - aiChatManager.scriptEditorOptions?.lang && - !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang) - ? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.` - : '' + forceDisabled + ? forceDisabledMessage + : !hasCopilot + ? isAdmin + ? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat` + : 'Ask an admin to enable Windmill AI in this workspace to use this chat' + : aiChatManager.mode === AIMode.SCRIPT && + aiChatManager.scriptEditorOptions?.lang && + !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang) + ? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.` + : '' ) const suggestions = [ @@ -53,6 +84,10 @@ aiChatManager.sendRequest(options) } + export function focusInput() { + aiChatDisplay?.focusInput() + } + const historyManager = aiChatManager.historyManager let aiChatDisplay: AIChatDisplay | undefined = $state(undefined) @@ -129,4 +164,9 @@ {disabled} {disabledMessage} {suggestions} + {hideHeader} + {hideModeSelector} + {wideLayout} + {emptyHint} + {inputPreface} > diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 6e2f21ce96..5b0e549dd0 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -61,7 +61,7 @@ import { runChatLoop } from './chatLoop' import type { ReviewChangesOpts } from './monaco-adapter' import { getCurrentModel, tryGetCurrentModel, getCombinedCustomPrompt } from '$lib/aiStore' import type { WorkspaceMutationTarget } from './workspaceTools' -import { globalTools, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './global/core' +import { globalToolsFor, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './global/core' import { isGlobalAiEnabled } from './global/gate' // If the estimated token usage is greater than the model context window - the threshold, we delete the oldest message @@ -208,13 +208,26 @@ export class AIChatManager { private userQuestionCallbacks = new Map void>() private appDatatablesRefreshTimeout: ReturnType | undefined = undefined + disabledModes: Partial> = $state({}) + // Set by AI sessions. Enables the session-only preview tools (open_preview / + // get_preview_status) and their system-prompt guidance in GLOBAL mode; the + // global side-panel chat leaves it false so those tools aren't offered. + isSessionChat = false + // The session this manager belongs to (session chats only). Carried into the + // tool `helpers` in GLOBAL mode so the preview/deploy tools dispatch to THIS + // session rather than the UI-active one — keeps backgrounded sessions isolated. + sessionId: string | undefined = undefined + allowedModes: Record = $derived({ - script: this.flowAiChatHelpers === undefined && this.scriptEditorOptions !== undefined, - flow: this.flowAiChatHelpers !== undefined, - app: this.appAiChatHelpers !== undefined, - navigator: true, - ask: true, - API: true, + script: + this.flowAiChatHelpers === undefined && + this.scriptEditorOptions !== undefined && + !this.disabledModes.script, + flow: this.flowAiChatHelpers !== undefined && !this.disabledModes.flow, + app: this.appAiChatHelpers !== undefined && !this.disabledModes.app, + navigator: !this.disabledModes.navigator, + ask: !this.disabledModes.ask, + API: !this.disabledModes.API, // Dev-only gate. See `./global/gate.ts` for how to enable. global: isAIModeVisible(AIMode.GLOBAL) }) @@ -495,9 +508,11 @@ export class AIChatManager { this.helpers = {} } else if (mode === AIMode.GLOBAL) { const customPrompt = getCombinedCustomPrompt(mode) - this.systemMessage = prepareGlobalSystemMessage(customPrompt) - this.tools = [...globalTools] - this.helpers = {} + this.systemMessage = prepareGlobalSystemMessage(customPrompt, { + previewTools: this.isSessionChat + }) + this.tools = globalToolsFor({ sessionPreview: this.isSessionChat }) + this.helpers = this.isSessionChat ? { sessionId: this.sessionId } : {} } else if (mode === AIMode.APP) { const customPrompt = getCombinedCustomPrompt(mode) this.systemMessage = prepareAppSystemMessage(customPrompt) @@ -795,6 +810,12 @@ export class AIChatManager { } } + // Optional pre-flight hook called once per send, after validation but + // before any UI state mutates or backend calls go out. Sessions use + // this to commit/materialise the workspace (creating a staged fork via + // the API) so the first message targets the correct workspace. + beforeSend?: () => Promise | void + sendRequest = async ( options: { removeDiff?: boolean @@ -819,6 +840,24 @@ export class AIChatManager { if (!this.instructions.trim()) { return } + if (this.beforeSend) { + try { + await this.beforeSend() + } catch (e) { + // beforeSend commits the session's workspace before the first + // message hits the backend. If it throws, sending anyway would + // silently target the wrong workspace (typically the parent), so + // abort and tell the user — their message text stays in the input. + console.error('AIChatManager beforeSend hook failed', e) + sendUserToast( + `Could not prepare the session before sending: ${ + e instanceof Error ? e.message : String(e) + }. Your message was not sent — please try again.`, + true + ) + return + } + } try { const oldSelectedContext = this.contextManager?.getSelectedContext() ?? [] if (this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW) { diff --git a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte index 27adb56ef3..2ab480a706 100644 --- a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte +++ b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte @@ -76,7 +76,7 @@ onClick={() => onMenuOpen?.()} startIcon={{ icon: Menu }} iconOnly - > + />
{@render children?.()} @@ -96,5 +96,13 @@ {/if} {:else} - {@render children?.()} +
+ {@render children?.()} +
{/if} diff --git a/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte index e59ed4b513..5b4258d4e7 100644 --- a/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte @@ -3,9 +3,16 @@ 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 { getAiChatManager } from './aiChatManagerContext' import type { UserQuestionDisplay } from './shared' + // Sessions inject a per-pane `AIChatManager` via context; outside of + // sessions getAiChatManager falls back to the global singleton. Without + // this, answers clicked inside a session would dispatch to the singleton's + // pending callbacks map (which doesn't have the session manager's question + // callback), and the AI loop would stall. + const aiChatManager = getAiChatManager() + interface Props { toolCallId: string userQuestion: UserQuestionDisplay diff --git a/frontend/src/lib/components/copilot/chat/ChatMode.svelte b/frontend/src/lib/components/copilot/chat/ChatMode.svelte index 5a40c49e36..89b714b80f 100644 --- a/frontend/src/lib/components/copilot/chat/ChatMode.svelte +++ b/frontend/src/lib/components/copilot/chat/ChatMode.svelte @@ -2,7 +2,10 @@ import { ChevronDown } from 'lucide-svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' import Button from '$lib/components/common/button/Button.svelte' - import { aiChatManager, AIMode } from './AIChatManager.svelte' + import { AIMode } from './AIChatManager.svelte' + import { getAiChatManager } from './aiChatManagerContext' + + const aiChatManager = getAiChatManager() const modeLabel = (mode: AIMode) => mode.charAt(0).toUpperCase() + mode.slice(1) + ' mode' diff --git a/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte b/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte index 4df548192a..65abb0e1c5 100644 --- a/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte +++ b/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte @@ -1,7 +1,9 @@ diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index 2b9f4a09f1..56a594ff1e 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -867,6 +867,7 @@ bind:this={editor} class="h-full relative" code={flowModule.value.content} + syncExternalCode scriptLang={flowModule?.value?.language} automaticLayout={true} cmdEnterAction={async () => { @@ -930,6 +931,7 @@ bind:this={editor} class="h-full relative" code={flowModule.value.content} + syncExternalCode scriptLang={flowModule?.value?.language} automaticLayout={true} cmdEnterAction={async () => { diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index 563a387184..fbe07ac7b8 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -200,6 +200,9 @@ markRemovedAsShadowed?: boolean controlsPosition?: 'top' | 'bottom' outerDivClass?: string + /** Fires when the computed graph height changes. Diff views can use + * this to equalize heights of side-by-side graphs. */ + onHeight?: (height: number) => void } let { @@ -273,7 +276,8 @@ onMoveMultiple = undefined, movingIds = undefined, controlsPosition = 'top', - outerDivClass = '' + outerDivClass = '', + onHeight = undefined }: Props = $props() // Initialize note manager with fine-grained reactivity @@ -759,6 +763,7 @@ const computed = maxBottom - minY height = Math.max(Math.min(computed, maxHeight ?? computed), minHeight) } + onHeight?.(height) } $effect(() => { diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 7249f0ae05..8121ca8b70 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -66,6 +66,9 @@ } | undefined diffDrawer?: DiffDrawer | undefined + onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void + /** Fired after a successful deploy; the session preview reloads on it. */ + onDeploy?: (e: { path: string }) => void /** Initial collapsed state for the file/runnable sidebar. The user's * toggled preference is persisted under `sidebarStorageKey`; this prop * only seeds the very first open. */ @@ -75,6 +78,14 @@ * preference. */ sidebarStorageKey?: string liveEditorDraftStoragePath?: string + /** Initial value for the "Split with Preview" tab-bar toggle. Defaults + * to `true` (split mode, preview always pinned to the right). Set + * `false` when the editor mounts inside a context that wants single- + * view by default with the Preview tab selected — e.g. session + * previews, where the editor pane is already narrow. The user can + * still toggle the mode after mount; this prop only seeds the + * initial state. */ + defaultSplitWithPreview?: boolean } let { @@ -88,9 +99,12 @@ newPath = undefined, savedApp = $bindable(undefined), diffDrawer = undefined, + onNavigate, + onDeploy = undefined, defaultSidebarCollapsed = false, sidebarStorageKey = 'raw-app-sidebar-collapsed', - liveEditorDraftStoragePath = undefined + liveEditorDraftStoragePath = undefined, + defaultSplitWithPreview = true }: Props = $props() export const version: number | undefined = undefined @@ -225,7 +239,9 @@ } let tabs: TabItem[] = $state([previewTab]) let activeTabId: string = $state(PREVIEW_TAB_ID) - let splitWithPreview: boolean = $state(true) + // Seed from the prop, then own the state locally so the user's toggle + // after mount sticks even if the prop reference changes. + let splitWithPreview: boolean = $state(untrack(() => defaultSplitWithPreview)) const activeTabKind = $derived<'file' | 'runnable' | 'preview'>( activeTabId === PREVIEW_TAB_ID ? 'preview' @@ -255,11 +271,23 @@ const showRunnable = $derived(activeTabKind === 'runnable') // Mount the UI Builder iframe the first time a file is shown (paneA has // width then; mounting it at 0-width breaks the VS Code workbench), and - // keep it mounted so tab switches don't reload it. + // keep it mounted so tab switches don't reload it. Mount it as soon as + // either pane needs it: `showSource` for the source-editor view, OR the + // preview tab is active — the Preview iframe is fed by `preview` + // postMessages bundled by the UI Builder iframe, so it needs to be + // mounted even when the user opens the editor straight on Preview (e.g. + // session previews seeded with `defaultSplitWithPreview=false`). let iframeShouldMount = $state(false) $effect(() => { - if (showSource) iframeShouldMount = true + if (showSource || activeTabKind === 'preview') iframeShouldMount = true }) + // Width of the editor area (both inner panes). The UI Builder iframe is + // pre-mounted while it's the inactive tab so the editor is ready instantly; + // but the VS Code workbench inside crashes if it boots at 0 size. So while + // inactive we keep the iframe at this real width and hide it with + // `visibility` instead of collapsing it — Monaco boots correctly and + // revealing a file is just an unhide (no reload, no relayout, no latency). + let editorAreaWidth = $state(0) // Inner pane sizes are a pure function of mode + active tab → derived. // `paneARatio` is the user's last manual split drag (set by rememberPaneDrag). @@ -994,7 +1022,11 @@ ensureFileTab(selectedDocument) // Don't auto-activate — the user's tab choice wins. // But if no file tab is currently active, fall in line. - if (activeTabKind === 'preview' && tabs.length === 2) { + // Skip this auto-activation in single-view-with-preview + // mode (the caller seeded `defaultSplitWithPreview=false` + // because Preview is the intended starting tab); the + // iframe's first setActiveDocument shouldn't fight that. + if (splitWithPreview && activeTabKind === 'preview' && tabs.length === 2) { activateTab(id) } } @@ -1158,9 +1190,14 @@ }) }) - // Open a default file on mount (boots the iframe; avoids a blank preview). - // Layout isn't persisted — each open starts fresh in split mode. + // Open a default file on mount (boots the iframe in split mode and gives + // the user something to edit on the left). When the caller seeded + // `defaultSplitWithPreview=false` we instead want the Preview tab as the + // only-visible / active surface, so skip the file-tab activation — the + // iframe still boots via `populateFiles`/`setFilesInIframe` even without + // a selected document. onMount(() => { + if (!splitWithPreview) return if (tabs.length === 1) { const def = pickDefaultFile(files) if (def) activateTab(ensureFileTab(def)) @@ -1332,6 +1369,8 @@ {data} {runnables} {getBundle} + {onNavigate} + {onDeploy} canUndo={historyManager.canUndo} canRedo={historyManager.canRedo} onUndo={handleUndo} @@ -1415,6 +1454,7 @@ Preview previously hid every tab. -->
-
+ +
{#if iframeShouldMount}