From 62c3294c35643e66753fe6b8bbabf910a9f2d379 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 10 Mar 2026 18:39:15 +0000 Subject: [PATCH 001/204] chore(main): release 1.654.0 (#8291) * chore(main): release 1.654.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 26 +++ backend/Cargo.lock | 148 +++++++++--------- backend/Cargo.toml | 4 +- 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 +- 15 files changed, 115 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index feec672d41..61457b258d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## [1.654.0](https://github.com/windmill-labs/windmill/compare/v1.653.0...v1.654.0) (2026-03-10) + + +### Features + +* add git sync support for workspace dependencies ([#8144](https://github.com/windmill-labs/windmill/issues/8144)) ([4f29e05](https://github.com/windmill-labs/windmill/commit/4f29e05e3ae725e0be7ab797f8fa2186d8c5c0a5)) +* add kafka trigger offset reset and auto.offset.reset config ([#8283](https://github.com/windmill-labs/windmill/issues/8283)) ([b02f9e5](https://github.com/windmill-labs/windmill/commit/b02f9e5c2426bff2356e1aaaa18e05b18c5efc6b)) +* add preprocessor support for dedicated workers and bunnative scripts ([#8284](https://github.com/windmill-labs/windmill/issues/8284)) ([dc0e59f](https://github.com/windmill-labs/windmill/commit/dc0e59f432a0e3a53606adb8ac76d2dd2d365ace)) +* add Vertex AI support for Google Gemini models ([#8303](https://github.com/windmill-labs/windmill/issues/8303)) ([cb349cb](https://github.com/windmill-labs/windmill/commit/cb349cb3d1b7561fb70a8c23fa83dc1c9441821c)) +* **frontend:** replace flat sugiyama with recursive compound layout for flow graph ([#8204](https://github.com/windmill-labs/windmill/issues/8204)) ([cad4436](https://github.com/windmill-labs/windmill/commit/cad44365ac17029a2257f12cef061219b0265570)) + + +### Bug Fixes + +* **cli:** fail when passing an invalid --workspace arg ([#8294](https://github.com/windmill-labs/windmill/issues/8294)) ([f291b1c](https://github.com/windmill-labs/windmill/commit/f291b1cc19689e69e7aa008c19ce747e9c56240e)) +* debounce webhook arg accumulation with max_count/max_time limits ([#8307](https://github.com/windmill-labs/windmill/issues/8307)) ([83be59e](https://github.com/windmill-labs/windmill/commit/83be59e0e866ebd091f1e27c0571710a989fd2e4)) +* delete debounce_key on post-preprocessing limit exceeded ([#8299](https://github.com/windmill-labs/windmill/issues/8299)) ([438f609](https://github.com/windmill-labs/windmill/commit/438f609a78325ee5c2493079ca27bf587fa0d5ff)) +* explicilty fail when --base-url --token --workspace are invalid ([#8302](https://github.com/windmill-labs/windmill/issues/8302)) ([5baeb8c](https://github.com/windmill-labs/windmill/commit/5baeb8c842a392c21457b7561e30b385e02a6a48)) +* handle missing schema in RunnableByPath during wmill.d.ts generation ([#8300](https://github.com/windmill-labs/windmill/issues/8300)) ([b841e0a](https://github.com/windmill-labs/windmill/commit/b841e0a0384941079f37374f8fbbe2dd7fb51897)) +* optimize flow lock generation and add rt.d.ts guidance for TS resource types ([#8295](https://github.com/windmill-labs/windmill/issues/8295)) ([b40cf80](https://github.com/windmill-labs/windmill/commit/b40cf80fdd62cbc31db0872ada551ce213b9dac8)) +* preserve teams oauth tenant on settings page reload ([#8308](https://github.com/windmill-labs/windmill/issues/8308)) ([dbfa271](https://github.com/windmill-labs/windmill/commit/dbfa271b8962fe7b3d2aa8bf494e9557047fc8b3)) +* resync custom_instance_user password on startup ([#8297](https://github.com/windmill-labs/windmill/issues/8297)) ([53ac43f](https://github.com/windmill-labs/windmill/commit/53ac43f5ee34570a9bb7b3441c73095e23690300)) +* show meaningful error messages in database manager schema fetch ([#8296](https://github.com/windmill-labs/windmill/issues/8296)) ([cda8439](https://github.com/windmill-labs/windmill/commit/cda843922dcfd9a02ef9926751cbf8f544d2d4b6)) +* skip loading flow preview history for new flows ([#8293](https://github.com/windmill-labs/windmill/issues/8293)) ([ac8c668](https://github.com/windmill-labs/windmill/commit/ac8c668cb93e56bc2a247bbdbbec14e5608125d2)) +* teams selection not sticking in workspace settings ([#8309](https://github.com/windmill-labs/windmill/issues/8309)) ([fefc8c6](https://github.com/windmill-labs/windmill/commit/fefc8c62a00fe7a39f3104091e08087cd7c37afb)) + ## [1.653.0](https://github.com/windmill-labs/windmill/compare/v1.652.0...v1.653.0) (2026-03-10) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 64a04ce4ec..4e11d4067d 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -11987,9 +11987,9 @@ checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -15741,7 +15741,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "async-nats", @@ -15808,7 +15808,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.653.0" +version = "1.654.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15821,7 +15821,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "argon2", @@ -15961,7 +15961,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.653.0" +version = "1.654.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15984,7 +15984,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.653.0" +version = "1.654.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15997,7 +15997,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16023,7 +16023,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.653.0" +version = "1.654.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16033,7 +16033,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.653.0" +version = "1.654.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16050,7 +16050,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.653.0" +version = "1.654.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -16073,7 +16073,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16096,7 +16096,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.653.0" +version = "1.654.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16112,7 +16112,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.653.0" +version = "1.654.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16132,7 +16132,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.653.0" +version = "1.654.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16152,7 +16152,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.653.0" +version = "1.654.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16166,7 +16166,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "async-nats", @@ -16193,7 +16193,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16218,7 +16218,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.653.0" +version = "1.654.0" dependencies = [ "axum 0.7.9", "flate2", @@ -16236,7 +16236,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16257,7 +16257,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.653.0" +version = "1.654.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16277,7 +16277,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.653.0" +version = "1.654.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16307,7 +16307,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16334,7 +16334,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.653.0" +version = "1.654.0" dependencies = [ "lazy_static", "serde", @@ -16346,7 +16346,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.653.0" +version = "1.654.0" dependencies = [ "argon2", "axum 0.7.9", @@ -16369,7 +16369,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.653.0" +version = "1.654.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16383,7 +16383,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.653.0" +version = "1.654.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16414,7 +16414,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.653.0" +version = "1.654.0" dependencies = [ "chrono", "lazy_static", @@ -16428,7 +16428,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16447,7 +16447,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.653.0" +version = "1.654.0" dependencies = [ "aes-gcm", "anyhow", @@ -16546,7 +16546,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.653.0" +version = "1.654.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16565,7 +16565,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.653.0" +version = "1.654.0" dependencies = [ "regex", "serde", @@ -16580,7 +16580,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16604,7 +16604,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "futures", @@ -16621,7 +16621,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.653.0" +version = "1.654.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16637,7 +16637,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "async-trait", @@ -16658,7 +16658,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "async-trait", @@ -16689,7 +16689,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "async-oauth2", @@ -16713,7 +16713,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "async-stream", @@ -16747,7 +16747,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "futures", @@ -16765,7 +16765,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.653.0" +version = "1.654.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16774,7 +16774,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "lazy_static", @@ -16786,7 +16786,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "serde_json", @@ -16798,7 +16798,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "gosyn", @@ -16810,7 +16810,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "lazy_static", @@ -16822,7 +16822,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "serde_json", @@ -16834,7 +16834,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "nu-parser", @@ -16845,7 +16845,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16856,7 +16856,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16869,7 +16869,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "async-recursion", @@ -16893,7 +16893,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "lazy_static", @@ -16907,7 +16907,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16924,7 +16924,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "lazy_static", @@ -16939,7 +16939,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "lazy_static", @@ -16958,7 +16958,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16974,7 +16974,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "serde", @@ -16985,7 +16985,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "async-recursion", @@ -17022,7 +17022,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "const_format", @@ -17060,7 +17060,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.653.0" +version = "1.654.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17071,7 +17071,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "async-recursion", @@ -17100,7 +17100,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -17123,7 +17123,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "async-trait", @@ -17156,7 +17156,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "async-trait", @@ -17176,7 +17176,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "async-trait", @@ -17210,7 +17210,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "async-trait", @@ -17245,7 +17245,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "async-trait", @@ -17268,7 +17268,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "async-trait", @@ -17292,7 +17292,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "async-nats", @@ -17316,7 +17316,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "async-trait", @@ -17351,7 +17351,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "async-trait", @@ -17379,7 +17379,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "async-trait", @@ -17402,7 +17402,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17420,7 +17420,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "async-once-cell", @@ -17526,7 +17526,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.653.0" +version = "1.654.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 36eca6296b..95ffa732c0 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.653.0" +version = "1.654.0" authors.workspace = true edition.workspace = true @@ -78,7 +78,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal"] [workspace.package] -version = "1.653.0" +version = "1.654.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d132f647db..5fbeacfddf 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.653.0 + version: 1.654.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 121d5b6c6d..207d9ec7b8 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.653.0"; +export const VERSION = "v1.654.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 6ca14e9975..73e1aeb8cb 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -67,7 +67,7 @@ export { workspaceAdd, }; -export const VERSION = "1.653.0"; +export const VERSION = "1.654.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 647db391cd..30d2ea2803 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.653.0", + "version": "1.654.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.653.0", + "version": "1.654.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 66e1da26b3..888cbb46d5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.653.0", + "version": "1.654.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index de69e3add0..7d59d0611c 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.653.0" +wmill = ">=1.654.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index d14454be0b..1db23c88c6 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.653.0 + version: 1.654.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index f03a1970d3..5b8ea5c064 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.653.0' + ModuleVersion = '1.654.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 29ea738024..baa55e576f 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.653.0" +version = "1.654.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 7daf5bf8e9..59c468528f 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.653.0", + "version": "1.654.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 08101b7678..7f41da191e 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.653.0", + "version": "1.654.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index be0764647c..fc9b4bd4fd 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.653.0 +1.654.0 From 425a75e030b15fe65676169f9069fbb7da19828e Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:12:46 +0100 Subject: [PATCH 002/204] fix: fix saved inputs popover infinite loop (#8311) --- .../src/lib/components/meltComponents/Menu.svelte | 8 +++++--- .../lib/components/meltComponents/Popover.svelte | 15 +++++++++------ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/components/meltComponents/Menu.svelte b/frontend/src/lib/components/meltComponents/Menu.svelte index e18e520141..879df7bc2c 100644 --- a/frontend/src/lib/components/meltComponents/Menu.svelte +++ b/frontend/src/lib/components/meltComponents/Menu.svelte @@ -10,6 +10,7 @@ import { twMerge } from 'tailwind-merge' import ResolveOpen from '$lib/components/common/menu/ResolveOpen.svelte' + import { watch } from 'runed' interface Props { placement?: Placement @@ -63,9 +64,10 @@ } = menu const sync = createSync(states) - $effect(() => { - sync.open(open, (v) => (open = Boolean(v))) - }) + watch( + () => open, + () => sync.open(open, (v) => (open = Boolean(v))) + ) export function close() { open = false diff --git a/frontend/src/lib/components/meltComponents/Popover.svelte b/frontend/src/lib/components/meltComponents/Popover.svelte index 96a0934611..a3a762ffca 100644 --- a/frontend/src/lib/components/meltComponents/Popover.svelte +++ b/frontend/src/lib/components/meltComponents/Popover.svelte @@ -7,7 +7,7 @@ From 48bc3e244558dccb1f08f455b299600861788b0d Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 10 Mar 2026 23:46:24 +0000 Subject: [PATCH 003/204] fix: prevent zombie jobs from looping forever (#8313) * fix: always increment zombie job counter to prevent infinite loop at restart limit Co-Authored-By: Claude Opus 4.6 * fix: force-complete zombie jobs when handle_job_error fails Co-Authored-By: Claude Opus 4.6 * fix: revert counter increment change, keep re-detection for robustness Co-Authored-By: Claude Opus 4.6 * fix: disable schedule when permissioned_as user is not found Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- ...b0ab8113d52ae8ae613a810c2b452e0972d05.json | 22 +++++++ ...4c597e07e53d499131b57e4d6441fd0af09cb.json | 15 +++++ backend/src/monitor.rs | 66 ++++++++++++++++++- backend/windmill-common/src/auth.rs | 2 +- backend/windmill-queue/src/jobs.rs | 7 +- 5 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 backend/.sqlx/query-2c8b8ed14647332491846ae3fa8b0ab8113d52ae8ae613a810c2b452e0972d05.json create mode 100644 backend/.sqlx/query-beecb176df512e4a94771d0d73c4c597e07e53d499131b57e4d6441fd0af09cb.json diff --git a/backend/.sqlx/query-2c8b8ed14647332491846ae3fa8b0ab8113d52ae8ae613a810c2b452e0972d05.json b/backend/.sqlx/query-2c8b8ed14647332491846ae3fa8b0ab8113d52ae8ae613a810c2b452e0972d05.json new file mode 100644 index 0000000000..a47db0ecab --- /dev/null +++ b/backend/.sqlx/query-2c8b8ed14647332491846ae3fa8b0ab8113d52ae8ae613a810c2b452e0972d05.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "2c8b8ed14647332491846ae3fa8b0ab8113d52ae8ae613a810c2b452e0972d05" +} diff --git a/backend/.sqlx/query-beecb176df512e4a94771d0d73c4c597e07e53d499131b57e4d6441fd0af09cb.json b/backend/.sqlx/query-beecb176df512e4a94771d0d73c4c597e07e53d499131b57e4d6441fd0af09cb.json new file mode 100644 index 0000000000..697e49ab9d --- /dev/null +++ b/backend/.sqlx/query-beecb176df512e4a94771d0d73c4c597e07e53d499131b57e4d6441fd0af09cb.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO v2_job_completed\n (workspace_id, id, started_at, duration_ms, result, memory_peak, status, worker)\n SELECT q.workspace_id, q.id, q.started_at,\n COALESCE((EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000, 0)::bigint,\n $2::jsonb, r.memory_peak, 'failure'::job_status, q.worker\n FROM v2_job_queue q\n LEFT JOIN v2_job_runtime r ON r.id = q.id\n WHERE q.id = $1\n ON CONFLICT (id) DO UPDATE SET status = 'failure', result = $2::jsonb", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "beecb176df512e4a94771d0d73c4c597e07e53d499131b57e4d6441fd0af09cb" +} diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 37190a5f7a..2e94bdace6 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -2825,7 +2825,7 @@ async fn handle_zombie_jobs(db: &Pool, base_internal_url: &str, node_n &windmill_queue::MiniCompletedJob::from(job), memory_peak, None, - error::Error::ExecutionErr(error_message), + error::Error::ExecutionErr(error_message.clone()), matches!(error_kind, ErrorMessage::SameWorker), // unrecoverable if the job is a same worker zombie Some(&same_worker_tx_never_used), "", @@ -2836,10 +2836,74 @@ async fn handle_zombie_jobs(db: &Pool, base_internal_url: &str, node_n &mut windmill_common::bench::BenchmarkIter::new(), ) .await; + + // If handle_job_error failed (e.g. schedule push failure rolled back the tx), + // the job is still in the queue. Force-complete it to prevent infinite zombie loops. + if let Err(e) = force_complete_zombie_job(db, &job_id, &error_message).await { + tracing::error!("Failed to force-complete zombie job {}: {e:#}", job_id); + } } } } +/// Force-complete a zombie job that handle_job_error failed to complete. +/// This is a minimal fallback: it inserts a failed completed job and deletes +/// from the queue in a single transaction, without schedule pushing or +/// error handler logic that could cause the completion to fail. +async fn force_complete_zombie_job( + db: &Pool, + job_id: &Uuid, + error_message: &str, +) -> error::Result<()> { + let still_queued = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1)", + job_id + ) + .fetch_one(db) + .await? + .unwrap_or(false); + + if !still_queued { + return Ok(()); + } + + tracing::error!( + "Zombie job {job_id} was not completed by handle_job_error, force-completing it" + ); + + let error_value = serde_json::json!({ + "message": error_message, + "name": "ExecutionErr", + }); + + let mut tx = db.begin().await?; + + sqlx::query!( + "INSERT INTO v2_job_completed + (workspace_id, id, started_at, duration_ms, result, memory_peak, status, worker) + SELECT q.workspace_id, q.id, q.started_at, + COALESCE((EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000, 0)::bigint, + $2::jsonb, r.memory_peak, 'failure'::job_status, q.worker + FROM v2_job_queue q + LEFT JOIN v2_job_runtime r ON r.id = q.id + WHERE q.id = $1 + ON CONFLICT (id) DO UPDATE SET status = 'failure', result = $2::jsonb", + job_id, + error_value, + ) + .execute(&mut *tx) + .await?; + + sqlx::query!("DELETE FROM v2_job_queue WHERE id = $1", job_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + tracing::info!("Force-completed zombie job {job_id}"); + Ok(()) +} + async fn cleanup_concurrency_counters_orphaned_keys(db: &DB) -> error::Result<()> { let result = sqlx::query!( " diff --git a/backend/windmill-common/src/auth.rs b/backend/windmill-common/src/auth.rs index c0555f5093..7024029c52 100644 --- a/backend/windmill-common/src/auth.rs +++ b/backend/windmill-common/src/auth.rs @@ -339,7 +339,7 @@ async fn fetch_authed_from_permissioned_as_inner( if let Some(r) = r { (r.is_admin, r.operator) } else { - return Err(Error::internal_err(format!( + return Err(Error::NotFound(format!( "user {name} not found in workspace {w_id}" ))); } diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 3d540cd41c..324ea6c0ed 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -5513,10 +5513,11 @@ async fn push_inner<'c, 'd>( &mut *tx, ) .await - .map_err(|e| { - Error::internal_err(format!( + .map_err(|e| match e { + Error::NotFound(_) => e, + _ => Error::internal_err(format!( "Could not get permissions directly for job {job_id}: {e:#}" - )) + )), })? } }; From 2aef01d18c0723aedcc626f4f3991195620774ab Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 11 Mar 2026 00:10:29 +0000 Subject: [PATCH 004/204] feat: partition audit log table by day with configurable retention (#8292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: partition audit log table by day with configurable retention Introduce daily range partitioning for audit logs to replace expensive DELETE-based retention with instant DROP TABLE per partition. - Create `audit_partitioned` table alongside existing `audit` table - New inserts go to `audit_partitioned`, reads UNION ALL both tables - Monitor creates future partitions and drops expired ones - Add `audit_log_retention_days` instance setting (default 365 days) - Old `audit` table empties naturally via existing DELETE cleanup Co-Authored-By: Claude Opus 4.6 * feat: add audit log retention setting to Core instance settings UI Co-Authored-By: Claude Opus 4.6 * fix: bump audit partitioning migration timestamp to avoid collision Co-Authored-By: Claude Opus 4.6 * chore: update ee-repo-ref.txt for audit partitioning Co-Authored-By: Claude Opus 4.6 * feat: add RLS/grants to audit_partitioned, run partition mgmt hourly, CE default 14d - Add grants for windmill_user/windmill_admin and all 5 RLS policies - Move manage_audit_partitions to hourly via should_run(120) - Default retention: 14 days CE, 365 days EE - Download JSON button is now icon-only Co-Authored-By: Claude Opus 4.6 * fix: address code review — quote SQL identifiers, add workspace index, deduplicate retention logic - Quote partition names in dynamic SQL for defense in depth - Add idx_audit_partitioned_workspace(workspace_id, timestamp DESC) index - Extract audit_log_retention_days() helper to deduplicate retention logic Co-Authored-By: Claude Opus 4.6 * chore: update ee-repo-ref for audit insert error handling Co-Authored-By: Claude Opus 4.6 * chore: update ee-repo-ref to cef4dfc45e6d6344c5d8d107bd2b4d1bf9bbdd64 This commit updates the EE repository reference after PR #450 was merged in windmill-ee-private. Previous ee-repo-ref: f09284bb257d461bcbe3c50fe31eb6f1e7eafee5 New ee-repo-ref: cef4dfc45e6d6344c5d8d107bd2b4d1bf9bbdd64 Automated by sync-ee-ref workflow. * fix: create audit partitions on startup in initial_load Ensures partitions exist before any requests arrive, closing the gap between server start and the first hourly monitor run. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: windmill-internal-app[bot] --- ...900e2d362ee934ef27284ef39f4eed440e59.json} | 4 +- ...618a80ceba6de2d39202e6293ef5207c31f6.json} | 4 +- backend/ee-repo-ref.txt | 2 +- ...20260311100000_audit_partitioning.down.sql | 1 + .../20260311100000_audit_partitioning.up.sql | 58 ++++++++ backend/src/main.rs | 29 ++-- backend/src/monitor.rs | 138 +++++++++++++++--- backend/windmill-api-users/src/users.rs | 2 +- .../windmill-common/src/global_settings.rs | 1 + backend/windmill-common/src/lib.rs | 1 + .../auditLogs/AuditLogsFilters.svelte | 9 +- .../src/lib/components/instanceSettings.ts | 10 ++ 12 files changed, 216 insertions(+), 43 deletions(-) rename backend/.sqlx/{query-72d3ebb05ac1ffeb0e8d0a3146d95bb5b90e7c4d1dc2c8a6ef06eddf6678f230.json => query-9229d9a9ff389cf26e480b604b83900e2d362ee934ef27284ef39f4eed440e59.json} (62%) rename backend/.sqlx/{query-ad8487a797713b3a6c10fb399c9fb8dcd940bb92e998145e250f28ccfe1c7033.json => query-fbccafe6d34093a723b9d5a6ee8d618a80ceba6de2d39202e6293ef5207c31f6.json} (67%) create mode 100644 backend/migrations/20260311100000_audit_partitioning.down.sql create mode 100644 backend/migrations/20260311100000_audit_partitioning.up.sql diff --git a/backend/.sqlx/query-72d3ebb05ac1ffeb0e8d0a3146d95bb5b90e7c4d1dc2c8a6ef06eddf6678f230.json b/backend/.sqlx/query-9229d9a9ff389cf26e480b604b83900e2d362ee934ef27284ef39f4eed440e59.json similarity index 62% rename from backend/.sqlx/query-72d3ebb05ac1ffeb0e8d0a3146d95bb5b90e7c4d1dc2c8a6ef06eddf6678f230.json rename to backend/.sqlx/query-9229d9a9ff389cf26e480b604b83900e2d362ee934ef27284ef39f4eed440e59.json index 0a78509ca3..9eb47caf8a 100644 --- a/backend/.sqlx/query-72d3ebb05ac1ffeb0e8d0a3146d95bb5b90e7c4d1dc2c8a6ef06eddf6678f230.json +++ b/backend/.sqlx/query-9229d9a9ff389cf26e480b604b83900e2d362ee934ef27284ef39f4eed440e59.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "WITH active_users AS (SELECT distinct username as email FROM audit 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, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n ORDER BY super_admin DESC, devops DESC\n LIMIT $1 OFFSET $2", + "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, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n ORDER BY super_admin DESC, devops DESC\n LIMIT $1 OFFSET $2", "describe": { "columns": [ { @@ -73,5 +73,5 @@ false ] }, - "hash": "72d3ebb05ac1ffeb0e8d0a3146d95bb5b90e7c4d1dc2c8a6ef06eddf6678f230" + "hash": "9229d9a9ff389cf26e480b604b83900e2d362ee934ef27284ef39f4eed440e59" } diff --git a/backend/.sqlx/query-ad8487a797713b3a6c10fb399c9fb8dcd940bb92e998145e250f28ccfe1c7033.json b/backend/.sqlx/query-fbccafe6d34093a723b9d5a6ee8d618a80ceba6de2d39202e6293ef5207c31f6.json similarity index 67% rename from backend/.sqlx/query-ad8487a797713b3a6c10fb399c9fb8dcd940bb92e998145e250f28ccfe1c7033.json rename to backend/.sqlx/query-fbccafe6d34093a723b9d5a6ee8d618a80ceba6de2d39202e6293ef5207c31f6.json index a703945270..da56a367b6 100644 --- a/backend/.sqlx/query-ad8487a797713b3a6c10fb399c9fb8dcd940bb92e998145e250f28ccfe1c7033.json +++ b/backend/.sqlx/query-fbccafe6d34093a723b9d5a6ee8d618a80ceba6de2d39202e6293ef5207c31f6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO audit\n (workspace_id, username, operation, action_kind, resource, parameters, email, span)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + "query": "INSERT INTO audit_partitioned\n (workspace_id, username, operation, action_kind, resource, parameters, email, span)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", "describe": { "columns": [], "parameters": { @@ -29,5 +29,5 @@ }, "nullable": [] }, - "hash": "ad8487a797713b3a6c10fb399c9fb8dcd940bb92e998145e250f28ccfe1c7033" + "hash": "fbccafe6d34093a723b9d5a6ee8d618a80ceba6de2d39202e6293ef5207c31f6" } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 6a883f01a7..1c21512157 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -f97defd916b982ef490d7ee69c671655bc9858b4 +cef4dfc45e6d6344c5d8d107bd2b4d1bf9bbdd64 diff --git a/backend/migrations/20260311100000_audit_partitioning.down.sql b/backend/migrations/20260311100000_audit_partitioning.down.sql new file mode 100644 index 0000000000..7eb63bbdba --- /dev/null +++ b/backend/migrations/20260311100000_audit_partitioning.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS audit_partitioned CASCADE; diff --git a/backend/migrations/20260311100000_audit_partitioning.up.sql b/backend/migrations/20260311100000_audit_partitioning.up.sql new file mode 100644 index 0000000000..c221af8d38 --- /dev/null +++ b/backend/migrations/20260311100000_audit_partitioning.up.sql @@ -0,0 +1,58 @@ +-- Create a new daily-partitioned audit table alongside the existing one. +-- New inserts go to audit_partitioned; reads UNION ALL both tables. +-- The old audit table empties out naturally via retention cleanup. + +CREATE TABLE audit_partitioned ( + workspace_id VARCHAR(50) NOT NULL, + id BIGINT NOT NULL DEFAULT nextval('audit_id_seq'), + timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), + username VARCHAR(255) NOT NULL, + operation VARCHAR(50) NOT NULL, + action_kind ACTION_KIND NOT NULL, + resource VARCHAR(255), + parameters JSONB, + email VARCHAR(255), + span VARCHAR(255), + PRIMARY KEY (id, timestamp) +) PARTITION BY RANGE (timestamp); + +-- Create daily partitions for today + 3 days +DO $$ +DECLARE + curr_date DATE := CURRENT_DATE; + end_date DATE := CURRENT_DATE + INTERVAL '3 days'; +BEGIN + WHILE curr_date <= end_date LOOP + EXECUTE format( + 'CREATE TABLE %I PARTITION OF audit_partitioned FOR VALUES FROM (%L) TO (%L)', + 'audit_' || to_char(curr_date, 'YYYYMMDD'), + curr_date, + curr_date + INTERVAL '1 day' + ); + curr_date := curr_date + INTERVAL '1 day'; + END LOOP; +END $$; + +-- Indexes (auto-propagated to all current and future partitions) +CREATE INDEX ix_audit_partitioned_timestamps ON audit_partitioned (timestamp DESC); +CREATE INDEX idx_audit_partitioned_workspace ON audit_partitioned (workspace_id, timestamp DESC); +CREATE INDEX idx_audit_partitioned_recent_login_activities + ON audit_partitioned (timestamp, username) + WHERE operation IN ('users.login', 'oauth.login', 'users.token.refresh'); + +-- Grants (match the old audit table) +GRANT ALL ON audit_partitioned TO windmill_user; +GRANT ALL ON audit_partitioned TO windmill_admin; + +-- RLS (match the old audit table) +ALTER TABLE audit_partitioned ENABLE ROW LEVEL SECURITY; + +CREATE POLICY admin_policy ON audit_partitioned FOR ALL TO windmill_admin USING (true); +CREATE POLICY see_own ON audit_partitioned FOR ALL TO windmill_user + USING ((username)::text = current_setting('session.user'::text)); +CREATE POLICY schedule ON audit_partitioned FOR INSERT TO windmill_user + WITH CHECK ((username)::text ~~ 'schedule-%'::text); +CREATE POLICY schedule_audit ON audit_partitioned FOR INSERT TO windmill_user + WITH CHECK ((parameters ->> 'end_user'::text) ~~ 'schedule-%'::text); +CREATE POLICY webhook ON audit_partitioned FOR INSERT TO windmill_user + WITH CHECK ((username)::text ~~ 'webhook-%'::text); diff --git a/backend/src/main.rs b/backend/src/main.rs index 90acfd76cb..45849c0bd3 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -37,12 +37,13 @@ use windmill_common::ee_oss::{ use windmill_common::{ agent_workers::AgentConfig, global_settings::{ - APP_WORKSPACED_ROUTE_SETTING, BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, - CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, - CRITICAL_ALERT_MUTE_UI_SETTING, CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, - DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, - ENV_SETTINGS, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, - EXTRA_PIP_INDEX_URL_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING, + APP_WORKSPACED_ROUTE_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING, BASE_URL_SETTING, + BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, + CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, + CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING, + DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, + EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING, + HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING, @@ -97,12 +98,13 @@ use windmill_worker::{ use crate::monitor::{ initial_load, load_keep_job_dir, load_metrics_debug_enabled, load_require_preexisting_user, load_tag_per_workspace_enabled, load_tag_per_workspace_workspaces, monitor_db, - reload_app_workspaced_route_setting, reload_base_url_setting, - reload_bunfig_install_scopes_setting, reload_critical_alert_mute_ui_setting, - reload_critical_alerts_on_token_expiry_setting, reload_critical_error_channels_setting, - reload_extra_pip_index_url_setting, reload_hub_api_secret_setting, reload_hub_base_url_setting, - reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting, - reload_license_key, reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting, + reload_app_workspaced_route_setting, reload_audit_log_retention_days_setting, + reload_base_url_setting, reload_bunfig_install_scopes_setting, + reload_critical_alert_mute_ui_setting, reload_critical_alerts_on_token_expiry_setting, + reload_critical_error_channels_setting, reload_extra_pip_index_url_setting, + reload_hub_api_secret_setting, reload_hub_base_url_setting, reload_job_default_timeout_setting, + reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key, + reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting, reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting, reload_smtp_config, reload_uv_index_strategy_setting, reload_worker_config, MonitorIteration, }; @@ -1664,6 +1666,9 @@ async fn process_notify_event( } TIMEOUT_WAIT_RESULT_SETTING => reload_timeout_wait_result_setting(conn).await, RETENTION_PERIOD_SECS_SETTING => reload_retention_period_setting(conn).await, + AUDIT_LOG_RETENTION_DAYS_SETTING => { + reload_audit_log_retention_days_setting(conn).await + } MONITOR_LOGS_ON_OBJECT_STORE_SETTING => { reload_delete_logs_periodically_setting(conn).await } diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 2e94bdace6..48d8552905 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -48,16 +48,17 @@ use windmill_common::{ error, flow_status::{FlowStatus, FlowStatusModule}, global_settings::{ - BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, - CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, - CRITICAL_ERROR_CHANNELS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING, - DEFAULT_TAGS_WORKSPACES_SETTING, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, - EXTRA_PIP_INDEX_URL_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, - INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, - JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, - MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING, - NUGET_CONFIG_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, - POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING, + AUDIT_LOG_RETENTION_DAYS_SETTING, BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, + CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, + CRITICAL_ALERT_MUTE_UI_SETTING, CRITICAL_ERROR_CHANNELS_SETTING, + DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, + EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING, + HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, + JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, + KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, + NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING, NUGET_CONFIG_SETTING, OTEL_SETTING, + OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, + POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_INDEX_STRATEGY_SETTING, @@ -77,9 +78,9 @@ use windmill_common::{ DEFAULT_TAGS_WORKSPACES, INDEXER_CONFIG, SCRIPT_TOKEN_EXPIRY, SMTP_CONFIG, WINDMILL_DIR, WORKER_CONFIG, WORKER_GROUP, }, - KillpillSender, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE, CRITICAL_ALERTS_ON_TOKEN_EXPIRY, - CRITICAL_ALERT_MUTE_UI_ENABLED, CRITICAL_ERROR_CHANNELS, DB, DEFAULT_HUB_BASE_URL, - HUB_BASE_URL, JOB_RETENTION_SECS, METRICS_DEBUG_ENABLED, METRICS_ENABLED, + KillpillSender, AUDIT_LOG_RETENTION_DAYS, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE, + CRITICAL_ALERTS_ON_TOKEN_EXPIRY, CRITICAL_ALERT_MUTE_UI_ENABLED, CRITICAL_ERROR_CHANNELS, DB, + DEFAULT_HUB_BASE_URL, HUB_BASE_URL, JOB_RETENTION_SECS, METRICS_DEBUG_ENABLED, METRICS_ENABLED, MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED, OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED, SERVICE_LOG_RETENTION_SECS, }; @@ -323,9 +324,15 @@ pub async fn initial_load( if server_mode { reload_retention_period_setting(&conn).await; + reload_audit_log_retention_days_setting(&conn).await; reload_request_size(&conn).await; reload_saml_metadata_setting(&conn).await; reload_scim_token_setting(&conn).await; + + // Ensure audit partitions exist before any requests arrive + if let Some(db) = conn.as_sql() { + manage_audit_partitions(&db, audit_log_retention_days().await).await; + } } if worker_mode { @@ -1027,12 +1034,10 @@ pub async fn delete_expired_items(db: &DB) -> () { Err(e) => tracing::error!("Error deleting log file: {:?}", e), } - #[cfg(not(feature = "enterprise"))] - let audit_retention_secs = 1 * 60 * 60 * 24 * 14; - - #[cfg(feature = "enterprise")] - let audit_retention_secs = 1 * 60 * 60 * 24 * 365; + let audit_retention_days = audit_log_retention_days().await; + let audit_retention_secs: i64 = audit_retention_days * 60 * 60 * 24; + // Clean up old (non-partitioned) audit table — will eventually be empty and dropped if let Err(e) = sqlx::query_scalar!( "DELETE FROM audit WHERE timestamp <= now() - ($1::bigint::text || ' s')::interval", audit_retention_secs, @@ -1040,7 +1045,7 @@ pub async fn delete_expired_items(db: &DB) -> () { .fetch_all(db) .await { - tracing::error!("Error deleting audit log on CE: {:?}", e); + tracing::error!("Error deleting audit log: {:?}", e); } if let Err(e) = sqlx::query_scalar!( @@ -1565,6 +1570,22 @@ pub async fn reload_retention_period_setting(conn: &Connection) { tracing::error!("Error reloading retention period: {:?}", e) } } + +pub async fn reload_audit_log_retention_days_setting(conn: &Connection) { + if let Err(e) = reload_setting( + conn, + AUDIT_LOG_RETENTION_DAYS_SETTING, + "AUDIT_LOG_RETENTION_DAYS", + 0, // 0 means use default: 365 for EE, 14 for CE + AUDIT_LOG_RETENTION_DAYS.clone(), + |x| x, + ) + .await + { + tracing::error!("Error reloading audit log retention days: {:?}", e) + } +} + pub async fn reload_delete_logs_periodically_setting(conn: &Connection) { if let Err(e) = reload_setting( conn, @@ -2182,6 +2203,15 @@ pub async fn monitor_db( } }; + // run every hour (120 iterations * 30s = 3600s) + let manage_audit_partitions_f = async { + if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(120) { + if let Some(db) = conn.as_sql() { + manage_audit_partitions(&db, audit_log_retention_days().await).await; + } + } + }; + join!( expired_items_f, zombie_jobs_f, @@ -2204,6 +2234,7 @@ pub async fn monitor_db( native_triggers_sync_f, cleanup_notify_events_f, check_expiring_tokens_f, + manage_audit_partitions_f, ); } @@ -3432,3 +3463,72 @@ RETURNING job_id } Ok(()) } + +async fn audit_log_retention_days() -> i64 { + let v = *AUDIT_LOG_RETENTION_DAYS.read().await; + if v > 0 { + v + } else if cfg!(feature = "enterprise") { + 365 + } else { + 14 + } +} + +async fn manage_audit_partitions(db: &DB, retention_days: i64) { + let today = chrono::Utc::now().date_naive(); + + // Create partitions for today and the next 3 days + for days_ahead in 0..=3i64 { + let date = today + chrono::Duration::days(days_ahead); + let next_date = date + chrono::Duration::days(1); + let partition_name = format!("audit_{}", date.format("%Y%m%d")); + let quoted_name = format!("\"{}\"", partition_name.replace('"', "\"\"")); + let sql = format!( + "CREATE TABLE IF NOT EXISTS {quoted_name} PARTITION OF audit_partitioned \ + FOR VALUES FROM ('{date}') TO ('{next_date}')" + ); + if let Err(e) = sqlx::query(&sql).execute(db).await { + if !e.to_string().contains("already exists") { + tracing::error!("Error creating audit partition {partition_name}: {e:?}"); + } + } + } + + // Drop expired partitions + let cutoff_date = today - chrono::Duration::days(retention_days); + + let partitions = sqlx::query_scalar::<_, String>( + "SELECT c.relname::text \ + FROM pg_inherits i \ + JOIN pg_class c ON c.oid = i.inhrelid \ + WHERE i.inhparent = 'audit_partitioned'::regclass", + ) + .fetch_all(db) + .await; + + match partitions { + Ok(partitions) => { + for partition_name in partitions { + if let Some(date_str) = partition_name.strip_prefix("audit_") { + if let Ok(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y%m%d") { + if date < cutoff_date { + let quoted_name = + format!("\"{}\"", partition_name.replace('"', "\"\"")); + let sql = format!("DROP TABLE IF EXISTS {quoted_name}"); + match sqlx::query(&sql).execute(db).await { + Ok(_) => tracing::info!( + "Dropped expired audit partition {partition_name}" + ), + Err(e) => tracing::error!( + "Error dropping audit partition {partition_name}: {e:?}" + ), + } + } + } + } + } + } + Err(e) => tracing::error!("Error listing audit partitions: {e:?}"), + } +} diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index ecd1c5fbd3..f7409169a9 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -393,7 +393,7 @@ async fn list_users_as_super_admin( let rows = if active_only.is_some_and(|x| x) { sqlx::query_as!( GlobalUserInfo, - "WITH active_users AS (SELECT distinct username as email FROM audit WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')), + "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, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user FROM password diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index b2d961e173..6d2f225479 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -4,6 +4,7 @@ pub const DEFAULT_TAGS_WORKSPACES_SETTING: &str = "default_tags_workspaces"; pub const BASE_URL_SETTING: &str = "base_url"; pub const OAUTH_SETTING: &str = "oauths"; pub const RETENTION_PERIOD_SECS_SETTING: &str = "retention_period_secs"; +pub const AUDIT_LOG_RETENTION_DAYS_SETTING: &str = "audit_log_retention_days"; pub const MONITOR_LOGS_ON_OBJECT_STORE_SETTING: &str = "monitor_logs_on_s3"; pub const JOB_DEFAULT_TIMEOUT_SECS_SETTING: &str = "job_default_timeout"; pub const REQUEST_SIZE_LIMIT_SETTING: &str = "request_size_limit_mb"; diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 85f5563419..edebdaa974 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -205,6 +205,7 @@ lazy_static::lazy_static! { pub static ref CRITICAL_ALERTS_ON_DB_OVERSIZE: Arc>> = Arc::new(RwLock::new(None)); pub static ref JOB_RETENTION_SECS: Arc> = Arc::new(RwLock::new(0)); + pub static ref AUDIT_LOG_RETENTION_DAYS: Arc> = Arc::new(RwLock::new(0)); pub static ref MONITOR_LOGS_ON_OBJECT_STORE: Arc> = Arc::new(RwLock::new(false)); diff --git a/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte b/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte index 1af551d82e..2e0d7cc2e1 100644 --- a/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte +++ b/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte @@ -465,12 +465,9 @@ on:click={downloadAuditLogsAsJson} unifiedSize="md" title="Downloads currently displayed logs only (up to {perPage} entries)" - > -
- - Download JSON -
- + startIcon={{ icon: Download }} + iconOnly + /> @@ -830,7 +836,9 @@
{#if currentBatchSize != null} @@ -849,11 +857,7 @@ }} /> {/if} - diff --git a/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte b/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte index 9d6c1d2bc1..1300dc4322 100644 --- a/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte +++ b/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte @@ -4,6 +4,7 @@ import Popover from '$lib/components/Popover.svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' + import Tooltip from '$lib/components/meltComponents/Tooltip.svelte' type TogglableItem = { label: string @@ -20,6 +21,7 @@ togglableItems: TogglableItem[] btnText?: string class?: string + hideSelectedOption?: boolean } let { @@ -30,7 +32,8 @@ selected = $bindable(undefined), togglableItems, btnText, - class: className = '' + class: className = '', + hideSelectedOption = false }: Props = $props() let items = untrack(() => togglableItems).map((i) => ({ @@ -51,7 +54,7 @@ disappearTimeout={0} >
- {#if isAnOptionSelected(selected)} + {#if isAnOptionSelected(selected) && !hideSelectedOption} {@const tooltip = togglableItems.find((i) => i.value === selected)?.tooltip} {/if}
- + + + +
From df1b1f9651bd0950562223a286f55569e594e13e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 11 Mar 2026 17:58:31 +0000 Subject: [PATCH 009/204] chore: fix 19 CVEs by upgrading php, docker, and node-tar (#8326) Co-authored-by: Claude Opus 4.6 --- Dockerfile | 6 +- docker/DockerfileSlim | 2 +- docker/DockerfileSlimEe | 2 +- frontend/package-lock.json | 128 ++----------------------------------- frontend/package.json | 7 +- 5 files changed, 14 insertions(+), 131 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0cc19801d6..70a28b2f96 100644 --- a/Dockerfile +++ b/Dockerfile @@ -268,11 +268,11 @@ RUN bun install -g windmill-cli \ RUN curl -fsSL https://claude.ai/install.sh | bash \ && cp /root/.local/share/claude/versions/* /usr/bin/claude -COPY --from=php:8.3.7-cli /usr/local/bin/php /usr/bin/php -COPY --from=composer:2.7.6 /usr/bin/composer /usr/bin/composer +COPY --from=php:8.3.30-cli /usr/local/bin/php /usr/bin/php +COPY --from=composer:2.9.5 /usr/bin/composer /usr/bin/composer # add the docker client to call docker from a worker if enabled -COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/ +COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/ ENV RUSTUP_HOME="/tmp/windmill/cache/rustup" ENV CARGO_HOME="/tmp/windmill/cache/cargo" diff --git a/docker/DockerfileSlim b/docker/DockerfileSlim index 279c79b780..61a365e496 100644 --- a/docker/DockerfileSlim +++ b/docker/DockerfileSlim @@ -67,7 +67,7 @@ RUN curl -fsSL https://claude.ai/install.sh | bash \ && cp /root/.local/share/claude/versions/* /usr/bin/claude # add the docker client to call docker from a worker if enabled -COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/ +COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/ # nsjail runtime deps and binary RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \ diff --git a/docker/DockerfileSlimEe b/docker/DockerfileSlimEe index 88c16aaac0..7ba85a0358 100644 --- a/docker/DockerfileSlimEe +++ b/docker/DockerfileSlimEe @@ -67,7 +67,7 @@ RUN curl -fsSL https://claude.ai/install.sh | bash \ && cp /root/.local/share/claude/versions/* /usr/bin/claude # add the docker client to call docker from a worker if enabled -COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/ +COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/ # nsjail runtime deps and binary RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5a65dc9947..aebb63af83 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -144,7 +144,7 @@ "svelte-range-slider-pips": "^2.3.1", "svelte-splitpanes": "^8.0.9", "tailwindcss": "^3.4.1", - "tar": "^7.4.3", + "tar": "^7.5.4", "tslib": "^2.6.1", "typescript": "^5.5.0", "vite": "^8.0.0-beta.16", @@ -5524,39 +5524,6 @@ "license": "MIT", "optional": true }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs-minipass/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -5681,53 +5648,6 @@ "giget": "dist/cli.mjs" } }, - "node_modules/giget/node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/giget/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/giget/node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/giget/node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/giget/node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -5735,31 +5655,6 @@ "dev": true, "license": "MIT" }, - "node_modules/giget/node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "dev": true, - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/giget/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -8266,19 +8161,6 @@ "node": ">= 18" } }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -11652,11 +11534,11 @@ } }, "node_modules/tar": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", - "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", + "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", diff --git a/frontend/package.json b/frontend/package.json index a96edf583a..45a7f26220 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -67,7 +67,7 @@ "svelte-range-slider-pips": "^2.3.1", "svelte-splitpanes": "^8.0.9", "tailwindcss": "^3.4.1", - "tar": "^7.4.3", + "tar": "^7.5.4", "tslib": "^2.6.1", "typescript": "^5.5.0", "vite": "^8.0.0-beta.16", @@ -78,7 +78,8 @@ "overrides": { "monaco-graphql": { "monaco-editor": "$monaco-editor" - } + }, + "tar": "$tar" }, "type": "module", "dependencies": { @@ -582,4 +583,4 @@ "@rollup/rollup-linux-x64-gnu": "^4.35.0", "fsevents": "^2.3.3" } -} \ No newline at end of file +} From fe1519f1284aadd67d5dce46cf0cb52ab351f789 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 11 Mar 2026 19:12:47 +0100 Subject: [PATCH 010/204] feat: support minimal telemetry mode (#8243) * feat: support minimal telemetry mode for EE When EE customers disable telemetry, send a reduced payload with only license-compliance data instead of ignoring the setting. Job usage data is excluded in minimal mode. The telemetry settings UI now shows in EE with context-appropriate descriptions for both CE and EE. Co-Authored-By: Claude Opus 4.6 * chore: update ee-repo-ref for telemetry-minimal Co-Authored-By: Claude Opus 4.6 * feat: make telemetry toggle label and description license-aware Show "Minimal telemetry" with EE-specific description on EE, and "Disable telemetry" with CE-specific description on CE. Co-Authored-By: Claude Opus 4.6 * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 * Update commit hash in ee-repo-ref.txt * Update reference hash in ee-repo-ref.txt * chore: update ee-repo-ref to 2f52c015bc6c81391234fa87b27ee1d4cd3a48a3 This commit updates the EE repository reference after PR #440 was merged in windmill-ee-private. Previous ee-repo-ref: 3628ed51426d8d29b3d5c62864ba256b7f9eab17 New ee-repo-ref: 2f52c015bc6c81391234fa87b27ee1d4cd3a48a3 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- backend/windmill-api-settings/src/lib.rs | 2 + backend/windmill-common/src/stats_oss.rs | 7 ++- .../src/lib/components/InstanceSetting.svelte | 10 +++- .../lib/components/InstanceSettings.svelte | 50 +++++++++++-------- .../src/lib/components/instanceSettings.ts | 5 +- 6 files changed, 49 insertions(+), 27 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 1c21512157..06277182aa 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -cef4dfc45e6d6344c5d8d107bd2b4d1bf9bbdd64 +2f52c015bc6c81391234fa87b27ee1d4cd3a48a3 \ No newline at end of file diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 0fdabec16d..252c21580e 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -570,6 +570,7 @@ pub async fn send_stats(Extension(db): Extension, authed: ApiAuthed) -> Resu &HTTP_CLIENT, &db, windmill_common::stats_oss::SendStatsReason::Manual, + false, ) .await?; @@ -582,6 +583,7 @@ pub async fn get_stats(Extension(db): Extension, authed: ApiAuthed) -> Resul let stats = windmill_common::stats_oss::get_stats_payload( &db, &windmill_common::stats_oss::SendStatsReason::Manual, + false, ) .await?; let encrypted = windmill_common::stats_oss::encrypt_stats(&stats)?; diff --git a/backend/windmill-common/src/stats_oss.rs b/backend/windmill-common/src/stats_oss.rs index 161c78f81b..d3cc2e2b44 100644 --- a/backend/windmill-common/src/stats_oss.rs +++ b/backend/windmill-common/src/stats_oss.rs @@ -32,6 +32,7 @@ pub async fn send_stats( _http_client: &reqwest::Client, _db: &DB, _reason: SendStatsReason, + _minimal: bool, ) -> Result<()> { // stats details are closed source Ok(()) @@ -56,7 +57,11 @@ pub async fn get_user_usage<'c, E: sqlx::Executor<'c, Database = Postgres>>( pub struct Stats {} #[cfg(not(feature = "private"))] -pub async fn get_stats_payload(_db: &DB, _reason: &SendStatsReason) -> Result { +pub async fn get_stats_payload( + _db: &DB, + _reason: &SendStatsReason, + _minimal: bool, +) -> Result { // stats details are closed source Ok(Stats {}) } diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index 1780bc1d80..663bb8aa75 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -283,8 +283,14 @@ {/if} {:else} -
- Anonymous usage data is collected to help improve Windmill. -
The following information is collected: -
    -
  • version of your instances
  • -
  • instance base URL
  • -
  • job usage (language, total duration, count)
  • -
  • login type usage (login type, count)
  • -
  • worker usage (worker, worker instance, vCPUs, memory)
  • -
  • user usage (author count, operator count)
  • -
  • superadmin email addresses
  • -
  • vCPU usage
  • -
  • memory usage
  • -
  • development instance status
  • -
-
{#if $enterpriseLicense}
- On Enterprise Edition, you must send data to check that usage is in line with the terms of - the subscription. You can either enable telemetry or regularly send usage data by clicking - the button below. For air-gapped instances, you can download the telemetry data and send - it manually. + Telemetry is required on Enterprise Edition for license compliance. When minimal telemetry + is enabled, only the following data is sent: +
    +
  • version of your instance
  • +
  • instance base URL
  • +
  • login type usage (login type, count)
  • +
  • worker usage (worker, worker instance, vCPUs, memory)
  • +
  • user usage (author count, operator count)
  • +
  • superadmin email addresses
  • +
  • development instance status
  • +
+
When minimal telemetry is disabled, the following is also collected: +
    +
  • job usage (language, total duration, count)
  • +
+
For air-gapped instances, you can download the telemetry data and send it manually.
+ {:else} +
+ Anonymous usage data is collected to help improve Windmill. +
The following information is collected: +
    +
  • version of your instance
  • +
  • instance base URL
  • +
  • job usage (language, total duration, count)
  • +
  • login type usage (login type, count)
  • +
  • worker usage (worker, worker instance, vCPUs, memory)
  • +
  • user usage (author count, operator count)
  • +
  • development instance status
  • +
+
{/if} {:else if category == 'Jobs'} = { Telemetry: [ { - label: 'Disable telemetry', + label: 'Minimal telemetry', key: 'disable_stats', fieldType: 'boolean', - storage: 'setting', - hiddenInEe: true + storage: 'setting' } ], 'Secret Storage': [ From 4306c9e4fef317e298a76924edb4f20aa7ced105 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 11 Mar 2026 18:22:07 +0000 Subject: [PATCH 011/204] fix: skip python preinstall on native workers (#8329) Co-authored-by: Claude Opus 4.5 --- backend/windmill-worker/src/worker.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 6d86ae2723..3ea0dd0f6b 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -1432,7 +1432,7 @@ pub async fn run_worker( tracing::debug!(worker = %worker_name, hostname = %hostname, worker_dir = %worker_dir, "Creating worker dir"); #[cfg(feature = "python")] - { + if !NATIVE_MODE_RESOLVED.load(std::sync::atomic::Ordering::Relaxed) { let (conn, worker_name, hostname, worker_dir) = ( conn.clone(), worker_name.clone(), From d8b4132b9ae90af759c6655f4f69479f6738e60a Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 11 Mar 2026 23:39:15 +0100 Subject: [PATCH 012/204] fix: native mode now properly sets DB pool size and sleep queue (#8332) Native mode (from DB config) was not accounted for when sizing the connection pool or setting SLEEP_QUEUE, because both read NUM_WORKERS from env which is never set when native mode is configured via the worker group config in the database. - Resolve native mode early (before connect_db) by querying the config table with the initial DB connection - Pass num_workers directly to connect_db instead of re-reading env - Replace SLEEP_QUEUE lazy_static with sleep_queue() function that checks NATIVE_MODE_RESOLVED at runtime (returns 300ms for native) - Set NATIVE_MODE_RESOLVED immediately when is_native_mode_from_env() - Allow native_mode in CE worker group config (was silently stripped) Co-authored-by: Claude Opus 4.6 --- ...29980a0bf4970b919cabad1daab16627404fc.json | 22 +++++++++++ backend/src/db_connect.rs | 9 +---- backend/src/main.rs | 38 +++++++++++++------ backend/src/monitor.rs | 3 +- backend/windmill-api-configs/src/lib.rs | 5 ++- backend/windmill-worker/src/worker.rs | 18 ++++++--- 6 files changed, 68 insertions(+), 27 deletions(-) create mode 100644 backend/.sqlx/query-36b95bc7956eb7bba7cd6fa9cd829980a0bf4970b919cabad1daab16627404fc.json diff --git a/backend/.sqlx/query-36b95bc7956eb7bba7cd6fa9cd829980a0bf4970b919cabad1daab16627404fc.json b/backend/.sqlx/query-36b95bc7956eb7bba7cd6fa9cd829980a0bf4970b919cabad1daab16627404fc.json new file mode 100644 index 0000000000..e625a747f7 --- /dev/null +++ b/backend/.sqlx/query-36b95bc7956eb7bba7cd6fa9cd829980a0bf4970b919cabad1daab16627404fc.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT (config->>'native_mode')::boolean FROM config WHERE name = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "bool", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "36b95bc7956eb7bba7cd6fa9cd829980a0bf4970b919cabad1daab16627404fc" +} diff --git a/backend/src/db_connect.rs b/backend/src/db_connect.rs index 05bf7b6522..d0f8f3af44 100644 --- a/backend/src/db_connect.rs +++ b/backend/src/db_connect.rs @@ -20,6 +20,7 @@ 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<()>, ) -> anyhow::Result> { use anyhow::Context; @@ -34,13 +35,7 @@ pub async fn connect_db( } else if indexer_mode { DEFAULT_MAX_CONNECTIONS_INDEXER } else { - DEFAULT_MAX_CONNECTIONS_WORKER - + std::env::var("NUM_WORKERS") - .ok() - .map(|x| x.parse().ok()) - .flatten() - .unwrap_or(1) - - 1 + DEFAULT_MAX_CONNECTIONS_WORKER + (num_workers.max(1) as u32) - 1 } } }; diff --git a/backend/src/main.rs b/backend/src/main.rs index 45849c0bd3..4a58ce0fde 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -694,6 +694,7 @@ async fn windmill_main() -> anyhow::Result<()> { let mut num_workers = if mode == Mode::Server || mode == Mode::Indexer || mode == Mode::MCP { 0 } else if is_native_mode_from_env() { + NATIVE_MODE_RESOLVED.store(true, std::sync::atomic::Ordering::Relaxed); println!("Native mode enabled: forcing NUM_WORKERS=8"); 8 } else { @@ -866,6 +867,30 @@ async fn windmill_main() -> anyhow::Result<()> { } } + // Resolve native mode early (before connect_db) so connection pool size accounts for it. + // native_mode can come from env OR from the DB worker group config. + if worker_mode && !is_native_mode_from_env() { + if let Some(db) = conn.as_sql() { + let native_from_db: bool = sqlx::query_scalar!( + "SELECT (config->>'native_mode')::boolean FROM config WHERE name = $1", + format!("worker__{}", *windmill_common::worker::WORKER_GROUP) + ) + .fetch_optional(db) + .await + .ok() + .flatten() + .flatten() + .unwrap_or(false); + if native_from_db { + NATIVE_MODE_RESOLVED.store(true, std::sync::atomic::Ordering::Relaxed); + num_workers = 8; + tracing::info!( + "Native mode detected from worker config (early): forcing NUM_WORKERS=8" + ); + } + } + } + let conn = if mode == Mode::Agent { conn } else { @@ -878,6 +903,7 @@ async fn windmill_main() -> anyhow::Result<()> { server_mode, indexer_mode, worker_mode, + num_workers, #[cfg(feature = "private")] killpill_rx.resubscribe(), ) @@ -982,16 +1008,6 @@ Windmill Community Edition {GIT_VERSION} ) .await; - // native_mode may also be set via DB worker group config (not just env). - // NATIVE_MODE_RESOLVED is updated by load_worker_config during initial_load. - if worker_mode - && !is_native_mode_from_env() - && NATIVE_MODE_RESOLVED.load(std::sync::atomic::Ordering::Relaxed) - { - num_workers = 8; - tracing::info!("Native mode detected from worker config: forcing NUM_WORKERS=8"); - } - monitor_db( &conn, &base_internal_url, @@ -1884,7 +1900,7 @@ pub async fn run_workers( tracing::info!( "Starting {num_workers} workers and SLEEP_QUEUE={}ms", - *windmill_worker::SLEEP_QUEUE + windmill_worker::sleep_queue() ); for i in 1..(num_workers + 1) { diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index d0b26a6b50..803afabf0d 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -251,9 +251,8 @@ pub async fn initial_load( .map(|x| x.tags.clone()) .unwrap_or_default(); // we only check from env as native_mode is not stored in the token + // NATIVE_MODE_RESOLVED is already set in main.rs during startup let native_mode = windmill_common::worker::is_native_mode_from_env(); - windmill_common::worker::NATIVE_MODE_RESOLVED - .store(native_mode, std::sync::atomic::Ordering::Relaxed); *config = WorkerConfig { worker_tags, env_vars: load_env_vars( diff --git a/backend/windmill-api-configs/src/lib.rs b/backend/windmill-api-configs/src/lib.rs index f4e91e554f..a05f2dbaa8 100644 --- a/backend/windmill-api-configs/src/lib.rs +++ b/backend/windmill-api-configs/src/lib.rs @@ -129,11 +129,12 @@ async fn update_config( #[cfg(not(feature = "enterprise"))] let config = if name.starts_with("worker__") { - // In CE, only allow setting worker_tags, cache_clear, and init_bash + // In CE, only allow setting worker_tags, cache_clear, init_bash, and native_mode serde_json::json!({ "worker_tags": config.get("worker_tags"), "cache_clear": config.get("cache_clear"), - "init_bash": config.get("init_bash") + "init_bash": config.get("init_bash"), + "native_mode": config.get("native_mode") }) } else { config diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 3ea0dd0f6b..2a9026b82e 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -303,7 +303,7 @@ pub struct PowershellRepo { lazy_static::lazy_static! { - pub static ref SLEEP_QUEUE: u64 = std::env::var("SLEEP_QUEUE") + static ref SLEEP_QUEUE_BASE: u64 = std::env::var("SLEEP_QUEUE") .ok() .and_then(|x| x.parse::().ok()) .unwrap_or_else(|| { @@ -647,6 +647,14 @@ lazy_static::lazy_static! { pub static ref FLOW_RUNNER_RUNNING: Mutex = Mutex::new(false); } +pub fn sleep_queue() -> u64 { + if NATIVE_MODE_RESOLVED.load(std::sync::atomic::Ordering::Relaxed) { + 300 + } else { + *SLEEP_QUEUE_BASE + } +} + type Envs = Vec<(String, String)>; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1373,7 +1381,7 @@ fn start_interactive_worker_shell( { Duration::from_secs(WORKER_SHELL_NAP_TIME_DURATION) } - _ => Duration::from_millis(*SLEEP_QUEUE * 10), + _ => Duration::from_millis(sleep_queue() * 10), }; tokio::select! { _ = tokio::time::sleep(nap_time) => { @@ -1386,7 +1394,7 @@ fn start_interactive_worker_shell( Err(err) => { tracing::error!(worker = %worker_name, hostname = %hostname, "Failed to pull jobs: {}", err); - tokio::time::sleep(Duration::from_millis(*SLEEP_QUEUE * 20)).await; + tokio::time::sleep(Duration::from_millis(sleep_queue() * 20)).await; } }; } @@ -2699,7 +2707,7 @@ pub async fn run_worker( None }; - tokio::time::sleep(Duration::from_millis(*SLEEP_QUEUE)).await; + tokio::time::sleep(Duration::from_millis(sleep_queue())).await; #[cfg(feature = "benchmark")] { @@ -2720,7 +2728,7 @@ pub async fn run_worker( } Err(err) => { tracing::error!(worker = %worker_name, hostname = %hostname, "Failed to pull jobs: {}", err); - tokio::time::sleep(Duration::from_millis(*SLEEP_QUEUE * 5)).await; + tokio::time::sleep(Duration::from_millis(sleep_queue() * 5)).await; } }; } From cbc7e78f8a60bff1d8730a6183cdbc9125d8e2b1 Mon Sep 17 00:00:00 2001 From: Clumsy <59402569+ClumsyAdmin@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:42:15 -0400 Subject: [PATCH 013/204] fix: show diff editor content for resources without a language (#8331) Resources like ansible_inventory have content but no language field, causing the DiffEditor setupModel guard to skip initialization entirely. Fall back to 'plaintext' when no language is provided but content exists. Co-authored-by: Claude Opus 4.6 --- frontend/src/lib/components/DiffEditor.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/DiffEditor.svelte b/frontend/src/lib/components/DiffEditor.svelte index 1d252d082a..3fb26334b9 100644 --- a/frontend/src/lib/components/DiffEditor.svelte +++ b/frontend/src/lib/components/DiffEditor.svelte @@ -102,8 +102,8 @@ }) } - if (defaultLang !== undefined) { - setupModel(defaultLang, defaultOriginal, defaultModified, defaultModifiedLang) + if (defaultLang !== undefined || defaultOriginal !== undefined || defaultModified !== undefined) { + setupModel(defaultLang ?? 'plaintext', defaultOriginal, defaultModified, defaultModifiedLang) } } From bf4340f40c1eb9cacee4c32e07ba44f2c92bf7c4 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 11 Mar 2026 23:46:24 +0000 Subject: [PATCH 014/204] fix: set min_connections(0) to prevent sqlx pool spin loop (#8334) Co-authored-by: Claude Opus 4.5 --- backend/src/db_connect.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/db_connect.rs b/backend/src/db_connect.rs index d0f8f3af44..abb3378efc 100644 --- a/backend/src/db_connect.rs +++ b/backend/src/db_connect.rs @@ -98,7 +98,7 @@ pub async fn connect( use sqlx::Executor; use std::time::Duration; let mut pool_options = sqlx::postgres::PgPoolOptions::new() - .min_connections((max_connections / 5).clamp(1, max_connections)) + .min_connections(0) .max_connections(max_connections) .max_lifetime(Duration::from_secs(30 * 60)); // 30 mins if worker_mode { From ca8a6274bc81ad49fa0c6166694ae4d65a4048cb Mon Sep 17 00:00:00 2001 From: wendrul <53628737+wendrul@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:15:00 +0100 Subject: [PATCH 015/204] fix: use !inline ref for scripts inside flows (preproc, error, ai tool) (#8319) * fix: use !inline ref for scripts inside flows (preproc, error, ai tool) * add test * path assign better --- cli/src/commands/flow/flow.ts | 18 +- cli/src/commands/flow/flow_metadata.ts | 30 +- cli/src/commands/sync/sync.ts | 23 +- ...nline_scripts_failure_preprocessor.test.ts | 498 ++++++++++++++++++ .../src/inline-scripts/extractor.ts | 11 +- 5 files changed, 573 insertions(+), 7 deletions(-) create mode 100644 cli/test/inline_scripts_failure_preprocessor.test.ts diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index eff742ee68..f0974cf354 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -56,13 +56,20 @@ export async function pushFlow( } const localFlow = (await yamlParseFile(localPath + "flow.yaml")) as FlowFile; + const fileReader = async (path: string) => await readFile(localPath + path, "utf-8"); await replaceInlineScripts( localFlow.value.modules, - async (path: string) => await readFile(localPath + path, "utf-8"), + fileReader, log, localPath, SEP ); + if (localFlow.value.failure_module) { + await replaceInlineScripts([localFlow.value.failure_module], fileReader, log, localPath, SEP); + } + if (localFlow.value.preprocessor_module) { + await replaceInlineScripts([localFlow.value.preprocessor_module], fileReader, log, localPath, SEP); + } if (flow) { if (isSuperset(localFlow, flow)) { @@ -252,13 +259,20 @@ async function preview( const localFlow = (await yamlParseFile(flowPath + "flow.yaml")) as FlowFile; // Replace inline scripts with their actual content + const fileReader = async (path: string) => await readFile(flowPath + path, "utf-8"); await replaceInlineScripts( localFlow.value.modules, - async (path: string) => await readFile(flowPath + path, "utf-8"), + fileReader, log, flowPath, SEP ); + if (localFlow.value.failure_module) { + await replaceInlineScripts([localFlow.value.failure_module], fileReader, log, flowPath, SEP); + } + if (localFlow.value.preprocessor_module) { + await replaceInlineScripts([localFlow.value.preprocessor_module], fileReader, log, flowPath, SEP); + } const input = opts.data ? await resolve(opts.data) : {}; diff --git a/cli/src/commands/flow/flow_metadata.ts b/cli/src/commands/flow/flow_metadata.ts index cb2e5336e6..d52d05a3ed 100644 --- a/cli/src/commands/flow/flow_metadata.ts +++ b/cli/src/commands/flow/flow_metadata.ts @@ -19,6 +19,7 @@ import { } from "../../utils/metadata.ts"; import { ScriptLanguage } from "../../utils/script_common.ts"; import { extractInlineScripts as extractInlineScriptsForFlows } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts"; +import { newPathAssigner } from "../../../windmill-utils-internal/src/path-utils/path-assigner.ts"; import { generateHash, getHeaders, writeIfChanged } from "../../utils/utils.ts"; import { exts } from "../script/script.ts"; @@ -121,14 +122,21 @@ export async function generateFlowLockInternal( } log.info(`Recomputing locks of ${changedScripts.join(", ")} in ${folder}`); + const fileReader = async (path: string) => await readFile(folder + SEP + path, "utf-8"); await replaceInlineScripts( flowValue.value.modules, - async (path: string) => await readFile(folder + SEP + path, "utf-8"), + fileReader, log, folder + SEP!, SEP, changedScripts ); + if (flowValue.value.failure_module) { + await replaceInlineScripts([flowValue.value.failure_module], fileReader, log, folder + SEP!, SEP, changedScripts); + } + if (flowValue.value.preprocessor_module) { + await replaceInlineScripts([flowValue.value.preprocessor_module], fileReader, log, folder + SEP!, SEP, changedScripts); + } //removeChangedLocks flowValue.value = await updateFlow( @@ -138,12 +146,20 @@ export async function generateFlowLockInternal( filteredDeps ); + const lockAssigner = newPathAssigner(opts.defaultTs ?? "bun"); const inlineScripts = extractInlineScriptsForFlows( flowValue.value.modules, {}, SEP, - opts.defaultTs + opts.defaultTs, + lockAssigner ); + if (flowValue.value.failure_module) { + inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.failure_module], {}, SEP, opts.defaultTs, lockAssigner)); + } + if (flowValue.value.preprocessor_module) { + inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.preprocessor_module], {}, SEP, opts.defaultTs, lockAssigner)); + } inlineScripts.forEach((s) => { writeIfChanged(process.cwd() + SEP + folder + SEP + s.path, s.content); }); @@ -176,7 +192,15 @@ async function filterWorkspaceDependenciesForFlow( rawWorkspaceDependencies: Record, folder: string ): Promise> { - const inlineScripts = extractInlineScriptsForFlows(structuredClone(flowValue.modules), {}, SEP, undefined); + const clonedValue = structuredClone(flowValue); + const depAssigner = newPathAssigner("bun"); + const inlineScripts = extractInlineScriptsForFlows(clonedValue.modules, {}, SEP, undefined, depAssigner); + if (clonedValue.failure_module) { + inlineScripts.push(...extractInlineScriptsForFlows([clonedValue.failure_module], {}, SEP, undefined, depAssigner)); + } + if (clonedValue.preprocessor_module) { + inlineScripts.push(...extractInlineScriptsForFlows([clonedValue.preprocessor_module], {}, SEP, undefined, depAssigner)); + } // Filter out lock files and map to common interface const scripts = inlineScripts diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 7d48a14848..6375a55ada 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -592,14 +592,35 @@ function ZipFSElement( } let inlineScripts; try { + const assigner = newPathAssigner(defaultTs, { skipInlineScriptSuffix: getNonDottedPaths() }); inlineScripts = extractInlineScriptsForFlows( flow.value.modules as any, {}, SEP, defaultTs, - undefined, // pathAssigner - let it create one + assigner, { skipInlineScriptSuffix: getNonDottedPaths() }, ); + if (flow.value.failure_module) { + inlineScripts.push(...extractInlineScriptsForFlows( + [flow.value.failure_module], + {}, + SEP, + defaultTs, + assigner, + { skipInlineScriptSuffix: getNonDottedPaths() }, + )); + } + if (flow.value.preprocessor_module) { + inlineScripts.push(...extractInlineScriptsForFlows( + [flow.value.preprocessor_module], + {}, + SEP, + defaultTs, + assigner, + { skipInlineScriptSuffix: getNonDottedPaths() }, + )); + } } catch (error) { log.error( `Failed to extract inline scripts for flow at path: ${p}`, diff --git a/cli/test/inline_scripts_failure_preprocessor.test.ts b/cli/test/inline_scripts_failure_preprocessor.test.ts new file mode 100644 index 0000000000..1a9150a257 --- /dev/null +++ b/cli/test/inline_scripts_failure_preprocessor.test.ts @@ -0,0 +1,498 @@ +/** + * Unit tests for failure_module and preprocessor_module inline script + * extraction (pull) and replacement (push). + * + * These tests verify that rawscript content in failure_module and + * preprocessor_module is correctly extracted to !inline references + * and resolved back, matching the existing behavior for regular modules. + */ + +import { expect, test, describe } from "bun:test"; +import { extractInlineScripts, extractCurrentMapping } from "../windmill-utils-internal/src/inline-scripts/extractor.ts"; +import { replaceInlineScripts } from "../windmill-utils-internal/src/inline-scripts/replacer.ts"; +import { newPathAssigner } from "../windmill-utils-internal/src/path-utils/path-assigner.ts"; +import type { FlowModule } from "../windmill-utils-internal/src/gen/types.gen.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeRawscriptModule( + id: string, + content: string, + language: "bun" | "python3" | "deno" = "bun", + lock?: string, +): FlowModule { + return { + id, + value: { + type: "rawscript" as const, + content, + language, + lock: lock, + input_transforms: {}, + }, + }; +} + +const noopLogger = { + info: () => {}, + error: () => {}, +}; + +// --------------------------------------------------------------------------- +// extractInlineScripts — PULL direction +// --------------------------------------------------------------------------- + +describe("extractInlineScripts for failure_module / preprocessor_module", () => { + test("extracts rawscript from failure_module wrapped in array", () => { + const failureModule = makeRawscriptModule( + "failure", + 'export function main() { throw new Error("handler"); }', + "bun", + ); + + const scripts = extractInlineScripts([failureModule], {}, "/", "bun"); + + expect(scripts.length).toBeGreaterThanOrEqual(1); + const script = scripts.find((s) => !s.is_lock); + expect(script).toBeDefined(); + expect(script!.content).toBe( + 'export function main() { throw new Error("handler"); }', + ); + // The module content should have been replaced with an !inline reference + expect(failureModule.value.content).toStartWith("!inline "); + }); + + test("extracts rawscript from preprocessor_module wrapped in array", () => { + const preprocessorModule = makeRawscriptModule( + "preprocessor", + "export function main() { return {}; }", + "python3", + ); + + const scripts = extractInlineScripts( + [preprocessorModule], + {}, + "/", + "bun", + ); + + expect(scripts.length).toBeGreaterThanOrEqual(1); + const script = scripts.find((s) => !s.is_lock); + expect(script).toBeDefined(); + expect(script!.content).toBe("export function main() { return {}; }"); + expect(script!.language).toBe("python3"); + expect(preprocessorModule.value.content).toStartWith("!inline "); + }); + + test("extracts lock alongside content", () => { + const mod = makeRawscriptModule( + "failure", + "console.log('hi')", + "bun", + "some-lock-content", + ); + + const scripts = extractInlineScripts([mod], {}, "/", "bun"); + + const contentScript = scripts.find((s) => !s.is_lock); + const lockScript = scripts.find((s) => s.is_lock); + expect(contentScript).toBeDefined(); + expect(lockScript).toBeDefined(); + expect(lockScript!.content).toBe("some-lock-content"); + expect((mod.value as any).lock).toStartWith("!inline "); + }); + + test("shared pathAssigner prevents collisions when summaries match", () => { + // If a regular module and failure_module share the same summary, + // a shared PathAssigner deduplicates via its internal counter. + const regular = makeRawscriptModule("a", "code_a", "bun"); + regular.summary = "my step"; + const failure = makeRawscriptModule("failure", "code_failure", "bun"); + failure.summary = "my step"; // same summary — would collide without shared assigner + + const assigner = newPathAssigner("bun"); + const scripts1 = extractInlineScripts([regular], {}, "/", "bun", assigner); + const scripts2 = extractInlineScripts([failure], {}, "/", "bun", assigner); + + const allPaths = [...scripts1, ...scripts2] + .filter((s) => !s.is_lock) + .map((s) => s.path); + + // All paths should be unique despite identical summaries + expect(allPaths.length).toBe(2); + expect(new Set(allPaths).size).toBe(2); + }); + + test("without shared pathAssigner, identical summaries produce duplicate paths", () => { + // Demonstrates the problem that sharing a PathAssigner solves. + const regular = makeRawscriptModule("a", "code_a", "bun"); + regular.summary = "my step"; + const failure = makeRawscriptModule("failure", "code_failure", "bun"); + failure.summary = "my step"; + + // Separate assigners — each starts with a fresh counter + const scripts1 = extractInlineScripts([regular], {}, "/", "bun"); + const scripts2 = extractInlineScripts([failure], {}, "/", "bun"); + + const allPaths = [...scripts1, ...scripts2] + .filter((s) => !s.is_lock) + .map((s) => s.path); + + // Without a shared assigner, the paths collide + expect(allPaths.length).toBe(2); + expect(new Set(allPaths).size).toBe(1); // both got the same path + }); + + test("skips non-rawscript failure_module (identity type)", () => { + const identityModule: FlowModule = { + id: "failure", + value: { type: "identity" as any }, + }; + const scripts = extractInlineScripts([identityModule], {}, "/", "bun"); + expect(scripts).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// replaceInlineScripts — PUSH direction +// --------------------------------------------------------------------------- + +describe("replaceInlineScripts for failure_module / preprocessor_module", () => { + test("resolves !inline reference back to file content", async () => { + const failureModule = makeRawscriptModule( + "failure", + "!inline failure.inline_script.ts", + "bun", + ); + + const files: Record = { + "failure.inline_script.ts": 'export function main() { return "error handled"; }', + }; + + await replaceInlineScripts( + [failureModule], + async (path) => { + if (!(path in files)) throw new Error(`File not found: ${path}`); + return files[path]; + }, + noopLogger, + "/tmp/test/", + "/", + ); + + expect(failureModule.value.content).toBe( + 'export function main() { return "error handled"; }', + ); + }); + + test("resolves !inline reference for preprocessor_module", async () => { + const preprocessorModule = makeRawscriptModule( + "preprocessor", + "!inline preprocessor.inline_script.py", + "python3", + ); + + const files: Record = { + "preprocessor.inline_script.py": "def main(): return {}", + }; + + await replaceInlineScripts( + [preprocessorModule], + async (path) => { + if (!(path in files)) throw new Error(`File not found: ${path}`); + return files[path]; + }, + noopLogger, + "/tmp/test/", + "/", + ); + + expect(preprocessorModule.value.content).toBe("def main(): return {}"); + }); + + test("resolves !inline lock reference", async () => { + const mod = makeRawscriptModule( + "failure", + "!inline failure.inline_script.ts", + "bun", + "!inline failure.inline_script.lock", + ); + + const files: Record = { + "failure.inline_script.ts": "code here", + "failure.inline_script.lock": "lock-data-here", + }; + + await replaceInlineScripts( + [mod], + async (path) => { + if (!(path in files)) throw new Error(`File not found: ${path}`); + return files[path]; + }, + noopLogger, + "/tmp/test/", + "/", + ); + + expect(mod.value.content).toBe("code here"); + expect((mod.value as any).lock).toBe("lock-data-here"); + }); + + test("leaves non-inline content untouched", async () => { + const mod = makeRawscriptModule( + "failure", + "export function main() { return 1; }", + "bun", + ); + + await replaceInlineScripts( + [mod], + async () => { + throw new Error("should not be called"); + }, + noopLogger, + "/tmp/test/", + "/", + ); + + expect(mod.value.content).toBe("export function main() { return 1; }"); + }); +}); + +// --------------------------------------------------------------------------- +// Round-trip: extract then replace +// --------------------------------------------------------------------------- + +describe("round-trip extract → replace for failure_module / preprocessor_module", () => { + test("failure_module content survives extract + replace", async () => { + const originalContent = 'export function main(error: any) {\n console.error(error);\n return { handled: true };\n}'; + const failureModule = makeRawscriptModule( + "failure", + originalContent, + "bun", + ); + + // PULL: extract inline scripts (mutates module in place) + const extracted = extractInlineScripts([failureModule], {}, "/", "bun"); + expect(failureModule.value.content).toStartWith("!inline "); + + // Build a virtual filesystem from extracted scripts + const files: Record = {}; + for (const s of extracted) { + files[s.path] = s.content; + } + + // PUSH: replace inline references back + await replaceInlineScripts( + [failureModule], + async (path) => { + if (!(path in files)) throw new Error(`File not found: ${path}`); + return files[path]; + }, + noopLogger, + "/tmp/test/", + "/", + ); + + expect(failureModule.value.content).toBe(originalContent); + }); + + test("preprocessor_module content survives extract + replace", async () => { + const originalContent = "def main():\n return {\"preprocessed\": True}"; + const preprocessorModule = makeRawscriptModule( + "preprocessor", + originalContent, + "python3", + ); + + const extracted = extractInlineScripts( + [preprocessorModule], + {}, + "/", + "bun", + ); + expect(preprocessorModule.value.content).toStartWith("!inline "); + + const files: Record = {}; + for (const s of extracted) { + files[s.path] = s.content; + } + + await replaceInlineScripts( + [preprocessorModule], + async (path) => { + if (!(path in files)) throw new Error(`File not found: ${path}`); + return files[path]; + }, + noopLogger, + "/tmp/test/", + "/", + ); + + expect(preprocessorModule.value.content).toBe(originalContent); + }); + + test("failure_module with lock survives extract + replace", async () => { + const originalContent = "export function main() { return 42; }"; + const originalLock = "package-lock-contents-here"; + const mod = makeRawscriptModule( + "failure", + originalContent, + "bun", + originalLock, + ); + + const extracted = extractInlineScripts([mod], {}, "/", "bun"); + + const files: Record = {}; + for (const s of extracted) { + files[s.path] = s.content; + } + + await replaceInlineScripts( + [mod], + async (path) => { + if (!(path in files)) throw new Error(`File not found: ${path}`); + return files[path]; + }, + noopLogger, + "/tmp/test/", + "/", + ); + + expect(mod.value.content).toBe(originalContent); + expect((mod.value as any).lock).toBe(originalLock); + }); + + test("full flow with modules + failure_module + preprocessor_module round-trips", async () => { + const regularContent = "export function main() { return 'step1'; }"; + const failureContent = "export function main(e: any) { return e; }"; + const preprocessorContent = "def main():\n pass"; + + const modules = [makeRawscriptModule("a", regularContent, "bun")]; + const failureModule = makeRawscriptModule("failure", failureContent, "bun"); + const preprocessorModule = makeRawscriptModule("preprocessor", preprocessorContent, "python3"); + + // Extract all (mimicking sync.ts pull logic) + const allExtracted = [ + ...extractInlineScripts(modules, {}, "/", "bun"), + ...extractInlineScripts([failureModule], {}, "/", "bun"), + ...extractInlineScripts([preprocessorModule], {}, "/", "bun"), + ]; + + // All modules should now have !inline references + expect(modules[0].value.content).toStartWith("!inline "); + expect(failureModule.value.content).toStartWith("!inline "); + expect(preprocessorModule.value.content).toStartWith("!inline "); + + // All paths should be unique + const paths = allExtracted.filter((s) => !s.is_lock).map((s) => s.path); + expect(new Set(paths).size).toBe(paths.length); + + // Build filesystem + const files: Record = {}; + for (const s of allExtracted) { + files[s.path] = s.content; + } + + const fileReader = async (path: string) => { + if (!(path in files)) throw new Error(`File not found: ${path}`); + return files[path]; + }; + + // Replace all (mimicking flow.ts push logic) + await replaceInlineScripts(modules, fileReader, noopLogger, "/tmp/", "/"); + await replaceInlineScripts([failureModule], fileReader, noopLogger, "/tmp/", "/"); + await replaceInlineScripts([preprocessorModule], fileReader, noopLogger, "/tmp/", "/"); + + expect(modules[0].value.content).toBe(regularContent); + expect(failureModule.value.content).toBe(failureContent); + expect(preprocessorModule.value.content).toBe(preprocessorContent); + }); +}); + +// --------------------------------------------------------------------------- +// extractCurrentMapping +// --------------------------------------------------------------------------- + +describe("extractCurrentMapping for failure_module / preprocessor_module", () => { + test("extracts mapping from failure_module via optional param", () => { + const failureModule: FlowModule = makeRawscriptModule( + "failure", + "!inline failure.inline_script.ts", + "bun", + ); + + const mapping = extractCurrentMapping( + undefined, + {}, + failureModule, + undefined, + ); + + expect(mapping["failure"]).toBe("failure.inline_script.ts"); + }); + + test("extracts mapping from preprocessor_module via optional param", () => { + const preprocessorModule: FlowModule = makeRawscriptModule( + "preprocessor", + "!inline preprocessor.inline_script.py", + "python3", + ); + + const mapping = extractCurrentMapping( + undefined, + {}, + undefined, + preprocessorModule, + ); + + expect(mapping["preprocessor"]).toBe("preprocessor.inline_script.py"); + }); + + test("extracts mapping from modules + failure + preprocessor combined", () => { + const modules: FlowModule[] = [ + makeRawscriptModule("a", "!inline a.inline_script.ts", "bun"), + ]; + const failureModule = makeRawscriptModule( + "failure", + "!inline failure.inline_script.ts", + "bun", + ); + const preprocessorModule = makeRawscriptModule( + "preprocessor", + "!inline preprocessor.inline_script.py", + "python3", + ); + + const mapping = extractCurrentMapping( + modules, + {}, + failureModule, + preprocessorModule, + ); + + expect(mapping["a"]).toBe("a.inline_script.ts"); + expect(mapping["failure"]).toBe("failure.inline_script.ts"); + expect(mapping["preprocessor"]).toBe("preprocessor.inline_script.py"); + }); + + test("ignores non-inline content in failure_module", () => { + const failureModule = makeRawscriptModule( + "failure", + "export function main() {}", + "bun", + ); + + const mapping = extractCurrentMapping( + undefined, + {}, + failureModule, + undefined, + ); + + expect(mapping["failure"]).toBeUndefined(); + }); +}); diff --git a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts index df9a1db08b..a3572ce7eb 100644 --- a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts +++ b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts @@ -150,8 +150,17 @@ export function extractInlineScripts( */ export function extractCurrentMapping( modules: FlowModule[] | undefined, - mapping: Record = {} + mapping: Record = {}, + failureModule?: FlowModule, + preprocessorModule?: FlowModule, ): Record { + if (failureModule) { + extractCurrentMapping([failureModule], mapping); + } + if (preprocessorModule) { + extractCurrentMapping([preprocessorModule], mapping); + } + if (!modules || !Array.isArray(modules)) { return mapping; } From 7fb729cc8483a2e6966a8e8995678929f4d451a0 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:18:03 +0100 Subject: [PATCH 016/204] fix(cli): instruct agent to tell user about generate-metadata and sync push instead of running them (#8318) Co-authored-by: Claude Opus 4.5 --- cli/src/guidance/skills.ts | 101 +++++++++++++----- .../auto-generated/cli/cli-commands.md | 2 +- system_prompts/auto-generated/flow.md | 4 +- system_prompts/auto-generated/prompts.ts | 6 +- .../schemas/kafka_trigger.schema.yaml | 7 ++ .../skills/cli-commands/SKILL.md | 2 +- .../auto-generated/skills/raw-app/SKILL.md | 6 +- .../auto-generated/skills/resources/SKILL.md | 2 +- .../auto-generated/skills/schedules/SKILL.md | 2 + .../auto-generated/skills/triggers/SKILL.md | 2 + .../auto-generated/skills/write-flow/SKILL.md | 4 +- .../skills/write-script-bash/SKILL.md | 4 +- .../skills/write-script-bigquery/SKILL.md | 4 +- .../skills/write-script-bun/SKILL.md | 4 +- .../skills/write-script-bunnative/SKILL.md | 4 +- .../skills/write-script-csharp/SKILL.md | 4 +- .../skills/write-script-deno/SKILL.md | 4 +- .../skills/write-script-duckdb/SKILL.md | 4 +- .../skills/write-script-go/SKILL.md | 4 +- .../skills/write-script-graphql/SKILL.md | 4 +- .../skills/write-script-java/SKILL.md | 4 +- .../skills/write-script-mssql/SKILL.md | 4 +- .../skills/write-script-mysql/SKILL.md | 4 +- .../skills/write-script-nativets/SKILL.md | 4 +- .../skills/write-script-php/SKILL.md | 4 +- .../skills/write-script-postgresql/SKILL.md | 4 +- .../skills/write-script-powershell/SKILL.md | 4 +- .../skills/write-script-python3/SKILL.md | 4 +- .../skills/write-script-rust/SKILL.md | 4 +- .../skills/write-script-snowflake/SKILL.md | 4 +- system_prompts/base/flow-base.md | 4 +- system_prompts/base/raw-app.md | 6 +- system_prompts/base/resources.md | 2 +- system_prompts/base/schedules.md | 2 + system_prompts/base/triggers.md | 2 + system_prompts/generate.py | 4 +- 36 files changed, 177 insertions(+), 57 deletions(-) diff --git a/cli/src/guidance/skills.ts b/cli/src/guidance/skills.ts index 34e288c7d7..e8abfdd924 100644 --- a/cli/src/guidance/skills.ts +++ b/cli/src/guidance/skills.ts @@ -43,10 +43,12 @@ description: MUST use when writing Go scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # Go @@ -115,10 +117,12 @@ description: MUST use when writing Java scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # Java @@ -167,10 +171,12 @@ description: MUST use when writing GraphQL queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # GraphQL @@ -226,10 +232,12 @@ description: MUST use when writing Rust scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # Rust @@ -315,10 +323,12 @@ description: MUST use when writing Bun Native scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # TypeScript (Bun Native) @@ -967,10 +977,12 @@ description: MUST use when writing PostgreSQL queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # PostgreSQL @@ -992,10 +1004,12 @@ description: MUST use when writing PHP scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # PHP @@ -1063,10 +1077,12 @@ description: MUST use when writing BigQuery queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # BigQuery @@ -1088,10 +1104,12 @@ description: MUST use when writing Bun/TypeScript scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # TypeScript (Bun) @@ -1742,10 +1760,12 @@ description: MUST use when writing C# scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # C# @@ -1797,10 +1817,12 @@ description: MUST use when writing MS SQL Server queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # Microsoft SQL Server (MSSQL) @@ -1822,10 +1844,12 @@ description: MUST use when writing Deno/TypeScript scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # TypeScript (Deno) @@ -2480,10 +2504,12 @@ description: MUST use when writing MySQL queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # MySQL @@ -2505,10 +2531,12 @@ description: MUST use when writing PowerShell scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # PowerShell @@ -2574,10 +2602,12 @@ description: MUST use when writing Snowflake queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # Snowflake @@ -2599,10 +2629,12 @@ description: MUST use when writing Python scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # Python @@ -3404,10 +3436,12 @@ description: MUST use when writing DuckDB queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # DuckDB @@ -3469,10 +3503,12 @@ description: MUST use when writing Bash scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # Bash @@ -3532,10 +3568,12 @@ description: MUST use when writing Native TypeScript scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # TypeScript (Native) @@ -4155,10 +4193,12 @@ description: MUST use when creating flows. Create a folder ending with \`.flow\` and add a YAML file with the flow definition. For rawscript modules, use \`!inline path/to/script.ts\` for the content key. -After writing: +After writing, tell the user they can run: - \`wmill flow generate-locks --yes\` - Generate lock files for the specific flow you modified (e.g. \`wmill flow generate-locks f/my_folder/my_flow.flow --yes\`) - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + ## OpenFlow Schema The OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions. @@ -4356,7 +4396,7 @@ export async function main(user_id: string) { } \`\`\` -After creating, generate lock files: +After creating, tell the user they can generate lock files by running: \`\`\`bash wmill app generate-locks \`\`\` @@ -4509,6 +4549,8 @@ data: ## CLI Commands +Tell the user they can run these commands (do NOT run them yourself): + | Command | Description | |---------|-------------| | \`wmill app new\` | Create a new raw app interactively | @@ -4525,7 +4567,7 @@ data: 3. **Keep runnables focused** - one function per file 4. **Use descriptive IDs** - \`get_user.ts\` not \`a.ts\` 5. **Always whitelist tables** - add to \`data.tables\` before querying -6. **Generate locks** - run \`wmill app generate-locks\` after adding/modifying backend runnables +6. **Generate locks** - tell the user to run \`wmill app generate-locks\` after adding/modifying backend runnables `, "triggers": `--- name: triggers @@ -4547,6 +4589,8 @@ Examples: ## CLI Commands +After writing, tell the user they can run these commands (do NOT run them yourself): + \`\`\`bash # Push trigger configuration wmill sync push @@ -4596,6 +4640,8 @@ Windmill uses 6-field cron expressions (includes seconds): ## CLI Commands +After writing, tell the user they can run these commands (do NOT run them yourself): + \`\`\`bash # Push schedules to Windmill wmill sync push @@ -4851,7 +4897,7 @@ wmill resource-type list --schema # Get specific resource type schema wmill resource-type get postgresql -# Push resources +# Push resources (tell the user to run this, do NOT run it yourself) wmill sync push \`\`\` `, @@ -4864,7 +4910,7 @@ description: MUST use when using the CLI. The Windmill CLI (\`wmill\`) provides commands for managing scripts, flows, apps, and other resources. -Current version: 1.651.1 +Current version: 1.653.0 ## Global Options @@ -5613,6 +5659,13 @@ properties: key: type: string value: {} + auto_offset_reset: + type: string + enum: + - latest + - earliest + description: Initial offset behavior when consumer group has no committed offset. + 'latest' starts from new messages only, 'earliest' starts from the beginning. error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index ce94c1fe28..9d80c0d52f 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -2,7 +2,7 @@ The Windmill CLI (`wmill`) provides commands for managing scripts, flows, apps, and other resources. -Current version: 1.651.1 +Current version: 1.653.0 ## Global Options diff --git a/system_prompts/auto-generated/flow.md b/system_prompts/auto-generated/flow.md index 6adbe267ef..8b00ad0a0c 100644 --- a/system_prompts/auto-generated/flow.md +++ b/system_prompts/auto-generated/flow.md @@ -4,10 +4,12 @@ Create a folder ending with `.flow` and add a YAML file with the flow definition. For rawscript modules, use `!inline path/to/script.ts` for the content key. -After writing: +After writing, tell the user they can run: - `wmill flow generate-locks --yes` - Generate lock files for the specific flow you modified (e.g. `wmill flow generate-locks f/my_folder/my_flow.flow --yes`) - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + ## OpenFlow Schema The OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions. diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 6192d42931..e68374fa7f 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -35,10 +35,12 @@ export const FLOW_BASE = `# Windmill Flow Building Guide Create a folder ending with \`.flow\` and add a YAML file with the flow definition. For rawscript modules, use \`!inline path/to/script.ts\` for the content key. -After writing: +After writing, tell the user they can run: - \`wmill flow generate-locks --yes\` - Generate lock files for the specific flow you modified (e.g. \`wmill flow generate-locks f/my_folder/my_flow.flow --yes\`) - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + ## OpenFlow Schema The OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions. @@ -1356,7 +1358,7 @@ export const CLI_COMMANDS = `# Windmill CLI Commands The Windmill CLI (\`wmill\`) provides commands for managing scripts, flows, apps, and other resources. -Current version: 1.651.1 +Current version: 1.653.0 ## Global Options diff --git a/system_prompts/auto-generated/schemas/kafka_trigger.schema.yaml b/system_prompts/auto-generated/schemas/kafka_trigger.schema.yaml index 1a0c98ef41..74cfa8e615 100644 --- a/system_prompts/auto-generated/schemas/kafka_trigger.schema.yaml +++ b/system_prompts/auto-generated/schemas/kafka_trigger.schema.yaml @@ -25,6 +25,13 @@ properties: key: type: string value: {} + auto_offset_reset: + type: string + enum: + - latest + - earliest + description: Initial offset behavior when consumer group has no committed offset. + 'latest' starts from new messages only, 'earliest' starts from the beginning. error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 66d8d51b4a..562d0b8536 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -7,7 +7,7 @@ description: MUST use when using the CLI. The Windmill CLI (`wmill`) provides commands for managing scripts, flows, apps, and other resources. -Current version: 1.651.1 +Current version: 1.653.0 ## Global Options diff --git a/system_prompts/auto-generated/skills/raw-app/SKILL.md b/system_prompts/auto-generated/skills/raw-app/SKILL.md index 8fb05e43c9..533e5f7c3e 100644 --- a/system_prompts/auto-generated/skills/raw-app/SKILL.md +++ b/system_prompts/auto-generated/skills/raw-app/SKILL.md @@ -84,7 +84,7 @@ export async function main(user_id: string) { } ``` -After creating, generate lock files: +After creating, tell the user they can generate lock files by running: ```bash wmill app generate-locks ``` @@ -237,6 +237,8 @@ data: ## CLI Commands +Tell the user they can run these commands (do NOT run them yourself): + | Command | Description | |---------|-------------| | `wmill app new` | Create a new raw app interactively | @@ -253,4 +255,4 @@ data: 3. **Keep runnables focused** - one function per file 4. **Use descriptive IDs** - `get_user.ts` not `a.ts` 5. **Always whitelist tables** - add to `data.tables` before querying -6. **Generate locks** - run `wmill app generate-locks` after adding/modifying backend runnables +6. **Generate locks** - tell the user to run `wmill app generate-locks` after adding/modifying backend runnables diff --git a/system_prompts/auto-generated/skills/resources/SKILL.md b/system_prompts/auto-generated/skills/resources/SKILL.md index 649cb39cbb..3f78cc1b0b 100644 --- a/system_prompts/auto-generated/skills/resources/SKILL.md +++ b/system_prompts/auto-generated/skills/resources/SKILL.md @@ -242,6 +242,6 @@ wmill resource-type list --schema # Get specific resource type schema wmill resource-type get postgresql -# Push resources +# Push resources (tell the user to run this, do NOT run it yourself) wmill sync push ``` diff --git a/system_prompts/auto-generated/skills/schedules/SKILL.md b/system_prompts/auto-generated/skills/schedules/SKILL.md index 1cd64cb002..24dab471e5 100644 --- a/system_prompts/auto-generated/skills/schedules/SKILL.md +++ b/system_prompts/auto-generated/skills/schedules/SKILL.md @@ -39,6 +39,8 @@ Windmill uses 6-field cron expressions (includes seconds): ## CLI Commands +After writing, tell the user they can run these commands (do NOT run them yourself): + ```bash # Push schedules to Windmill wmill sync push diff --git a/system_prompts/auto-generated/skills/triggers/SKILL.md b/system_prompts/auto-generated/skills/triggers/SKILL.md index 5f3a532051..183f447175 100644 --- a/system_prompts/auto-generated/skills/triggers/SKILL.md +++ b/system_prompts/auto-generated/skills/triggers/SKILL.md @@ -18,6 +18,8 @@ Examples: ## CLI Commands +After writing, tell the user they can run these commands (do NOT run them yourself): + ```bash # Push trigger configuration wmill sync push diff --git a/system_prompts/auto-generated/skills/write-flow/SKILL.md b/system_prompts/auto-generated/skills/write-flow/SKILL.md index e4a8bc976c..f844b813bd 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -9,10 +9,12 @@ description: MUST use when creating flows. Create a folder ending with `.flow` and add a YAML file with the flow definition. For rawscript modules, use `!inline path/to/script.ts` for the content key. -After writing: +After writing, tell the user they can run: - `wmill flow generate-locks --yes` - Generate lock files for the specific flow you modified (e.g. `wmill flow generate-locks f/my_folder/my_flow.flow --yes`) - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + ## OpenFlow Schema The OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions. diff --git a/system_prompts/auto-generated/skills/write-script-bash/SKILL.md b/system_prompts/auto-generated/skills/write-script-bash/SKILL.md index a914902deb..b776538001 100644 --- a/system_prompts/auto-generated/skills/write-script-bash/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bash/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing Bash scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # Bash 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 8561d3170e..cd3c9e1610 100644 --- a/system_prompts/auto-generated/skills/write-script-bigquery/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bigquery/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing BigQuery queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # BigQuery 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 3758a172e2..6957a139f9 100644 --- a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing Bun/TypeScript scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # TypeScript (Bun) 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 0ae5b57474..731462addd 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing Bun Native scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # TypeScript (Bun Native) diff --git a/system_prompts/auto-generated/skills/write-script-csharp/SKILL.md b/system_prompts/auto-generated/skills/write-script-csharp/SKILL.md index e0d268d55e..ca807520e0 100644 --- a/system_prompts/auto-generated/skills/write-script-csharp/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-csharp/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing C# scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # C# 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 a23c8ceccd..a925e9835a 100644 --- a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing Deno/TypeScript scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # TypeScript (Deno) 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 04f6a3fdec..1df6392db9 100644 --- a/system_prompts/auto-generated/skills/write-script-duckdb/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-duckdb/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing DuckDB queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # DuckDB diff --git a/system_prompts/auto-generated/skills/write-script-go/SKILL.md b/system_prompts/auto-generated/skills/write-script-go/SKILL.md index ff6b1c490c..894a1dd791 100644 --- a/system_prompts/auto-generated/skills/write-script-go/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-go/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing Go scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # Go diff --git a/system_prompts/auto-generated/skills/write-script-graphql/SKILL.md b/system_prompts/auto-generated/skills/write-script-graphql/SKILL.md index 452a1d4734..0749cc47ef 100644 --- a/system_prompts/auto-generated/skills/write-script-graphql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-graphql/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing GraphQL queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # GraphQL diff --git a/system_prompts/auto-generated/skills/write-script-java/SKILL.md b/system_prompts/auto-generated/skills/write-script-java/SKILL.md index facc50899e..811fa875ef 100644 --- a/system_prompts/auto-generated/skills/write-script-java/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-java/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing Java scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # Java 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 58ea4982a2..f6bc5e008a 100644 --- a/system_prompts/auto-generated/skills/write-script-mssql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-mssql/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing MS SQL Server queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # Microsoft SQL Server (MSSQL) 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 8028fa6f1c..28ba025931 100644 --- a/system_prompts/auto-generated/skills/write-script-mysql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-mysql/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing MySQL queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # MySQL diff --git a/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md b/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md index 18eebcbc74..24d2c14440 100644 --- a/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing Native TypeScript scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # TypeScript (Native) diff --git a/system_prompts/auto-generated/skills/write-script-php/SKILL.md b/system_prompts/auto-generated/skills/write-script-php/SKILL.md index c2d02ff3bf..a8d6b2b0ab 100644 --- a/system_prompts/auto-generated/skills/write-script-php/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-php/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing PHP scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # PHP 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 df1fc6b5ca..ccb4654fff 100644 --- a/system_prompts/auto-generated/skills/write-script-postgresql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-postgresql/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing PostgreSQL queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # PostgreSQL diff --git a/system_prompts/auto-generated/skills/write-script-powershell/SKILL.md b/system_prompts/auto-generated/skills/write-script-powershell/SKILL.md index e54f3e647b..fefc379b12 100644 --- a/system_prompts/auto-generated/skills/write-script-powershell/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-powershell/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing PowerShell scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # PowerShell 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 15e459c241..5d6007df7a 100644 --- a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing Python scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # Python diff --git a/system_prompts/auto-generated/skills/write-script-rust/SKILL.md b/system_prompts/auto-generated/skills/write-script-rust/SKILL.md index 3dad884ebd..044cb9059a 100644 --- a/system_prompts/auto-generated/skills/write-script-rust/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-rust/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing Rust scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # Rust 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 54667c0432..24b8d06d4f 100644 --- a/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md @@ -5,10 +5,12 @@ description: MUST use when writing Snowflake queries. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types. # Snowflake diff --git a/system_prompts/base/flow-base.md b/system_prompts/base/flow-base.md index 513617693e..55d4c06b58 100644 --- a/system_prompts/base/flow-base.md +++ b/system_prompts/base/flow-base.md @@ -4,10 +4,12 @@ Create a folder ending with `.flow` and add a YAML file with the flow definition. For rawscript modules, use `!inline path/to/script.ts` for the content key. -After writing: +After writing, tell the user they can run: - `wmill flow generate-locks --yes` - Generate lock files for the specific flow you modified (e.g. `wmill flow generate-locks f/my_folder/my_flow.flow --yes`) - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + ## OpenFlow Schema The OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions. diff --git a/system_prompts/base/raw-app.md b/system_prompts/base/raw-app.md index 0dbcac9462..5d68232eda 100644 --- a/system_prompts/base/raw-app.md +++ b/system_prompts/base/raw-app.md @@ -79,7 +79,7 @@ export async function main(user_id: string) { } ``` -After creating, generate lock files: +After creating, tell the user they can generate lock files by running: ```bash wmill app generate-locks ``` @@ -232,6 +232,8 @@ data: ## CLI Commands +Tell the user they can run these commands (do NOT run them yourself): + | Command | Description | |---------|-------------| | `wmill app new` | Create a new raw app interactively | @@ -248,4 +250,4 @@ data: 3. **Keep runnables focused** - one function per file 4. **Use descriptive IDs** - `get_user.ts` not `a.ts` 5. **Always whitelist tables** - add to `data.tables` before querying -6. **Generate locks** - run `wmill app generate-locks` after adding/modifying backend runnables +6. **Generate locks** - tell the user to run `wmill app generate-locks` after adding/modifying backend runnables diff --git a/system_prompts/base/resources.md b/system_prompts/base/resources.md index 290d6f617b..0f51f6d322 100644 --- a/system_prompts/base/resources.md +++ b/system_prompts/base/resources.md @@ -237,6 +237,6 @@ wmill resource-type list --schema # Get specific resource type schema wmill resource-type get postgresql -# Push resources +# Push resources (tell the user to run this, do NOT run it yourself) wmill sync push ``` diff --git a/system_prompts/base/schedules.md b/system_prompts/base/schedules.md index bf14d24cbd..8e50fb87a6 100644 --- a/system_prompts/base/schedules.md +++ b/system_prompts/base/schedules.md @@ -34,6 +34,8 @@ Windmill uses 6-field cron expressions (includes seconds): ## CLI Commands +After writing, tell the user they can run these commands (do NOT run them yourself): + ```bash # Push schedules to Windmill wmill sync push diff --git a/system_prompts/base/triggers.md b/system_prompts/base/triggers.md index 4998b85342..5205eb2d4f 100644 --- a/system_prompts/base/triggers.md +++ b/system_prompts/base/triggers.md @@ -13,6 +13,8 @@ Examples: ## CLI Commands +After writing, tell the user they can run these commands (do NOT run them yourself): + ```bash # Push trigger configuration wmill sync push diff --git a/system_prompts/generate.py b/system_prompts/generate.py index 0af7fe0eb6..122b85e61a 100644 --- a/system_prompts/generate.py +++ b/system_prompts/generate.py @@ -763,10 +763,12 @@ def generate_skills( # CLI intro for script skills script_cli_intro = """## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - `wmill script generate-metadata` - Generate .script.yaml and .lock files - `wmill sync push` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use `wmill resource-type list --schema` to discover available resource types.""" skills_generated = [] From f3e9a29c13ac9420febccd0c1c4e7d5474f03a89 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 12 Mar 2026 12:28:17 +0000 Subject: [PATCH 017/204] add customUi props for History and Save to workspace editor bar buttons (#8336) Co-authored-by: Claude Opus 4.6 --- frontend/src/lib/components/EditorBar.svelte | 4 ++-- frontend/src/lib/components/custom_ui.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/EditorBar.svelte b/frontend/src/lib/components/EditorBar.svelte index cdaec4c44c..4ddbb70b19 100644 --- a/frontend/src/lib/components/EditorBar.svelte +++ b/frontend/src/lib/components/EditorBar.svelte @@ -1096,7 +1096,7 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
{@render right?.()} - {#if scriptPath && !noHistory} + {#if scriptPath && !noHistory && customUi?.history != false}
{#if !emptyString(repo.git_repo_resource_path)} diff --git a/frontend/src/lib/hub.ts b/frontend/src/lib/hub.ts index fcaa2ab832..a038f18580 100644 --- a/frontend/src/lib/hub.ts +++ b/frontend/src/lib/hub.ts @@ -92,7 +92,6 @@ export function rawAppToHubUrl(hubBaseUrl: string, summary?: string): URL { } type HubPaths = { - gitSync: string gitSyncTest: string gitInitRepo: string slackErrorHandler: string diff --git a/frontend/src/lib/hubPaths.json b/frontend/src/lib/hubPaths.json index 79bf362003..d62fffa097 100644 --- a/frontend/src/lib/hubPaths.json +++ b/frontend/src/lib/hubPaths.json @@ -1,28 +1,28 @@ { - "gitSync_0": "hub/9087/sync-script-to-git-repo-windmill", - "gitSync_1": "hub/9987/sync-script-to-git-repo-windmill", - "gitSync_2": "hub/11498/sync-script-to-git-repo-windmill", - "gitSync_3": "hub/11533/sync-script-to-git-repo-windmill", - "gitSync_4": "hub/11580/sync-script-to-git-repo-windmill", - "gitSync_5": "hub/11641/sync-script-to-git-repo-windmill", - "gitSync_6": "hub/11666/sync-script-to-git-repo-windmill", - "gitSync_7": "hub/11668/sync-script-to-git-repo-windmill", - "gitSync_8": "hub/19673/sync-script-to-git-repo-windmill", - "gitSync_9": "hub/19738/sync-script-to-git-repo-windmill", - "gitSync_10": "hub/19785/sync-script-to-git-repo-windmill", - "gitSync_11": "hub/19789/sync-script-to-git-repo-windmill", - "gitSync_12": "hub/19798/sync-script-to-git-repo-windmill", - "gitSync_13": "hub/19801/sync-script-to-git-repo-windmill", - "gitSync_14": "hub/19803/sync-script-to-git-repo-windmill", - "gitSync_15": "hub/19816/sync-script-to-git-repo-windmill", - "gitSync_16": "hub/19818/sync-script-to-git-repo-windmill", - "gitSync_17": "hub/28073/sync-script-to-git-repo-windmill", - "gitSync_18": "hub/28078/sync-script-to-git-repo-windmill", - "gitSync_19": "hub/28081/sync-script-to-git-repo-windmill", - "gitSync_20": "hub/28102/sync-script-to-git-repo-windmill", - "gitSync_21": "hub/28131/sync-script-to-git-repo-windmill", - "gitSync_22": "hub/28159/sync-script-to-git-repo-windmill", - "gitSync": "hub/28160/sync-script-to-git-repo-windmill", + "deprecated_gitSync_0": "hub/9087/sync-script-to-git-repo-windmill", + "deprecated_gitSync_1": "hub/9987/sync-script-to-git-repo-windmill", + "deprecated_gitSync_2": "hub/11498/sync-script-to-git-repo-windmill", + "deprecated_gitSync_3": "hub/11533/sync-script-to-git-repo-windmill", + "deprecated_gitSync_4": "hub/11580/sync-script-to-git-repo-windmill", + "deprecated_gitSync_5": "hub/11641/sync-script-to-git-repo-windmill", + "deprecated_gitSync_6": "hub/11666/sync-script-to-git-repo-windmill", + "deprecated_gitSync_7": "hub/11668/sync-script-to-git-repo-windmill", + "deprecated_gitSync_8": "hub/19673/sync-script-to-git-repo-windmill", + "deprecated_gitSync_9": "hub/19738/sync-script-to-git-repo-windmill", + "deprecated_gitSync_10": "hub/19785/sync-script-to-git-repo-windmill", + "deprecated_gitSync_11": "hub/19789/sync-script-to-git-repo-windmill", + "deprecated_gitSync_12": "hub/19798/sync-script-to-git-repo-windmill", + "deprecated_gitSync_13": "hub/19801/sync-script-to-git-repo-windmill", + "deprecated_gitSync_14": "hub/19803/sync-script-to-git-repo-windmill", + "deprecated_gitSync_15": "hub/19816/sync-script-to-git-repo-windmill", + "deprecated_gitSync_16": "hub/19818/sync-script-to-git-repo-windmill", + "deprecated_gitSync_17": "hub/28073/sync-script-to-git-repo-windmill", + "deprecated_gitSync_18": "hub/28078/sync-script-to-git-repo-windmill", + "deprecated_gitSync_19": "hub/28081/sync-script-to-git-repo-windmill", + "deprecated_gitSync_20": "hub/28102/sync-script-to-git-repo-windmill", + "deprecated_gitSync_21": "hub/28131/sync-script-to-git-repo-windmill", + "deprecated_gitSync_22": "hub/28159/sync-script-to-git-repo-windmill", + "deprecated_gitSync_latest": "hub/28160/sync-script-to-git-repo-windmill", "gitSyncTest": "hub/19799/git-repo-test-read-write-windmill", "gitInitRepo_0": "hub/28134/git-sync%3A-init-repository-windmill", "gitInitRepo": "hub/28158/git-sync%3A-init-repository-windmill", diff --git a/integration_tests/requirements.txt b/integration_tests/requirements.txt index 97572f1b84..35b4f13cf4 100644 --- a/integration_tests/requirements.txt +++ b/integration_tests/requirements.txt @@ -6,3 +6,4 @@ httpx==0.26.0 idna==3.6 sniffio==1.3.0 docker==7.1.0 +gitpython==3.1.43 diff --git a/integration_tests/test/git_sync_test.py b/integration_tests/test/git_sync_test.py new file mode 100644 index 0000000000..5268fefa01 --- /dev/null +++ b/integration_tests/test/git_sync_test.py @@ -0,0 +1,792 @@ +import os +import shutil +import tempfile +import time +import unittest +import uuid + +import git as gitpython + +from .wmill_integration_test_utils import WindmillClient, GiteaClient + + +# Script content template for bun/TypeScript scripts +def ts_script(body: str) -> str: + return f"export async function main() {{\n {body}\n}}\n" + + +def unique_name(prefix: str = "git-sync-test") -> str: + return f"{prefix}-{uuid.uuid4().hex[:8]}" + + +class TestGitSync(unittest.TestCase): + _client: WindmillClient + _gitea: GiteaClient + _repos_to_cleanup: list + _fork_workspaces_to_cleanup: list + + @classmethod + def setUpClass(cls) -> None: + print("Running {}".format(cls.__name__)) + cls._client = WindmillClient() + cls._gitea = GiteaClient() + cls._gitea.setup_admin() + cls._repos_to_cleanup = [] + cls._fork_workspaces_to_cleanup = [] + + @classmethod + def tearDownClass(cls) -> None: + # Disable git sync to avoid interfering with other tests + try: + cls._client.configure_git_sync({"repositories": []}) + except Exception as e: + print(f"Warning: failed to disable git sync: {e}") + + for fork_id in cls._fork_workspaces_to_cleanup: + try: + cls._client.delete_workspace(fork_id) + except Exception as e: + print(f"Warning: failed to delete fork workspace {fork_id}: {e}") + + for repo_name in cls._repos_to_cleanup: + cls._gitea.delete_repo(repo_name) + + def setUp(self): + """Wait for any pending deployment callbacks from previous tests to drain.""" + time.sleep(2) + # Wait until no new deployment callback jobs appear for 4 seconds + prev_count = self._client.count_deployment_callback_jobs() + for _ in range(3): + time.sleep(2) + cur_count = self._client.count_deployment_callback_jobs() + if cur_count == prev_count: + break + prev_count = cur_count + + def _create_test_repo(self) -> tuple: + """Create a Gitea repo and return (repo_name, docker_clone_url).""" + name = unique_name() + docker_url = self._gitea.create_repo(name) + self._repos_to_cleanup.append(name) + return name, docker_url + + def _setup_git_sync_resource(self, repo_name: str, branch: str = "main") -> str: + """Create a git_repository resource pointing to the Gitea repo. + Returns the resource path.""" + resource_path = f"u/admin/git_sync_{repo_name.replace('-', '_')}" + docker_url = self._gitea.get_docker_clone_url(repo_name) + self._client.create_resource( + path=resource_path, + resource_type="git_repository", + value={ + "url": docker_url, + "branch": branch, + "is_github_app": False, + }, + update_if_exists=True, + ) + return resource_path + + def _configure_single_repo_sync( + self, + resource_path: str, + include_type=None, + include_path=None, + use_individual_branch=False, + group_by_folder=False, + force_branch=None, + ): + """Configure git sync with a single repository (auto-managed script).""" + repo_settings = { + "git_repo_resource_path": f"$res:{resource_path}", + "use_individual_branch": use_individual_branch, + "group_by_folder": group_by_folder, + } + if force_branch: + repo_settings["force_branch"] = force_branch + if include_type or include_path: + repo_settings["settings"] = { + "include_type": include_type or [], + "include_path": include_path if include_path is not None else ["**"], + } + + self._client.configure_git_sync({ + "repositories": [repo_settings], + }) + + def _clone_repo(self, repo_name: str, branch: str = None) -> str: + """Clone the repo to a temp dir and return the path.""" + host_url = self._gitea.get_host_clone_url(repo_name) + tmp_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, tmp_dir, ignore_errors=True) + args = {} + if branch: + args["branch"] = branch + gitpython.Repo.clone_from(host_url, tmp_dir, **args) + return tmp_dir + + def _clone_repo_all_branches(self, repo_name: str) -> str: + """Clone the repo fetching all branches.""" + host_url = self._gitea.get_host_clone_url(repo_name) + tmp_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, tmp_dir, ignore_errors=True) + gitpython.Repo.clone_from(host_url, tmp_dir, no_single_branch=True) + return tmp_dir + + def _list_repo_files(self, repo_dir: str, branch: str = None) -> list: + """List all tracked files in the repo (relative paths).""" + repo = gitpython.Repo(repo_dir) + if branch: + commit = repo.refs[branch].commit + else: + commit = repo.head.commit + return [item.path for item in commit.tree.traverse()] + + def _read_file_content(self, repo_dir: str, file_path: str) -> str: + """Read a file's content from the repo working tree.""" + full_path = os.path.join(repo_dir, file_path) + with open(full_path, "r") as f: + return f.read() + + def _get_commit_count(self, repo_dir: str, branch: str = "main") -> int: + repo = gitpython.Repo(repo_dir) + return len(list(repo.iter_commits(branch))) + + def _get_last_commit_message(self, repo_dir: str, branch: str = "main") -> str: + repo = gitpython.Repo(repo_dir) + return repo.iter_commits(branch).__next__().message + + def _get_branches(self, repo_dir: str) -> list: + repo = gitpython.Repo(repo_dir) + return [ref.name for ref in repo.remote().refs] + + def _create_folder(self, folder_name: str): + """Create a folder in the workspace, ignoring errors if it already exists.""" + try: + self._client._client.post( + f"/api/w/{self._client._workspace}/folders/create", + json={"name": folder_name}, + ) + except Exception: + pass + + # ────────────────────────────────────────────────── + # Core happy-path tests + # ────────────────────────────────────────────────── + + def test_script_deploy_syncs_to_git(self): + """Deploy a script and verify it appears in the git repo with correct content.""" + repo_name, _ = self._create_test_repo() + resource_path = self._setup_git_sync_resource(repo_name) + self._configure_single_repo_sync( + resource_path, + include_type=["script"], + ) + + initial_count = self._client.count_deployment_callback_jobs() + + script_path = f"u/admin/{unique_name('sync_test')}" + self._client.create_script( + path=script_path, + content=ts_script("return 42"), + language="bun", + ) + + self._client.wait_for_sync_jobs(initial_count, min_new=1) + time.sleep(3) + + repo_dir = self._clone_repo(repo_name) + files = self._list_repo_files(repo_dir) + + # The script should appear in the repo + matching = [f for f in files if script_path in f] + self.assertTrue( + len(matching) > 0, + f"Expected script '{script_path}' in repo files: {files}", + ) + + # Verify file content matches what we deployed + script_file = [f for f in matching if f.endswith(".ts")][0] + content = self._read_file_content(repo_dir, script_file) + self.assertIn( + "return 42", + content, + f"Expected 'return 42' in script content: {content}", + ) + + def test_multi_repo_routing(self): + """Two repos with different path filters receive the correct objects.""" + repo_name_a, _ = self._create_test_repo() + repo_name_b, _ = self._create_test_repo() + res_path_a = self._setup_git_sync_resource(repo_name_a) + res_path_b = self._setup_git_sync_resource(repo_name_b) + + folder_a = unique_name("folder_a") + folder_b = unique_name("folder_b") + + self._client.configure_git_sync({ + "repositories": [ + { + "git_repo_resource_path": f"$res:{res_path_a}", + "use_individual_branch": False, + "group_by_folder": False, + "settings": { + "include_type": ["script"], + "include_path": [f"f/{folder_a}/**"], + }, + }, + { + "git_repo_resource_path": f"$res:{res_path_b}", + "use_individual_branch": False, + "group_by_folder": False, + "settings": { + "include_type": ["script"], + "include_path": [f"f/{folder_b}/**"], + }, + }, + ], + }) + + self._create_folder(folder_a) + self._create_folder(folder_b) + + initial_count = self._client.count_deployment_callback_jobs() + + script_a = f"f/{folder_a}/script_a" + script_b = f"f/{folder_b}/script_b" + + self._client.create_script( + path=script_a, + content=ts_script("return 'a'"), + language="bun", + ) + self._client.create_script( + path=script_b, + content=ts_script("return 'b'"), + language="bun", + ) + + # Wait for at least 2 deployment callback jobs + self._client.wait_for_sync_jobs(initial_count, min_new=2) + time.sleep(3) + + # Verify repo A has script_a but not script_b + repo_dir_a = self._clone_repo(repo_name_a) + files_a = self._list_repo_files(repo_dir_a) + self.assertTrue( + any("script_a" in f for f in files_a), + f"Expected script_a in repo A files: {files_a}", + ) + self.assertFalse( + any("script_b" in f for f in files_a), + f"Did not expect script_b in repo A files: {files_a}", + ) + + # Verify repo B has script_b but not script_a + repo_dir_b = self._clone_repo(repo_name_b) + files_b = self._list_repo_files(repo_dir_b) + self.assertTrue( + any("script_b" in f for f in files_b), + f"Expected script_b in repo B files: {files_b}", + ) + self.assertFalse( + any("script_a" in f for f in files_b), + f"Did not expect script_a in repo B files: {files_b}", + ) + + def test_script_update_creates_new_commit_with_updated_content(self): + """Updating a script should produce a new commit with the new content.""" + repo_name, _ = self._create_test_repo() + resource_path = self._setup_git_sync_resource(repo_name) + self._configure_single_repo_sync(resource_path, include_type=["script"]) + + script_path = f"u/admin/{unique_name('update_test')}" + + # Create initial script + initial_count = self._client.count_deployment_callback_jobs() + self._client.create_script( + path=script_path, + content=ts_script("return 1"), + language="bun", + ) + self._client.wait_for_sync_jobs(initial_count, min_new=1) + time.sleep(3) + + repo_dir = self._clone_repo(repo_name) + initial_commits = self._get_commit_count(repo_dir) + + # Update the script + update_count = self._client.count_deployment_callback_jobs() + self._client.update_script( + path=script_path, + content=ts_script("return 2"), + language="bun", + ) + self._client.wait_for_sync_jobs(update_count, min_new=1) + time.sleep(3) + + # Re-clone and check commit count increased + repo_dir2 = self._clone_repo(repo_name) + new_commits = self._get_commit_count(repo_dir2) + self.assertGreater( + new_commits, + initial_commits, + f"Expected more commits after update: {new_commits} vs {initial_commits}", + ) + + # Verify file content reflects the update + files = self._list_repo_files(repo_dir2) + script_file = [f for f in files if script_path in f and f.endswith(".ts")][0] + content = self._read_file_content(repo_dir2, script_file) + self.assertIn( + "return 2", + content, + f"Expected 'return 2' in updated script content: {content}", + ) + self.assertNotIn( + "return 1", + content, + f"Did not expect 'return 1' in updated script content: {content}", + ) + + def test_deploy_multiple_object_types(self): + """Deploy a script, flow, and variable and verify all appear in the repo.""" + repo_name, _ = self._create_test_repo() + resource_path = self._setup_git_sync_resource(repo_name) + self._configure_single_repo_sync( + resource_path, + include_type=["script", "flow", "variable"], + ) + + initial_count = self._client.count_deployment_callback_jobs() + + suffix = unique_name("multi") + script_path = f"u/admin/{suffix}_script" + flow_path = f"u/admin/{suffix}_flow" + var_path = f"u/admin/{suffix}_var" + + self._client.create_script( + path=script_path, + content=ts_script("return 'multi'"), + language="bun", + ) + self._client.create_flow( + path=flow_path, + flow_value_json="""{ + "summary": "test flow", + "value": { + "modules": [{ + "id": "a", + "value": { + "type": "rawscript", + "content": "export async function main() { return 1 }", + "language": "bun", + "input_transforms": {}, + "tag": "" + } + }] + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": {}, + "required": [], + "type": "object", + "order": [] + } + }""", + ) + self._client.create_variable( + path=var_path, + value="test_value", + ) + + # Wait for 3 deployment callbacks (one per object) + self._client.wait_for_sync_jobs(initial_count, min_new=3) + time.sleep(3) + + repo_dir = self._clone_repo(repo_name) + files = self._list_repo_files(repo_dir) + files_str = "\n".join(files) + + self.assertTrue( + any(suffix + "_script" in f for f in files), + f"Expected script in repo:\n{files_str}", + ) + self.assertTrue( + any(suffix + "_flow" in f for f in files), + f"Expected flow in repo:\n{files_str}", + ) + self.assertTrue( + any(suffix + "_var" in f for f in files), + f"Expected variable in repo:\n{files_str}", + ) + + # ────────────────────────────────────────────────── + # Commit message verification + # ────────────────────────────────────────────────── + + def test_commit_message_format(self): + """Verify commit messages have the [WM] prefix.""" + repo_name, _ = self._create_test_repo() + resource_path = self._setup_git_sync_resource(repo_name) + self._configure_single_repo_sync(resource_path, include_type=["script"]) + + initial_count = self._client.count_deployment_callback_jobs() + script_path = f"u/admin/{unique_name('commit_msg')}" + self._client.create_script( + path=script_path, + content=ts_script("return 'msg'"), + language="bun", + ) + self._client.wait_for_sync_jobs(initial_count, min_new=1) + time.sleep(3) + + repo_dir = self._clone_repo(repo_name) + commit_msg = self._get_last_commit_message(repo_dir) + + self.assertTrue( + commit_msg.startswith("[WM]"), + f"Expected commit message to start with '[WM]', got: {commit_msg!r}", + ) + + # ────────────────────────────────────────────────── + # Rename handling + # ────────────────────────────────────────────────── + + def test_rename_removes_old_file(self): + """Renaming a script should remove the old file and create the new one.""" + repo_name, _ = self._create_test_repo() + resource_path = self._setup_git_sync_resource(repo_name) + self._configure_single_repo_sync(resource_path, include_type=["script"]) + + old_path = f"u/admin/{unique_name('rename_old')}" + + # Create initial script + initial_count = self._client.count_deployment_callback_jobs() + self._client.create_script( + path=old_path, + content=ts_script("return 'old'"), + language="bun", + ) + self._client.wait_for_sync_jobs(initial_count, min_new=1) + time.sleep(3) + + # Verify old script exists in repo + repo_dir = self._clone_repo(repo_name) + files = self._list_repo_files(repo_dir) + old_name = old_path.split("/")[-1] + self.assertTrue( + any(old_name in f for f in files), + f"Expected old script '{old_name}' in repo: {files}", + ) + + # Create new script at different path (simulates rename) + new_path = f"u/admin/{unique_name('rename_new')}" + rename_count = self._client.count_deployment_callback_jobs() + self._client.create_script( + path=new_path, + content=ts_script("return 'renamed'"), + language="bun", + ) + # Also delete the old script + self._client.delete_script(old_path) + # Wait for both create and delete deployment callbacks + self._client.wait_for_sync_jobs(rename_count, min_new=2) + time.sleep(3) + + # Verify new script exists + repo_dir2 = self._clone_repo(repo_name) + files2 = self._list_repo_files(repo_dir2) + new_name = new_path.split("/")[-1] + self.assertTrue( + any(new_name in f for f in files2), + f"Expected new script '{new_name}' in repo: {files2}", + ) + self.assertFalse( + any(old_name in f for f in files2), + f"Expected old script '{old_name}' to be removed: {files2}", + ) + + # ────────────────────────────────────────────────── + # Promotion mode (individual branches) + # ────────────────────────────────────────────────── + + def test_promotion_mode_creates_per_object_branches(self): + """In promotion mode (use_individual_branch=True), each deploy creates + a branch named wm_deploy/{workspace}/{path_type}/{path} with the content + on that branch, not on main.""" + repo_name, _ = self._create_test_repo() + resource_path = self._setup_git_sync_resource(repo_name) + self._configure_single_repo_sync( + resource_path, + include_type=["script"], + use_individual_branch=True, + ) + + initial_count = self._client.count_deployment_callback_jobs() + script_path = f"u/admin/{unique_name('promo')}" + self._client.create_script( + path=script_path, + content=ts_script("return 'promotion'"), + language="bun", + ) + self._client.wait_for_sync_jobs(initial_count, min_new=1) + time.sleep(3) + + # Clone with all branches + repo_dir = self._clone_repo_all_branches(repo_name) + branches = self._get_branches(repo_dir) + + # Should have a branch matching wm_deploy pattern + wm_branches = [b for b in branches if "wm_deploy/" in b] + self.assertTrue( + len(wm_branches) > 0, + f"Expected wm_deploy/ branch, got branches: {branches}", + ) + + # The branch name should contain 'script' (the path_type) + deploy_branch = wm_branches[0] + self.assertIn( + "script", + deploy_branch, + f"Expected 'script' in branch name: {deploy_branch}", + ) + + # The script path (with / replaced by __) should appear in the branch name + script_name = script_path.split("/")[-1] + self.assertIn( + script_name, + deploy_branch.replace("/", "__"), + f"Expected script name '{script_name}' in branch: {deploy_branch}", + ) + + # Verify main branch does NOT have the script + main_files = self._list_repo_files(repo_dir, branch="origin/main") + self.assertFalse( + any(script_name in f for f in main_files), + f"Did not expect script on main branch, but found it: {main_files}", + ) + + # Verify the deploy branch HAS the script + local_branch_name = deploy_branch.replace("origin/", "") + repo = gitpython.Repo(repo_dir) + repo.git.checkout(local_branch_name) + branch_files = self._list_repo_files(repo_dir) + self.assertTrue( + any(script_name in f for f in branch_files), + f"Expected script on deploy branch '{local_branch_name}': {branch_files}", + ) + + def test_promotion_mode_group_by_folder(self): + """With use_individual_branch=True and group_by_folder=True, the branch name + uses the folder prefix (first 2 path segments joined by __) instead of the + full path.""" + repo_name, _ = self._create_test_repo() + resource_path = self._setup_git_sync_resource(repo_name) + self._configure_single_repo_sync( + resource_path, + include_type=["script"], + use_individual_branch=True, + group_by_folder=True, + ) + + folder_name = unique_name("grp") + self._create_folder(folder_name) + + initial_count = self._client.count_deployment_callback_jobs() + script_path = f"f/{folder_name}/{unique_name('grp_script')}" + self._client.create_script( + path=script_path, + content=ts_script("return 'grouped'"), + language="bun", + ) + self._client.wait_for_sync_jobs(initial_count, min_new=1) + time.sleep(3) + + repo_dir = self._clone_repo_all_branches(repo_name) + branches = self._get_branches(repo_dir) + + wm_branches = [b for b in branches if "wm_deploy/" in b] + self.assertTrue( + len(wm_branches) > 0, + f"Expected wm_deploy/ branch with group_by_folder: {branches}", + ) + + # With group_by_folder, the branch should contain the folder prefix + # format: wm_deploy/{workspace}/f__{folder_name} + deploy_branch = wm_branches[0] + expected_folder_part = f"f__{folder_name}" + self.assertIn( + expected_folder_part, + deploy_branch, + f"Expected folder-grouped branch name containing '{expected_folder_part}', got: {deploy_branch}", + ) + + # ────────────────────────────────────────────────── + # force_branch with wmill.yaml + # ────────────────────────────────────────────────── + + def test_force_branch_with_wmill_yaml(self): + """force_branch passes --branch to wmill sync pull, which selects the + matching gitBranches config from wmill.yaml. With branch-specific variables + configured, the variable file should use a branch-specific path.""" + repo_name, _ = self._create_test_repo() + resource_path = self._setup_git_sync_resource(repo_name) + + # Push a wmill.yaml to the repo that configures branch-specific variables + wmill_yaml_content = """\ +includes: + - "**" +gitBranches: + staging: + specificItems: + variables: + - "**" +""" + self._gitea.create_file(repo_name, "wmill.yaml", wmill_yaml_content) + + self._configure_single_repo_sync( + resource_path, + include_type=["variable"], + force_branch="staging", + ) + + initial_count = self._client.count_deployment_callback_jobs() + var_path = f"u/admin/{unique_name('env_var')}" + self._client.create_variable( + path=var_path, + value="staging_value", + ) + self._client.wait_for_sync_jobs(initial_count, min_new=1) + time.sleep(3) + + repo_dir = self._clone_repo(repo_name) + files = self._list_repo_files(repo_dir) + + var_name = var_path.split("/")[-1] + + # With force_branch="staging" and specificItems for variables, + # the variable file should have ".staging." in its name + staging_files = [f for f in files if var_name in f and ".staging." in f] + self.assertTrue( + len(staging_files) > 0, + f"Expected variable file with '.staging.' in name for branch-specific item, " + f"got files: {files}", + ) + + # ────────────────────────────────────────────────── + # Exclude path filtering + # ────────────────────────────────────────────────── + + def test_exclude_path_filtering(self): + """Scripts in excluded paths should not be synced to the repo.""" + repo_name, _ = self._create_test_repo() + resource_path = self._setup_git_sync_resource(repo_name) + + folder_inc = unique_name("inc") + folder_exc = unique_name("exc") + self._create_folder(folder_inc) + self._create_folder(folder_exc) + + self._client.configure_git_sync({ + "repositories": [{ + "git_repo_resource_path": f"$res:{resource_path}", + "use_individual_branch": False, + "group_by_folder": False, + "settings": { + "include_type": ["script"], + "include_path": ["f/**"], + "exclude_path": [f"f/{folder_exc}/**"], + }, + }], + }) + + initial_count = self._client.count_deployment_callback_jobs() + + script_inc = f"f/{folder_inc}/included_script" + script_exc = f"f/{folder_exc}/excluded_script" + + self._client.create_script( + path=script_inc, + content=ts_script("return 'included'"), + language="bun", + ) + self._client.create_script( + path=script_exc, + content=ts_script("return 'excluded'"), + language="bun", + ) + + # Only 1 sync job expected (the excluded one should not trigger) + self._client.wait_for_sync_jobs(initial_count, min_new=1) + time.sleep(5) + # Verify no extra sync jobs arrived for the excluded script + final_count = self._client.count_deployment_callback_jobs() + self.assertEqual( + final_count, initial_count + 1, + f"Expected exactly 1 new sync job, got {final_count - initial_count}", + ) + + repo_dir = self._clone_repo(repo_name) + files = self._list_repo_files(repo_dir) + + self.assertTrue( + any("included_script" in f for f in files), + f"Expected included_script in repo: {files}", + ) + self.assertFalse( + any("excluded_script" in f for f in files), + f"Did not expect excluded_script in repo: {files}", + ) + + # ────────────────────────────────────────────────── + # Workspace fork + # ────────────────────────────────────────────────── + + def test_workspace_fork_creates_branch(self): + """Forking a workspace with git sync configured should create a + fork branch in the git repo.""" + repo_name, _ = self._create_test_repo() + resource_path = self._setup_git_sync_resource(repo_name) + + # Configure git sync on the parent workspace (sync mode, not promotion) + self._configure_single_repo_sync( + resource_path, + include_type=["script"], + ) + + # Deploy a script first so there's content in the repo + initial_count = self._client.count_deployment_callback_jobs() + script_path = f"u/admin/{unique_name('fork_base')}" + self._client.create_script( + path=script_path, + content=ts_script("return 'base'"), + language="bun", + ) + self._client.wait_for_sync_jobs(initial_count, min_new=1) + time.sleep(3) + + # Create workspace fork + fork_id = f"wm-fork-{uuid.uuid4().hex[:8]}" + fork_name = f"Fork {fork_id}" + self._fork_workspaces_to_cleanup.append(fork_id) + + # Step 1: Create git branches for the fork + job_ids = self._client.create_workspace_fork_branch(fork_id, fork_name) + if job_ids: + self._client.wait_for_jobs_by_ids(job_ids, timeout=90) + time.sleep(3) + + # Step 2: Create the fork workspace + self._client.create_workspace_fork(fork_id, fork_name) + + # Verify a fork branch was created in the git repo + repo_dir = self._clone_repo_all_branches(repo_name) + branches = self._get_branches(repo_dir) + + # Fork branches are named: wm-fork/{original_branch}/{fork_id} + fork_branches = [b for b in branches if "wm-fork" in b] + self.assertTrue( + len(fork_branches) > 0, + f"Expected a wm-fork branch in the repo after forking, got: {branches}", + ) diff --git a/integration_tests/test/wmill_integration_test_utils.py b/integration_tests/test/wmill_integration_test_utils.py index 260986ef9b..93730f0d31 100644 --- a/integration_tests/test/wmill_integration_test_utils.py +++ b/integration_tests/test/wmill_integration_test_utils.py @@ -3,6 +3,7 @@ import time import httpx import json import os +import uuid @@ -13,9 +14,11 @@ class WindmillClient: _client: httpx.Client - def __init__(self): - self._workspace = "integration-tests" - self._url = "http://localhost:8000" + def __init__(self, workspace: str = "integration-tests", url: str = None): + if url is None: + url = os.environ.get("WINDMILL_BASE_URL", "http://localhost:8000") + self._workspace = workspace + self._url = url self._token = self._login() self._client = self._init_client() @@ -155,6 +158,55 @@ class WindmillClient: time.sleep(1) raise Exception(f"Script deployment failed for {path}") + def update_script(self, path: str, content: str, language: str, tag: str = None): + """Update an existing script by creating a new version with parent_hash.""" + print(f"Updating script {path}") + + # Get current script hash + response = self._client.get( + f"/api/w/{self._workspace}/scripts/get/p/{path}" + ) + if response.status_code // 100 != 2: + raise Exception(response.content.decode()) + current_hash = response.json()["hash"] + + payload = { + "path": path, + "content": content, + "description": "", + "summary": "", + "language": language, + "parent_hash": current_hash, + } + + if tag is not None: + payload["tag"] = tag + + response = self._client.post( + f"/api/w/{self._workspace}/scripts/create", + json=payload, + ) + if response.status_code // 100 != 2: + raise Exception(response.content.decode()) + script_hash = response.content.decode() + print(f"Script hash for path {path} is {script_hash}") + time_now = datetime.datetime.now(datetime.timezone.utc) + while datetime.datetime.now( + datetime.timezone.utc + ) - time_now < datetime.timedelta(seconds=60): + response = self._client.get( + f"/api/w/{self._workspace}/scripts/deployment_status/h/{script_hash}" + ) + if response.status_code // 100 != 2: + raise Exception(response.content.decode()) + elif response.json()["lock"] != None: + return + elif response.json()["lock_error_logs"] != None: + raise Exception(response.json()["lock_error_logs"]) + print(f"Waiting for script {path} with hash {script_hash} to be deployed") + time.sleep(1) + raise Exception(f"Script deployment failed for {path}") + def delete_script(self, path: str): print(f"Deleting script {path}") response = self._client.post( @@ -385,6 +437,120 @@ class WindmillClient: print(f"Exception when retrieving workers list: {e}") return [] + def create_resource(self, path: str, resource_type: str, value: dict, update_if_exists: bool = False): + print(f"Creating resource {path} of type {resource_type}") + params = {} + if update_if_exists: + params["update_if_exists"] = "true" + response = self._client.post( + f"/api/w/{self._workspace}/resources/create", + json={ + "path": path, + "value": value, + "resource_type": resource_type, + "description": "", + }, + params=params, + ) + if response.status_code // 100 != 2: + raise Exception(response.content.decode()) + return response.content.decode() + + def configure_git_sync(self, git_sync_settings: dict): + print(f"Configuring git sync with {len(git_sync_settings.get('repositories', []))} repositories") + response = self._client.post( + f"/api/w/{self._workspace}/workspaces/edit_git_sync_config", + json={"git_sync_settings": git_sync_settings}, + ) + if response.status_code // 100 != 2: + raise Exception(response.content.decode()) + return response.content.decode() + + def get_completed_jobs(self, job_kinds: str = None, success: bool = None): + params = {"per_page": 1000} + if job_kinds: + params["job_kinds"] = job_kinds + if success is not None: + params["success"] = str(success).lower() + response = self._client.get( + f"/api/w/{self._workspace}/jobs/completed/list", + params=params, + ) + if response.status_code // 100 != 2: + raise Exception(response.content.decode()) + return response.json() + + def wait_for_sync_jobs(self, initial_count: int, min_new: int = 1, timeout: int = 90) -> list: + """Poll completed DeploymentCallback jobs until count increases by min_new.""" + start = time.time() + current_count = initial_count + while time.time() - start < timeout: + jobs = self.get_completed_jobs(job_kinds="deploymentcallback") + current_count = len(jobs) + if current_count >= initial_count + min_new: + return jobs + time.sleep(2) + raise TimeoutError( + f"Timed out waiting for sync jobs: expected {initial_count + min_new}, " + f"got {current_count} after {timeout}s" + ) + + def count_deployment_callback_jobs(self) -> int: + jobs = self.get_completed_jobs(job_kinds="deploymentcallback") + return len(jobs) + + def create_workspace_fork_branch(self, fork_id: str, fork_name: str) -> list: + """Create git branches for a workspace fork. Returns list of job UUIDs to wait on.""" + print(f"Creating fork branch for {fork_id} from {self._workspace}") + response = self._client.post( + f"/api/w/{self._workspace}/workspaces/create_workspace_fork_branch", + json={"id": fork_id, "name": fork_name}, + ) + if response.status_code // 100 != 2: + raise Exception(response.content.decode()) + return response.json() + + def create_workspace_fork(self, fork_id: str, fork_name: str) -> str: + """Create a forked workspace (call after fork branch jobs complete).""" + print(f"Creating fork workspace {fork_id} from {self._workspace}") + response = self._client.post( + f"/api/w/{self._workspace}/workspaces/create_fork", + json={"id": fork_id, "name": fork_name}, + ) + if response.status_code // 100 != 2: + raise Exception(response.content.decode()) + return response.content.decode() + + def wait_for_jobs_by_ids(self, job_ids: list, timeout: int = 90): + """Wait for specific jobs (by UUID) to complete.""" + start = time.time() + while time.time() - start < timeout: + all_done = True + for job_id in job_ids: + response = self._client.get( + f"/api/w/{self._workspace}/jobs_u/get/{job_id}", + ) + if response.status_code // 100 != 2: + all_done = False + break + job = response.json() + if job.get("type") != "CompletedJob": + all_done = False + break + if all_done: + return + time.sleep(2) + raise TimeoutError(f"Timed out waiting for jobs {job_ids} after {timeout}s") + + def delete_workspace(self, workspace_id: str): + """Delete a workspace.""" + print(f"Deleting workspace {workspace_id}") + response = self._client.post( + f"/api/w/{workspace_id}/workspaces/delete", + ) + if response.status_code // 100 != 2 and response.status_code != 404: + print(f"Warning: failed to delete workspace {workspace_id}: {response.content.decode()}") + def create_agent_token(self, worker_group="agent", tags=None, exp=None): """ Create an agent JWT token using superadmin privilege. @@ -418,3 +584,163 @@ class WindmillClient: token = response.content.decode().strip('"') print(f"Created agent token: {token}") return token + + +GITEA_HOST_URL = os.environ.get("GITEA_HOST_URL", "http://localhost:3000") +GITEA_DOCKER_URL = os.environ.get("GITEA_DOCKER_URL", "http://gitea:3000") +GITEA_ADMIN_USER = "windmill" +GITEA_ADMIN_PASSWORD = "password123!" +GITEA_ADMIN_EMAIL = "windmill@windmill.dev" + + +class GiteaClient: + _host_url: str + _docker_url: str + _token: str + + def __init__(self): + self._host_url = GITEA_HOST_URL + self._docker_url = GITEA_DOCKER_URL + self._token = None + + def setup_admin(self): + """Create the admin user in Gitea (idempotent) and get an API token.""" + with httpx.Client(base_url=self._host_url, timeout=30.0) as client: + # Create admin user (ignore 422 if exists) + resp = client.post( + "/api/v1/admin/users", + json={ + "username": GITEA_ADMIN_USER, + "password": GITEA_ADMIN_PASSWORD, + "email": GITEA_ADMIN_EMAIL, + "must_change_password": False, + "visibility": "public", + }, + headers={"Content-Type": "application/json"}, + auth=(GITEA_ADMIN_USER, GITEA_ADMIN_PASSWORD), + ) + if resp.status_code == 201: + print(f"Created Gitea admin user '{GITEA_ADMIN_USER}'") + elif resp.status_code == 422: + print(f"Gitea admin user '{GITEA_ADMIN_USER}' already exists") + elif resp.status_code == 401: + # Admin user doesn't exist yet; use the Gitea setup API + resp2 = client.post( + "/user/sign_up", + data={ + "user_name": GITEA_ADMIN_USER, + "password": GITEA_ADMIN_PASSWORD, + "retype": GITEA_ADMIN_PASSWORD, + "email": GITEA_ADMIN_EMAIL, + }, + ) + if resp2.status_code // 100 != 2 and resp2.status_code != 303: + # Try the API endpoint for creating the first user + resp3 = client.post( + "/api/v1/admin/users", + json={ + "username": GITEA_ADMIN_USER, + "password": GITEA_ADMIN_PASSWORD, + "email": GITEA_ADMIN_EMAIL, + "must_change_password": False, + }, + ) + if resp3.status_code // 100 != 2: + raise Exception(f"Failed to create Gitea user: {resp3.status_code} {resp3.text}") + print(f"Created Gitea admin user '{GITEA_ADMIN_USER}' via signup") + else: + raise Exception(f"Failed to create Gitea user: {resp.status_code} {resp.text}") + + # Create API token + token_name = f"integration-test-{uuid.uuid4().hex[:8]}" + resp = client.post( + f"/api/v1/users/{GITEA_ADMIN_USER}/tokens", + json={"name": token_name, "scopes": ["all"]}, + auth=(GITEA_ADMIN_USER, GITEA_ADMIN_PASSWORD), + ) + if resp.status_code // 100 != 2: + raise Exception(f"Failed to create Gitea token: {resp.status_code} {resp.text}") + self._token = resp.json()["sha1"] + print(f"Created Gitea API token: {token_name}") + + def _headers(self): + return { + "Authorization": f"token {self._token}", + "Content-Type": "application/json", + } + + def create_repo(self, name: str) -> str: + """Create a repo and return the docker-internal clone URL with credentials.""" + with httpx.Client(base_url=self._host_url, timeout=30.0) as client: + resp = client.post( + "/api/v1/user/repos", + json={ + "name": name, + "auto_init": True, + "default_branch": "main", + "private": False, + }, + headers=self._headers(), + ) + if resp.status_code // 100 != 2: + raise Exception(f"Failed to create repo {name}: {resp.status_code} {resp.text}") + print(f"Created Gitea repo: {name}") + return f"{self._docker_url}/{GITEA_ADMIN_USER}/{name}.git" + + def get_host_clone_url(self, name: str) -> str: + """Return host-accessible clone URL with credentials.""" + from urllib.parse import urlparse + parsed = urlparse(self._host_url) + return f"http://{GITEA_ADMIN_USER}:{GITEA_ADMIN_PASSWORD}@{parsed.netloc}/{GITEA_ADMIN_USER}/{name}.git" + + def get_docker_clone_url(self, name: str) -> str: + """Return clone URL accessible from the Windmill backend (docker or local).""" + from urllib.parse import urlparse + parsed = urlparse(self._docker_url) + return f"http://{GITEA_ADMIN_USER}:{GITEA_ADMIN_PASSWORD}@{parsed.netloc}/{GITEA_ADMIN_USER}/{name}.git" + + def create_file(self, repo_name: str, file_path: str, content: str, branch: str = "main"): + """Create or update a file in the repo via Gitea API.""" + import base64 + encoded = base64.b64encode(content.encode()).decode() + with httpx.Client(base_url=self._host_url, timeout=30.0) as client: + # Check if file exists (to get SHA for update) + resp = client.get( + f"/api/v1/repos/{GITEA_ADMIN_USER}/{repo_name}/contents/{file_path}", + params={"ref": branch}, + headers=self._headers(), + ) + body = { + "content": encoded, + "message": f"Add {file_path}", + "branch": branch, + } + if resp.status_code == 200: + # File exists — update with PUT + body["sha"] = resp.json()["sha"] + resp = client.put( + f"/api/v1/repos/{GITEA_ADMIN_USER}/{repo_name}/contents/{file_path}", + json=body, + headers=self._headers(), + ) + else: + # File doesn't exist — create with POST + resp = client.post( + f"/api/v1/repos/{GITEA_ADMIN_USER}/{repo_name}/contents/{file_path}", + json=body, + headers=self._headers(), + ) + if resp.status_code // 100 != 2: + raise Exception(f"Failed to create file {file_path}: {resp.status_code} {resp.text}") + print(f"Created file {file_path} in {repo_name}") + + def delete_repo(self, name: str): + with httpx.Client(base_url=self._host_url, timeout=30.0) as client: + resp = client.delete( + f"/api/v1/repos/{GITEA_ADMIN_USER}/{name}", + headers=self._headers(), + ) + if resp.status_code // 100 == 2 or resp.status_code == 404: + print(f"Deleted Gitea repo: {name}") + else: + print(f"Warning: failed to delete repo {name}: {resp.status_code}") From ec20d76216492086842c4f5e4e3b36727a5631e9 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 12 Mar 2026 15:00:30 +0100 Subject: [PATCH 019/204] feat: add auto_commit option to Kafka triggers with advanced UI badges (#8317) * feat: add auto_commit option to Kafka triggers with manual commit API Add ability to disable auto-commit on Kafka triggers so users can manually commit offsets after processing messages. This prevents message loss when processing fails. Changes: - Add `auto_commit` column to kafka_trigger table (default true) - Add POST /kafka_triggers/commit_offsets/{path} endpoint using BaseConsumer with manual assign() to avoid rebalance - Enrich trigger_info payload with partition and offset fields - Conditionally commit based on auto_commit setting - Add auto-commit toggle to frontend Kafka trigger config - Add commitKafkaOffsets helpers to Python and TypeScript SDKs - Add integration tests for auto_commit DB defaults Co-Authored-By: Claude Opus 4.6 * feat: use DB-based pending commits for kafka manual offset commit Co-Authored-By: Claude Opus 4.5 * feat: pass trigger_path to all v2 preprocessors, secure commit_offsets endpoint, fix commit semantics - Add trigger_path to v2 preprocessor event for all trigger types (kafka, nats, sqs, mqtt, gcp, postgres, websocket, http, email) - Secure commit_offsets endpoint: infer trigger from job token (OptJobAuthed) instead of requiring trigger path parameter - Fix auto_commit: only commit offset after successful job push - Fix pending commits: commit offset+1 (Kafka semantics) and use CommitMode::Sync - Update TS/Python clients and frontend preprocessor templates Co-Authored-By: Claude Opus 4.6 * feat: add advanced section badges and reorganize kafka trigger settings Co-Authored-By: Claude Opus 4.6 * fix: remove dead wm_trigger assertions from kafka e2e test Co-Authored-By: Claude Opus 4.6 * sqlx * refactor: remove unused advancedCollapsed state from all trigger editors Co-Authored-By: Claude Opus 4.6 * update ref * chore: update ee-repo-ref to ed2c9d360e6fab866b9744cc79f50038d1fc7152 This commit updates the EE repository reference after PR #452 was merged in windmill-ee-private. Previous ee-repo-ref: 5b31116a1d5a042c6a780732901cfd89584d1773 New ee-repo-ref: ed2c9d360e6fab866b9744cc79f50038d1fc7152 Automated by sync-ee-ref workflow. * fix: use path-based auth for kafka commit_offsets endpoint Co-Authored-By: Claude Opus 4.6 * chore: update ee-repo-ref to fcd3ea52b0cc94fbe1159baf662a38da947456de This commit updates the EE repository reference after PR #457 was merged in windmill-ee-private. Previous ee-repo-ref: b3a5c33c92cb1b2caf7a65986d71da291ff72a35 New ee-repo-ref: fcd3ea52b0cc94fbe1159baf662a38da947456de Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: windmill-internal-app[bot] Co-authored-by: Ruben Fiszel --- ...7fa3ab402e33edfca46daa95b52e525424586.json | 41 +++++++ ...cb24cc635836276ee6faa2438f494bfbce04.json} | 5 +- ...68554a6a4291426c09dd5b04fd832f9d31208.json | 23 ++++ ...e81abb3ec627578f2d14f0ce33896dc6ab4cf.json | 23 ++++ ...623b991c938e40d0db25290b03dc0577c2740.json | 23 ++++ ...c7e60d23e9827f23c0de5b546ed56697d54a.json} | 22 +++- ...55c13a18dfd26e237a8218d3006e2898b5aa3.json | 23 ++++ ...920837354d91a0ae16b1236c1267da71765f.json} | 5 +- ...15565490a271050dcc4b5aac2b730ae3a55b9.json | 14 +++ ...927c07832badb2c9145d5996b829163bdf7d9.json | 23 ++++ ...c047cf3b7ce22ce868db1b1a63e6c10d0d12b.json | 15 +++ ...fcbb5b647737e0366529a2f418218b1a74679.json | 18 +++ backend/Cargo.lock | 6 +- backend/ee-repo-ref.txt | 2 +- .../20260312000000_kafka_auto_commit.down.sql | 2 + .../20260312000000_kafka_auto_commit.up.sql | 14 +++ backend/summarized_schema.txt | 4 +- .../tests/trigger_e2e.rs | 18 +-- .../tests/triggers.rs | 70 ++++++++--- backend/windmill-api/openapi.yaml | 45 +++++++ backend/windmill-api/src/capture.rs | 1 + .../windmill-api/src/triggers/http/handler.rs | 1 + .../src/triggers/http/http_trigger_args.rs | 13 +- .../src/listener.rs | 3 +- backend/windmill-trigger/src/listener.rs | 12 +- .../triggers/TriggerAdvancedBadges.svelte | 28 +++++ .../email/EmailTriggerEditorInner.svelte | 7 +- .../triggers/gcp/GcpTriggerEditorInner.svelte | 9 +- .../triggers/http/RouteEditorInner.svelte | 9 ++ .../kafka/KafkaTriggerEditorInner.svelte | 112 +++++++++++++----- .../kafka/KafkaTriggersConfigSection.svelte | 23 ---- .../lib/components/triggers/kafka/utils.ts | 1 + .../mqtt/MqttTriggerEditorInner.svelte | 10 +- .../nats/NatsTriggerEditorInner.svelte | 7 +- .../PostgresTriggerEditorInner.svelte | 7 +- .../schedules/ScheduleEditorInner.svelte | 10 ++ .../triggers/sqs/SqsTriggerEditorInner.svelte | 7 +- .../WebsocketTriggerEditorInner.svelte | 12 +- frontend/src/lib/script_helpers.ts | 64 ++++++---- python-client/wmill/wmill/client.py | 25 ++++ typescript-client/build.sh | 4 +- typescript-client/client.d.ts | 13 ++ typescript-client/client.ts | 22 ++++ 43 files changed, 677 insertions(+), 119 deletions(-) create mode 100644 backend/.sqlx/query-038d2fde90fa9e99e30d15161777fa3ab402e33edfca46daa95b52e525424586.json rename backend/.sqlx/{query-12631fecee6aa11a45cf5c8d101c0dd8de50ac9b57e68198f637038d344fdd46.json => query-072e5ab78f929c6b7264f98c1588cb24cc635836276ee6faa2438f494bfbce04.json} (54%) create mode 100644 backend/.sqlx/query-1df610a583e86edb70c374fd66c68554a6a4291426c09dd5b04fd832f9d31208.json create mode 100644 backend/.sqlx/query-45fc21026fa76e5d69f00a68a7be81abb3ec627578f2d14f0ce33896dc6ab4cf.json create mode 100644 backend/.sqlx/query-4b2a29b3ef7ec4802d81ec4b706623b991c938e40d0db25290b03dc0577c2740.json rename backend/.sqlx/{query-7e3bfb33fb771aec39b43a7550091ce7c9b1261b52d10f4a7f3273fed3c916df.json => query-4cf4be7a981173d3f242887d9313c7e60d23e9827f23c0de5b546ed56697d54a.json} (61%) create mode 100644 backend/.sqlx/query-50807b807bb901a380926798be655c13a18dfd26e237a8218d3006e2898b5aa3.json rename backend/.sqlx/{query-4b5a711986017654bdd495893a16ddc6ab09c98cd8723865cbd341404bc6a02f.json => query-5dd6315ec270c268e905262e4b0a920837354d91a0ae16b1236c1267da71765f.json} (63%) create mode 100644 backend/.sqlx/query-80bad96cbec6b5eca57a6380e7515565490a271050dcc4b5aac2b730ae3a55b9.json create mode 100644 backend/.sqlx/query-c2f38c9e09aac73d10e8f327715927c07832badb2c9145d5996b829163bdf7d9.json create mode 100644 backend/.sqlx/query-ef15599f532fab2cbb487542ffec047cf3b7ce22ce868db1b1a63e6c10d0d12b.json create mode 100644 backend/.sqlx/query-f67e5c96eb9cb35953d4c3e83e0fcbb5b647737e0366529a2f418218b1a74679.json create mode 100644 backend/migrations/20260312000000_kafka_auto_commit.down.sql create mode 100644 backend/migrations/20260312000000_kafka_auto_commit.up.sql create mode 100644 frontend/src/lib/components/triggers/TriggerAdvancedBadges.svelte diff --git a/backend/.sqlx/query-038d2fde90fa9e99e30d15161777fa3ab402e33edfca46daa95b52e525424586.json b/backend/.sqlx/query-038d2fde90fa9e99e30d15161777fa3ab402e33edfca46daa95b52e525424586.json new file mode 100644 index 0000000000..5dcf8e0792 --- /dev/null +++ b/backend/.sqlx/query-038d2fde90fa9e99e30d15161777fa3ab402e33edfca46daa95b52e525424586.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, topic, partition, \"offset\" FROM kafka_pending_commits\n WHERE workspace_id = $1 AND kafka_trigger_path = $2\n ORDER BY id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "topic", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "partition", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "offset", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "038d2fde90fa9e99e30d15161777fa3ab402e33edfca46daa95b52e525424586" +} diff --git a/backend/.sqlx/query-12631fecee6aa11a45cf5c8d101c0dd8de50ac9b57e68198f637038d344fdd46.json b/backend/.sqlx/query-072e5ab78f929c6b7264f98c1588cb24cc635836276ee6faa2438f494bfbce04.json similarity index 54% rename from backend/.sqlx/query-12631fecee6aa11a45cf5c8d101c0dd8de50ac9b57e68198f637038d344fdd46.json rename to backend/.sqlx/query-072e5ab78f929c6b7264f98c1588cb24cc635836276ee6faa2438f494bfbce04.json index c452c33018..812c323e74 100644 --- a/backend/.sqlx/query-12631fecee6aa11a45cf5c8d101c0dd8de50ac9b57e68198f637038d344fdd46.json +++ b/backend/.sqlx/query-072e5ab78f929c6b7264f98c1588cb24cc635836276ee6faa2438f494bfbce04.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE kafka_trigger\n SET\n kafka_resource_path = $1,\n group_id = $2,\n topics = $3,\n filters = $4,\n auto_offset_reset = $5,\n script_path = $6,\n path = $7,\n is_flow = $8,\n edited_by = $9,\n email = $10,\n edited_at = now(),\n server_id = NULL,\n error = NULL,\n error_handler_path = $13,\n error_handler_args = $14,\n retry = $15\n WHERE\n workspace_id = $11 AND path = $12\n ", + "query": "\n UPDATE kafka_trigger\n SET\n kafka_resource_path = $1,\n group_id = $2,\n topics = $3,\n filters = $4,\n auto_offset_reset = $5,\n auto_commit = $6,\n script_path = $7,\n path = $8,\n is_flow = $9,\n edited_by = $10,\n email = $11,\n edited_at = now(),\n server_id = NULL,\n error = NULL,\n error_handler_path = $14,\n error_handler_args = $15,\n retry = $16\n WHERE\n workspace_id = $12 AND path = $13\n ", "describe": { "columns": [], "parameters": { @@ -10,6 +10,7 @@ "VarcharArray", "JsonbArray", "Varchar", + "Bool", "Varchar", "Varchar", "Bool", @@ -24,5 +25,5 @@ }, "nullable": [] }, - "hash": "12631fecee6aa11a45cf5c8d101c0dd8de50ac9b57e68198f637038d344fdd46" + "hash": "072e5ab78f929c6b7264f98c1588cb24cc635836276ee6faa2438f494bfbce04" } diff --git a/backend/.sqlx/query-1df610a583e86edb70c374fd66c68554a6a4291426c09dd5b04fd832f9d31208.json b/backend/.sqlx/query-1df610a583e86edb70c374fd66c68554a6a4291426c09dd5b04fd832f9d31208.json new file mode 100644 index 0000000000..babf3ffbcc --- /dev/null +++ b/backend/.sqlx/query-1df610a583e86edb70c374fd66c68554a6a4291426c09dd5b04fd832f9d31208.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT reset_offset FROM kafka_trigger WHERE workspace_id = $1 AND path = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "reset_offset", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1df610a583e86edb70c374fd66c68554a6a4291426c09dd5b04fd832f9d31208" +} diff --git a/backend/.sqlx/query-45fc21026fa76e5d69f00a68a7be81abb3ec627578f2d14f0ce33896dc6ab4cf.json b/backend/.sqlx/query-45fc21026fa76e5d69f00a68a7be81abb3ec627578f2d14f0ce33896dc6ab4cf.json new file mode 100644 index 0000000000..b5873760fc --- /dev/null +++ b/backend/.sqlx/query-45fc21026fa76e5d69f00a68a7be81abb3ec627578f2d14f0ce33896dc6ab4cf.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO kafka_trigger (\n path, kafka_resource_path, topics, group_id, script_path,\n is_flow, workspace_id, edited_by, email, auto_commit\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "VarcharArray", + "Varchar", + "Varchar", + "Bool", + "Varchar", + "Varchar", + "Varchar", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "45fc21026fa76e5d69f00a68a7be81abb3ec627578f2d14f0ce33896dc6ab4cf" +} diff --git a/backend/.sqlx/query-4b2a29b3ef7ec4802d81ec4b706623b991c938e40d0db25290b03dc0577c2740.json b/backend/.sqlx/query-4b2a29b3ef7ec4802d81ec4b706623b991c938e40d0db25290b03dc0577c2740.json new file mode 100644 index 0000000000..6dd5c799e7 --- /dev/null +++ b/backend/.sqlx/query-4b2a29b3ef7ec4802d81ec4b706623b991c938e40d0db25290b03dc0577c2740.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT auto_commit FROM kafka_trigger WHERE workspace_id = $1 AND path = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "auto_commit", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4b2a29b3ef7ec4802d81ec4b706623b991c938e40d0db25290b03dc0577c2740" +} diff --git a/backend/.sqlx/query-7e3bfb33fb771aec39b43a7550091ce7c9b1261b52d10f4a7f3273fed3c916df.json b/backend/.sqlx/query-4cf4be7a981173d3f242887d9313c7e60d23e9827f23c0de5b546ed56697d54a.json similarity index 61% rename from backend/.sqlx/query-7e3bfb33fb771aec39b43a7550091ce7c9b1261b52d10f4a7f3273fed3c916df.json rename to backend/.sqlx/query-4cf4be7a981173d3f242887d9313c7e60d23e9827f23c0de5b546ed56697d54a.json index 23652a2571..9b76b7f048 100644 --- a/backend/.sqlx/query-7e3bfb33fb771aec39b43a7550091ce7c9b1261b52d10f4a7f3273fed3c916df.json +++ b/backend/.sqlx/query-4cf4be7a981173d3f242887d9313c7e60d23e9827f23c0de5b546ed56697d54a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT kafka_resource_path, topics, group_id, mode AS \"mode: String\"\n FROM kafka_trigger\n WHERE workspace_id = $1 AND path = $2\n ", + "query": "\n SELECT kafka_resource_path, topics, group_id, mode AS \"mode: String\",\n auto_offset_reset, auto_commit, reset_offset\n FROM kafka_trigger\n WHERE workspace_id = $1 AND path = $2\n ", "describe": { "columns": [ { @@ -33,6 +33,21 @@ } } } + }, + { + "ordinal": 4, + "name": "auto_offset_reset", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "auto_commit", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "reset_offset", + "type_info": "Bool" } ], "parameters": { @@ -42,11 +57,14 @@ ] }, "nullable": [ + false, + false, + false, false, false, false, false ] }, - "hash": "7e3bfb33fb771aec39b43a7550091ce7c9b1261b52d10f4a7f3273fed3c916df" + "hash": "4cf4be7a981173d3f242887d9313c7e60d23e9827f23c0de5b546ed56697d54a" } diff --git a/backend/.sqlx/query-50807b807bb901a380926798be655c13a18dfd26e237a8218d3006e2898b5aa3.json b/backend/.sqlx/query-50807b807bb901a380926798be655c13a18dfd26e237a8218d3006e2898b5aa3.json new file mode 100644 index 0000000000..ec667519e4 --- /dev/null +++ b/backend/.sqlx/query-50807b807bb901a380926798be655c13a18dfd26e237a8218d3006e2898b5aa3.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT auto_commit\n FROM kafka_trigger\n WHERE workspace_id = $1 AND path = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "auto_commit", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "50807b807bb901a380926798be655c13a18dfd26e237a8218d3006e2898b5aa3" +} diff --git a/backend/.sqlx/query-4b5a711986017654bdd495893a16ddc6ab09c98cd8723865cbd341404bc6a02f.json b/backend/.sqlx/query-5dd6315ec270c268e905262e4b0a920837354d91a0ae16b1236c1267da71765f.json similarity index 63% rename from backend/.sqlx/query-4b5a711986017654bdd495893a16ddc6ab09c98cd8723865cbd341404bc6a02f.json rename to backend/.sqlx/query-5dd6315ec270c268e905262e4b0a920837354d91a0ae16b1236c1267da71765f.json index d90a467380..936a4650f6 100644 --- a/backend/.sqlx/query-4b5a711986017654bdd495893a16ddc6ab09c98cd8723865cbd341404bc6a02f.json +++ b/backend/.sqlx/query-5dd6315ec270c268e905262e4b0a920837354d91a0ae16b1236c1267da71765f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO kafka_trigger (\n workspace_id,\n path,\n kafka_resource_path,\n group_id,\n topics,\n filters,\n auto_offset_reset,\n script_path,\n is_flow,\n mode,\n edited_by,\n email,\n edited_at,\n error_handler_path,\n error_handler_args,\n retry\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now(), $13, $14, $15\n )\n ", + "query": "\n INSERT INTO kafka_trigger (\n workspace_id,\n path,\n kafka_resource_path,\n group_id,\n topics,\n filters,\n auto_offset_reset,\n auto_commit,\n script_path,\n is_flow,\n mode,\n edited_by,\n email,\n edited_at,\n error_handler_path,\n error_handler_args,\n retry\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, now(), $14, $15, $16\n )\n ", "describe": { "columns": [], "parameters": { @@ -12,6 +12,7 @@ "VarcharArray", "JsonbArray", "Varchar", + "Bool", "Varchar", "Bool", { @@ -35,5 +36,5 @@ }, "nullable": [] }, - "hash": "4b5a711986017654bdd495893a16ddc6ab09c98cd8723865cbd341404bc6a02f" + "hash": "5dd6315ec270c268e905262e4b0a920837354d91a0ae16b1236c1267da71765f" } diff --git a/backend/.sqlx/query-80bad96cbec6b5eca57a6380e7515565490a271050dcc4b5aac2b730ae3a55b9.json b/backend/.sqlx/query-80bad96cbec6b5eca57a6380e7515565490a271050dcc4b5aac2b730ae3a55b9.json new file mode 100644 index 0000000000..31f2767fe8 --- /dev/null +++ b/backend/.sqlx/query-80bad96cbec6b5eca57a6380e7515565490a271050dcc4b5aac2b730ae3a55b9.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM kafka_pending_commits WHERE id = ANY($1)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "80bad96cbec6b5eca57a6380e7515565490a271050dcc4b5aac2b730ae3a55b9" +} diff --git a/backend/.sqlx/query-c2f38c9e09aac73d10e8f327715927c07832badb2c9145d5996b829163bdf7d9.json b/backend/.sqlx/query-c2f38c9e09aac73d10e8f327715927c07832badb2c9145d5996b829163bdf7d9.json new file mode 100644 index 0000000000..7415b311f1 --- /dev/null +++ b/backend/.sqlx/query-c2f38c9e09aac73d10e8f327715927c07832badb2c9145d5996b829163bdf7d9.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE kafka_trigger SET reset_offset = true, server_id = NULL WHERE workspace_id = $1 AND path = $2 RETURNING true", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c2f38c9e09aac73d10e8f327715927c07832badb2c9145d5996b829163bdf7d9" +} diff --git a/backend/.sqlx/query-ef15599f532fab2cbb487542ffec047cf3b7ce22ce868db1b1a63e6c10d0d12b.json b/backend/.sqlx/query-ef15599f532fab2cbb487542ffec047cf3b7ce22ce868db1b1a63e6c10d0d12b.json new file mode 100644 index 0000000000..238977d563 --- /dev/null +++ b/backend/.sqlx/query-ef15599f532fab2cbb487542ffec047cf3b7ce22ce868db1b1a63e6c10d0d12b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE kafka_trigger SET reset_offset = false WHERE workspace_id = $1 AND path = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "ef15599f532fab2cbb487542ffec047cf3b7ce22ce868db1b1a63e6c10d0d12b" +} diff --git a/backend/.sqlx/query-f67e5c96eb9cb35953d4c3e83e0fcbb5b647737e0366529a2f418218b1a74679.json b/backend/.sqlx/query-f67e5c96eb9cb35953d4c3e83e0fcbb5b647737e0366529a2f418218b1a74679.json new file mode 100644 index 0000000000..5797d761e9 --- /dev/null +++ b/backend/.sqlx/query-f67e5c96eb9cb35953d4c3e83e0fcbb5b647737e0366529a2f418218b1a74679.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO kafka_pending_commits (workspace_id, kafka_trigger_path, topic, partition, \"offset\")\n VALUES ($1, $2, $3, $4, $5)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Int4", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "f67e5c96eb9cb35953d4c3e83e0fcbb5b647737e0366529a2f418218b1a74679" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 23e6f2a4b1..b10179c658 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -16870,7 +16870,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16950,7 +16950,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "serde", @@ -16980,7 +16980,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.653.0" +version = "1.654.0" dependencies = [ "anyhow", "serde-wasm-bindgen", diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 18e2279e37..a048a9b463 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -85bcbfc8e952842c555156d1050b2a024e7e37a3 \ No newline at end of file +fcd3ea52b0cc94fbe1159baf662a38da947456de \ No newline at end of file diff --git a/backend/migrations/20260312000000_kafka_auto_commit.down.sql b/backend/migrations/20260312000000_kafka_auto_commit.down.sql new file mode 100644 index 0000000000..58d1ad2659 --- /dev/null +++ b/backend/migrations/20260312000000_kafka_auto_commit.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS kafka_pending_commits; +ALTER TABLE kafka_trigger DROP COLUMN auto_commit; diff --git a/backend/migrations/20260312000000_kafka_auto_commit.up.sql b/backend/migrations/20260312000000_kafka_auto_commit.up.sql new file mode 100644 index 0000000000..643e2ce1f3 --- /dev/null +++ b/backend/migrations/20260312000000_kafka_auto_commit.up.sql @@ -0,0 +1,14 @@ +ALTER TABLE kafka_trigger ADD COLUMN auto_commit BOOLEAN NOT NULL DEFAULT TRUE; + +CREATE TABLE kafka_pending_commits ( + id BIGSERIAL PRIMARY KEY, + workspace_id VARCHAR(50) NOT NULL, + kafka_trigger_path VARCHAR(255) NOT NULL, + topic VARCHAR(255) NOT NULL, + partition INTEGER NOT NULL, + "offset" BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, kafka_trigger_path) REFERENCES kafka_trigger(workspace_id, path) ON DELETE CASCADE +); + +CREATE INDEX idx_kafka_pending_commits_trigger ON kafka_pending_commits (workspace_id, kafka_trigger_path); diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 3ece1ce7ef..2f86dcb322 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -109,7 +109,9 @@ job_result_stream_v2: job_id(uuid), workspace_id(text), stream(text), idx(int) job_settings: job_id(uuid), runnable_settings(bigint) job_stats: workspace_id(char), job_id(uuid), metric_id(char), metric_name(char), metric_kind(metric_kind), scalar_int(int), scalar_float(float), timestamps(ts), timeseries_int(int[]), timeseries_float(float[]) FK: (workspace_id) -> workspace(id) -kafka_trigger: path(char), kafka_resource_path(char), topics(char), group_id(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), filters(jsonb[]) +kafka_pending_commits: id(bigint), workspace_id(char), kafka_trigger_path(char), topic(char), partition(int), offset(bigint), created_at(ts) + FK: (workspace_id, kafka_trigger_path) -> kafka_trigger(workspace_id, path) +kafka_trigger: path(char), kafka_resource_path(char), topics(char), group_id(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), filters(jsonb[]), auto_commit(bool) log_file: hostname(char), log_ts(ts), ok_lines(bigint), err_lines(bigint), mode(log_mode), worker_group(char), file_path(char), json_fmt(bool) magic_link: email(char), token(char), expiration(ts) mcp_oauth_client: mcp_server_url(text), client_id(text), client_secret(text), client_secret_expires_at(ts), token_endpoint(text), created_at(ts) diff --git a/backend/windmill-api-integration-tests/tests/trigger_e2e.rs b/backend/windmill-api-integration-tests/tests/trigger_e2e.rs index 3b0e32749a..1d4f122bcc 100644 --- a/backend/windmill-api-integration-tests/tests/trigger_e2e.rs +++ b/backend/windmill-api-integration-tests/tests/trigger_e2e.rs @@ -251,7 +251,8 @@ async fn test_websocket_e2e(db: Pool) -> anyhow::Result<()> { "test-workspace", "test-user", "test@windmill.dev", - &[json!({"type": "RawMessage", "content": "hello from e2e test"})] as &[serde_json::Value], + &[json!({"type": "RawMessage", "content": "hello from e2e test"})] + as &[serde_json::Value], ) .execute(&db) .await?; @@ -302,9 +303,11 @@ async fn test_postgres_e2e(db: Pool) -> anyhow::Result<()> { sqlx::query("CREATE TABLE test_trigger_table (id serial PRIMARY KEY, data text)") .execute(&db) .await?; - sqlx::query(&format!("CREATE PUBLICATION {pub_name} FOR TABLE test_trigger_table")) - .execute(&db) - .await?; + sqlx::query(&format!( + "CREATE PUBLICATION {pub_name} FOR TABLE test_trigger_table" + )) + .execute(&db) + .await?; sqlx::query(&format!( "SELECT pg_create_logical_replication_slot('{slot_name}', 'pgoutput')" )) @@ -313,10 +316,9 @@ async fn test_postgres_e2e(db: Pool) -> anyhow::Result<()> { // Extract the test DB name from the pool so the resource points here, // not at the main windmill database. - let test_db_name: String = - sqlx::query_scalar("SELECT current_database()") - .fetch_one(&db) - .await?; + let test_db_name: String = sqlx::query_scalar("SELECT current_database()") + .fetch_one(&db) + .await?; insert_resource( &db, diff --git a/backend/windmill-api-integration-tests/tests/triggers.rs b/backend/windmill-api-integration-tests/tests/triggers.rs index 46854d566e..b55f24397c 100644 --- a/backend/windmill-api-integration-tests/tests/triggers.rs +++ b/backend/windmill-api-integration-tests/tests/triggers.rs @@ -214,12 +214,9 @@ async fn test_capture_delete(db: Pool) -> anyhow::Result<()> { .execute(&db) .await?; - let count = sqlx::query_scalar!( - "SELECT COUNT(*) FROM capture WHERE id = $1", - id, - ) - .fetch_one(&db) - .await?; + let count = sqlx::query_scalar!("SELECT COUNT(*) FROM capture WHERE id = $1", id,) + .fetch_one(&db) + .await?; assert_eq!(count, Some(0)); @@ -385,7 +382,10 @@ async fn test_capture_api_list_captures(db: Pool) -> anyhow::Result<() .send() .await?; - assert!(response.status().is_success(), "list captures should succeed"); + assert!( + response.status().is_success(), + "list captures should succeed" + ); let captures: Vec = response.json().await?; assert_eq!(captures.len(), 3); @@ -480,12 +480,9 @@ async fn test_capture_api_delete(db: Pool) -> anyhow::Result<()> { assert!(response.status().is_success(), "delete should succeed"); - let count = sqlx::query_scalar!( - "SELECT COUNT(*) FROM capture WHERE id = $1", - id, - ) - .fetch_one(&db) - .await?; + let count = sqlx::query_scalar!("SELECT COUNT(*) FROM capture WHERE id = $1", id,) + .fetch_one(&db) + .await?; assert_eq!(count, Some(0)); @@ -933,7 +930,8 @@ async fn test_kafka_trigger_insert(db: Pool) -> anyhow::Result<()> { let trigger = sqlx::query!( r#" - SELECT kafka_resource_path, topics, group_id, mode AS "mode: String" + SELECT kafka_resource_path, topics, group_id, mode AS "mode: String", + auto_offset_reset, auto_commit, reset_offset FROM kafka_trigger WHERE workspace_id = $1 AND path = $2 "#, @@ -947,6 +945,50 @@ async fn test_kafka_trigger_insert(db: Pool) -> anyhow::Result<()> { assert_eq!(trigger.topics, vec!["topic-a", "topic-b"]); assert_eq!(trigger.group_id, "my-consumer-group"); assert_eq!(trigger.mode, "enabled"); + assert_eq!(trigger.auto_offset_reset, "latest"); + assert_eq!(trigger.auto_commit, true); + assert_eq!(trigger.reset_offset, false); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_kafka_trigger_insert_auto_commit_disabled(db: Pool) -> anyhow::Result<()> { + sqlx::query!( + r#" + INSERT INTO kafka_trigger ( + path, kafka_resource_path, topics, group_id, script_path, + is_flow, workspace_id, edited_by, email, auto_commit + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + "#, + "f/test/kafka_trigger_no_commit", + "u/admin/kafka_resource", + &["topic-c"] as &[&str], + "my-consumer-group-2", + "f/test/kafka_handler", + false, + "test-workspace", + "test-user", + "test@windmill.dev", + false, + ) + .execute(&db) + .await?; + + let trigger = sqlx::query!( + r#" + SELECT auto_commit + FROM kafka_trigger + WHERE workspace_id = $1 AND path = $2 + "#, + "test-workspace", + "f/test/kafka_trigger_no_commit", + ) + .fetch_one(&db) + .await?; + + assert_eq!(trigger.auto_commit, false); Ok(()) } diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 9bb25f5589..9ec79f84d4 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -12017,6 +12017,39 @@ paths: "200": description: kafka trigger offsets reset successfully + /w/{workspace}/kafka_triggers/commit_offsets/{path}: + post: + summary: commit kafka offsets for a specific trigger + operationId: commitKafkaOffsets + tags: + - kafka_trigger + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Path" + requestBody: + description: offsets to commit + required: true + content: + application/json: + schema: + type: object + properties: + topic: + type: string + partition: + type: integer + format: int32 + offset: + type: integer + format: int64 + required: + - topic + - partition + - offset + responses: + "200": + description: kafka offsets committed successfully + /w/{workspace}/nats_triggers/create: post: summary: create nats trigger @@ -22098,6 +22131,10 @@ components: - earliest default: latest description: "Initial offset behavior when consumer group has no committed offset. 'latest' starts from new messages only, 'earliest' starts from the beginning." + auto_commit: + type: boolean + default: true + description: "When true (default), offsets are committed automatically after receiving each message. When false, you must manually commit offsets using the commit_offsets endpoint." server_id: type: string description: ID of the server currently handling this trigger (internal) @@ -22165,6 +22202,10 @@ components: - earliest default: latest description: "Initial offset behavior when consumer group has no committed offset." + auto_commit: + type: boolean + default: true + description: "When true (default), offsets are committed automatically after receiving each message. When false, you must manually commit offsets using the commit_offsets endpoint." mode: $ref: "#/components/schemas/TriggerMode" error_handler_path: @@ -22224,6 +22265,10 @@ components: - earliest default: latest description: "Initial offset behavior when consumer group has no committed offset." + auto_commit: + type: boolean + default: true + description: "When true (default), offsets are committed automatically after receiving each message. When false, you must manually commit offsets using the commit_offsets endpoint." path: type: string description: The unique path identifier for this trigger diff --git a/backend/windmill-api/src/capture.rs b/backend/windmill-api/src/capture.rs index bcc287ec04..277504b481 100644 --- a/backend/windmill-api/src/capture.rs +++ b/backend/windmill-api/src/capture.rs @@ -1012,6 +1012,7 @@ async fn http_payload( .to_v2_preprocessor_args( &http_trigger_config.route_path, &route_path, + "", ¶ms, headers, query, diff --git a/backend/windmill-api/src/triggers/http/handler.rs b/backend/windmill-api/src/triggers/http/handler.rs index 9d95ead840..fcb883f089 100644 --- a/backend/windmill-api/src/triggers/http/handler.rs +++ b/backend/windmill-api/src/triggers/http/handler.rs @@ -468,6 +468,7 @@ async fn route_job( .to_args_from_format( &trigger.route_path, &called_path, + &trigger.path, ¶ms, runnable_format, trigger.wrap_body, diff --git a/backend/windmill-api/src/triggers/http/http_trigger_args.rs b/backend/windmill-api/src/triggers/http/http_trigger_args.rs index 4866f9be34..7df1e9f200 100644 --- a/backend/windmill-api/src/triggers/http/http_trigger_args.rs +++ b/backend/windmill-api/src/triggers/http/http_trigger_args.rs @@ -68,6 +68,7 @@ struct HttpTriggerPreprocessorEvent<'a> { kind: String, route: &'a str, path: &'a str, + trigger_path: &'a str, body: Box, raw_string: Option, params: &'a HashMap, @@ -117,6 +118,7 @@ impl HttpTriggerArgs { self, route_path: &str, called_path: &str, + trigger_path: &str, params: &HashMap, format: RunnableFormat, wrap_body: bool, @@ -126,7 +128,14 @@ impl HttpTriggerArgs { match format { RunnableFormat { has_preprocessor: true, version: RunnableFormatVersion::V2 } => { // we don't care about wrap_body in v2 - self.to_v2_preprocessor_args(route_path, called_path, params, headers, query) + self.to_v2_preprocessor_args( + route_path, + called_path, + trigger_path, + params, + headers, + query, + ) } RunnableFormat { has_preprocessor: true, version: RunnableFormatVersion::V1 } => self .to_v1_preprocessor_args( @@ -177,6 +186,7 @@ impl HttpTriggerArgs { self, route_path: &str, called_path: &str, + trigger_path: &str, params: &HashMap, headers: HashMap>, query: HashMap>, @@ -193,6 +203,7 @@ impl HttpTriggerArgs { method: (&self.0.metadata.method).try_into()?, route: route_path, path: called_path, + trigger_path, params, }), ); diff --git a/backend/windmill-trigger-websocket/src/listener.rs b/backend/windmill-trigger-websocket/src/listener.rs index f6fba882a8..8aab61d9c6 100644 --- a/backend/windmill-trigger-websocket/src/listener.rs +++ b/backend/windmill-trigger-websocket/src/listener.rs @@ -322,7 +322,7 @@ impl Listener for WebsocketTrigger { db: &DB, listening_trigger: &ListeningTrigger, payload: Self::Payload, - trigger_info: HashMap>, + mut trigger_info: HashMap>, extra: Option, ) -> Result<()> { let ListeningTrigger { @@ -338,6 +338,7 @@ impl Listener for WebsocketTrigger { let WebsocketConfig { url, .. } = trigger_config; + trigger_info.insert("trigger_path".to_string(), to_raw_value(path)); let args = WebsocketTrigger::build_job_args( &script_path, *is_flow, diff --git a/backend/windmill-trigger/src/listener.rs b/backend/windmill-trigger/src/listener.rs index d09bb5e55a..04e1df102d 100644 --- a/backend/windmill-trigger/src/listener.rs +++ b/backend/windmill-trigger/src/listener.rs @@ -21,6 +21,7 @@ use windmill_common::{ jobs::JobTriggerKind, triggers::{TriggerKind, TriggerMetadata}, utils::report_critical_error, + worker::to_raw_value, DB, INSTANCE_NAME, }; @@ -467,9 +468,13 @@ pub trait Listener: TriggerCrud + TriggerJobArgs { db: &DB, listening_trigger: &ListeningTrigger, payload: Self::Payload, - trigger_info: HashMap>, + mut trigger_info: HashMap>, _extra: Option, ) -> Result<()> { + trigger_info.insert( + "trigger_path".to_string(), + to_raw_value(&listening_trigger.path), + ); let args = Self::build_job_args( &listening_trigger.script_path, listening_trigger.is_flow, @@ -552,6 +557,11 @@ pub trait Listener: TriggerCrud + TriggerJobArgs { return Ok(()); } + let mut trigger_info = trigger_info; + trigger_info.insert( + "trigger_path".to_string(), + to_raw_value(&listening_trigger.path), + ); let (main_args, preprocessor_args) = Self::build_capture_payloads(&payload, trigger_info); if let Err(err) = insert_capture_payload( db, diff --git a/frontend/src/lib/components/triggers/TriggerAdvancedBadges.svelte b/frontend/src/lib/components/triggers/TriggerAdvancedBadges.svelte new file mode 100644 index 0000000000..b261aa8ed7 --- /dev/null +++ b/frontend/src/lib/components/triggers/TriggerAdvancedBadges.svelte @@ -0,0 +1,28 @@ + + +{#if allBadges.length > 0} +
+ {#each allBadges as badge} + {badge.name} + {/each} +
+{/if} diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte index b40ef8e505..a33f14243b 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte @@ -25,6 +25,7 @@ import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' + import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { saveEmailTriggerFromCfg } from './utils' import { deepEqual } from 'fast-equals' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' @@ -370,7 +371,10 @@ />
-
+ {#snippet header()} + + {/snippet} +
@@ -390,6 +394,7 @@
+
{/if} {/snippet} diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte index 5e1b08d2c3..6a11d6180b 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte @@ -31,6 +31,7 @@ import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' + import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import Subsection from '$lib/components/Subsection.svelte' import Toggle from '$lib/components/Toggle.svelte' @@ -452,7 +453,12 @@ />
-
+ {#snippet header()} + + {/snippet} +
@@ -520,6 +526,7 @@
+
{/if} {/snippet} diff --git a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte index a35227e081..272a0b707c 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte @@ -52,6 +52,7 @@ import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' + import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { deepEqual } from 'fast-equals' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' @@ -695,6 +696,13 @@ {#if !is_static_website}
+ {#snippet header()} + + {/snippet}
@@ -909,6 +917,7 @@
+
{/if}
{/if} diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte index ad5cce6f92..5f68f03705 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte @@ -6,12 +6,7 @@ import Path from '$lib/components/Path.svelte' import Required from '$lib/components/Required.svelte' import ScriptPicker from '$lib/components/ScriptPicker.svelte' - import { - KafkaTriggerService, - type ErrorHandler, - type Retry, - type TriggerMode - } from '$lib/gen' + import { KafkaTriggerService, type ErrorHandler, type Retry, type TriggerMode } from '$lib/gen' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils' import Section from '$lib/components/Section.svelte' @@ -25,10 +20,13 @@ import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' + import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { deepEqual } from 'fast-equals' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' import TriggerFilters from '../TriggerFilters.svelte' + import Select from '$lib/components/select/Select.svelte' + import Toggle from '$lib/components/Toggle.svelte' interface Props { useDrawer?: boolean @@ -87,6 +85,7 @@ let kafkaResourcePath = $state('') let kafkaCfg: Record = $state({}) let autoOffsetReset = $state('latest') + let autoCommit = $state(true) let deploymentLoading = $state(false) let resetLoading = $state(false) let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler') @@ -176,6 +175,7 @@ topics: nDefaultValues?.topics ?? [''] } autoOffsetReset = nDefaultValues?.auto_offset_reset ?? 'latest' + autoCommit = nDefaultValues?.auto_commit ?? true initialScriptPath = '' fixedScriptPath = fixedScriptPath_ ?? '' script_path = fixedScriptPath @@ -207,6 +207,7 @@ topics: cfg?.topics } autoOffsetReset = cfg?.auto_offset_reset ?? 'latest' + autoCommit = cfg?.auto_commit ?? true mode = cfg?.mode ?? 'enabled' extra_perms = cfg?.extra_perms can_write = canWrite(path, cfg?.extra_perms, $userStore) @@ -240,6 +241,7 @@ topics: kafkaCfg.topics, filters, auto_offset_reset: autoOffsetReset, + auto_commit: autoCommit, mode, extra_perms: extra_perms, error_handler_path, @@ -481,36 +483,85 @@ bind:kafkaCfgValid bind:kafkaResourcePath bind:kafkaCfg - bind:autoOffsetReset {path} {can_write} showTestingBadge={isEditor} /> - {#if edit && can_write} - - {/if} - - -
-
+ {#snippet header()} + 0 } + ]} + /> + {/snippet} +
+ -
diff --git a/frontend/src/lib/components/triggers/kafka/utils.ts b/frontend/src/lib/components/triggers/kafka/utils.ts index 16e065093d..12ee49ac24 100644 --- a/frontend/src/lib/components/triggers/kafka/utils.ts +++ b/frontend/src/lib/components/triggers/kafka/utils.ts @@ -25,6 +25,7 @@ export async function saveKafkaTriggerFromCfg( topics: cfg.topics, filters: cfg.filters ?? [], auto_offset_reset: cfg.auto_offset_reset ?? 'latest', + auto_commit: cfg.auto_commit ?? true, ...errorHandlerAndRetries } try { diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte index 18148fdbab..df5c7c21e1 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte @@ -29,6 +29,7 @@ import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' + import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import Toggle from '$lib/components/Toggle.svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' @@ -469,7 +470,13 @@ />
-
+ {#snippet header()} + + {/snippet} +
@@ -606,6 +613,7 @@
+
{/if} {/snippet} diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte index 8c7fbb846c..a3c02c57dc 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte @@ -19,6 +19,7 @@ import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' + import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { deepEqual } from 'fast-equals' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' @@ -452,7 +453,10 @@ />
-
+ {#snippet header()} + + {/snippet} +
@@ -472,6 +476,7 @@
+
{/if} {/snippet} diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte index cd176ac51c..5ffe489a22 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte @@ -36,6 +36,7 @@ import TestingBadge from '../testingBadge.svelte' import { getHandlerType, handleConfigChange, type Trigger } from '../utils' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' + import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { fade } from 'svelte/transition' import MultiSelect from '$lib/components/select/MultiSelect.svelte' import { safeSelectItems } from '$lib/components/select/utils.svelte' @@ -854,7 +855,10 @@
-
+ {#snippet header()} + + {/snippet} +
@@ -874,6 +878,7 @@
+
{/if} {/snippet} diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 4aca94c4b5..55f825b40c 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -1,5 +1,6 @@ -
+
Ducklake
diff --git a/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte b/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte index 44b9d5d1d3..ef46189444 100644 --- a/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte @@ -102,19 +102,6 @@ let hasUnsavedChanges = $derived.by(() => { return !deepEqual(s3ResourceSettings, s3ResourceSavedSettings) }) - - let volumeStorageItems: { value: string; label: string }[] = $derived.by(() => { - const items: { value: string; label: string }[] = [{ value: '', label: 'Disabled' }] - if (!emptyString(s3ResourceSettings.resourcePath)) { - items.push({ value: 'primary', label: 'Primary storage' }) - } - for (const [name, s] of s3ResourceSettings.secondaryStorage ?? []) { - if (!emptyString(s.resourcePath)) { - items.push({ value: name, label: name }) - } - } - return items - }) @@ -236,18 +223,18 @@ class="cursor-not-allowed" > {#snippet trigger()} - + - + {/snippet} {#snippet content()} - + {#if emptyString(tableRow[1].resourcePath)} Please select a storage resource {:else if isDirty(tableRow[0])} Please save your changes {/if} - + {/snippet} {:else} @@ -330,25 +317,6 @@ -
- -
- s3ResourceSettings.volumeStorage ?? '', + (v) => { + s3ResourceSettings.volumeStorage = v || undefined + } + } + /> +
+ + onDiscard?.()} + saveLabel="Save volume storage settings" + /> + {:else} + + You need to configure a workspace object storage before you can use volumes. + + + {/if} +{/if} diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index 8857b170c5..593a361b57 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -51,6 +51,7 @@ import ConnectionSection from '$lib/components/ConnectionSection.svelte' import AISettings from '$lib/components/workspaceSettings/AISettings.svelte' import StorageSettings from '$lib/components/workspaceSettings/StorageSettings.svelte' + import VolumeStorageSettings from '$lib/components/workspaceSettings/VolumeStorageSettings.svelte' import GitSyncSection from '$lib/components/git_sync/GitSyncSection.svelte' import { untrack } from 'svelte' import { getHandlerType } from '$lib/components/triggers/utils' @@ -295,6 +296,8 @@ | 'ai' | 'windmill_data_tables' | 'windmill_lfs' + | 'volume_storage' + | 'ducklake' | 'git_sync' | 'default_app' | 'native_triggers' @@ -844,22 +847,40 @@ // Function to check if there are unsaved changes in storage settings function getStorageSettingsInitialAndModifiedValues() { - const savedValue = { - s3ResourceSettings: s3ResourceSavedSettings, - ducklakeSettings: ducklakeSavedSettings + return { + savedValue: { s3ResourceSettings: s3ResourceSavedSettings }, + modifiedValue: { s3ResourceSettings: s3ResourceSettings } } - - const modifiedValue = { - s3ResourceSettings: s3ResourceSettings, - ducklakeSettings: ducklakeSettings - } - - return { savedValue, modifiedValue } } // Function to discard unsaved storage settings changes function discardStorageSettingsChanges() { s3ResourceSettings = clone(s3ResourceSavedSettings) + } + + // Function to check if there are unsaved changes in volume storage settings + function getVolumeStorageInitialAndModifiedValues() { + return { + savedValue: { volumeStorage: s3ResourceSavedSettings.volumeStorage }, + modifiedValue: { volumeStorage: s3ResourceSettings.volumeStorage } + } + } + + // Function to discard unsaved volume storage changes + function discardVolumeStorageChanges() { + s3ResourceSettings.volumeStorage = s3ResourceSavedSettings.volumeStorage + } + + // Function to check if there are unsaved changes in ducklake settings + function getDucklakeSettingsInitialAndModifiedValues() { + return { + savedValue: { ducklakeSettings: ducklakeSavedSettings }, + modifiedValue: { ducklakeSettings: ducklakeSettings } + } + } + + // Function to discard unsaved ducklake settings changes + function discardDucklakeSettingsChanges() { ducklakeSettings = clone(ducklakeSavedSettings) } @@ -998,6 +1019,10 @@ return getAiSettingsInitialAndModifiedValues() case 'windmill_lfs': return getStorageSettingsInitialAndModifiedValues() + case 'volume_storage': + return getVolumeStorageInitialAndModifiedValues() + case 'ducklake': + return getDucklakeSettingsInitialAndModifiedValues() case 'deploy_to': return getDeploySettingsInitialAndModifiedValues() case 'webhook': @@ -1038,6 +1063,12 @@ case 'windmill_lfs': discardStorageSettingsChanges() break + case 'volume_storage': + discardVolumeStorageChanges() + break + case 'ducklake': + discardDucklakeSettingsChanges() + break case 'deploy_to': discardDeploySettingsChanges() break @@ -1178,6 +1209,18 @@ label: 'Object storage (S3)', aiId: 'workspace-settings-windmill-lfs', aiDescription: 'Object Storage (S3) workspace settings' + }, + { + id: 'volume_storage', + label: 'Volumes', + aiId: 'workspace-settings-volume-storage', + aiDescription: 'Volume storage workspace settings' + }, + { + id: 'ducklake', + label: 'Ducklake', + aiId: 'workspace-settings-ducklake', + aiDescription: 'Ducklake workspace settings' } ] }, @@ -1811,6 +1854,18 @@ export async function main( s3ResourceSettings = clone(s3ResourceSavedSettings) }} /> + {:else if tab == 'volume_storage'} + { + s3ResourceSavedSettings = clone(s3ResourceSettings) + }} + onDiscard={() => { + s3ResourceSettings = clone(s3ResourceSavedSettings) + }} + /> + {:else if tab == 'ducklake'} Date: Fri, 13 Mar 2026 07:08:33 +0100 Subject: [PATCH 031/204] add a hint to commit message on git sync for renames (#8343) * add a hint to commit message on git sync for renames * chore: update ee-repo-ref to 344e2aa60d8cafe08b8c57445d2f9555bad7625a This commit updates the EE repository reference after PR #458 was merged in windmill-ee-private. Previous ee-repo-ref: ad4c90cc96ad1b4511cba6b6ee28351895882156 New ee-repo-ref: 344e2aa60d8cafe08b8c57445d2f9555bad7625a Automated by sync-ee-ref workflow. --------- 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 a048a9b463..ee34a47736 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -fcd3ea52b0cc94fbe1159baf662a38da947456de \ No newline at end of file +344e2aa60d8cafe08b8c57445d2f9555bad7625a From 4c2c165a5b757bd5f2f49074bb290407bce3b2fb Mon Sep 17 00:00:00 2001 From: Pyra <92104930+pyranota@users.noreply.github.com> Date: Fri, 13 Mar 2026 07:10:28 +0100 Subject: [PATCH 032/204] feat(cli): add unified generate-metadata command (#8335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): add unified generate-metadata command - Add generate-metadata command that calls script, flow, and app handlers - Export generateLocks from flow.ts and generateMetadata from script.ts - Add deprecation warnings to individual metadata commands * feat(cli): improve unified generate-metadata command - Use internal handlers for single-pass collection of stale items - Add --dry-run flag to show what would be updated - Fix WASM parser init deprecation warning - Add comprehensive tests for all flags - Match original handler behavior for per-item messages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * feat(cli): add skip flags and comprehensive tests for generate-metadata - Add --skip-scripts, --skip-flows, --skip-apps flags for granular control - --schema-only now properly skips flows and apps (they only have locks) - Dynamic "Checking X, Y, Z..." message based on what's being processed - Show warning when all types are skipped - Add comprehensive tests for all flags: - --dry-run shows stale items without updating - --schema-only only processes scripts - --skip-scripts, --skip-flows, --skip-apps work correctly - skipping all types shows warning - 'All metadata up-to-date' when nothing to update 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * improve output Signed-off-by: pyranota * refactor(cli): add shared test fixtures with cross-links Co-Authored-By: Claude Opus 4.5 * feat(cli): add folder argument to generate-metadata command Co-Authored-By: Claude Opus 4.5 --------- Signed-off-by: pyranota Co-authored-by: Claude Opus 4.5 --- cli/src/commands/app/app.ts | 3 + cli/src/commands/app/app_metadata.ts | 18 +- cli/src/commands/flow/flow.ts | 5 +- cli/src/commands/flow/flow_metadata.ts | 10 +- .../generate-metadata/generate-metadata.ts | 332 +++++++++++ cli/src/commands/script/script.ts | 5 +- cli/src/main.ts | 2 + cli/src/utils/metadata.ts | 4 +- cli/test/sync_pull_push.test.ts | 23 +- cli/test/test_backend.ts | 13 + cli/test/test_fixtures.ts | 531 ++++++++++++++++++ cli/test/unified_generate_metadata.test.ts | 451 +++++++++++++++ 12 files changed, 1378 insertions(+), 19 deletions(-) create mode 100644 cli/src/commands/generate-metadata/generate-metadata.ts create mode 100644 cli/test/test_fixtures.ts create mode 100644 cli/test/unified_generate_metadata.test.ts diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index 3aa8305542..f048aac616 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -275,6 +275,9 @@ const command = new Command() "Default TypeScript runtime (bun or deno)" ) .action(async (opts: any, appFolder: string | undefined) => { + log.warn( + colors.yellow('This command is deprecated. Use "wmill generate-metadata" instead.') + ); const { generateLocksCommand } = await import("./app_metadata.ts"); await generateLocksCommand(opts, appFolder); }); diff --git a/cli/src/commands/app/app_metadata.ts b/cli/src/commands/app/app_metadata.ts index 3a28770225..78a3019566 100644 --- a/cli/src/commands/app/app_metadata.ts +++ b/cli/src/commands/app/app_metadata.ts @@ -157,7 +157,7 @@ export async function generateAppLocksInternal( return remote_path; } - if (Object.keys(filteredDeps).length > 0) { + if (Object.keys(filteredDeps).length > 0 && !noStaleMessage) { log.info( (await blueColor())( `Found workspace dependencies (${workspaceDependenciesLanguages @@ -180,9 +180,11 @@ export async function generateAppLocksInternal( } if (changedScripts.length > 0) { - log.info( - `Recomputing locks of ${changedScripts.join(", ")} in ${appFolder}` - ); + if (!noStaleMessage) { + log.info( + `Recomputing locks of ${changedScripts.join(", ")} in ${appFolder}` + ); + } if (rawApp) { const runnablesPath = path.join(appFolder, APP_BACKEND_FOLDER); @@ -230,7 +232,7 @@ export async function generateAppLocksInternal( yamlStringify(appFile as Record, yamlOptions) ); } - } else { + } else if (!noStaleMessage) { log.info(colors.gray(`No scripts changed in ${appFolder}`)); } } @@ -246,7 +248,9 @@ export async function generateAppLocksInternal( for (const [scriptPath, hash] of Object.entries(hashes)) { await updateMetadataGlobalLock(appFolder, hash, scriptPath); } - log.info(colors.green(`App ${remote_path} lockfiles updated`)); + if (!noStaleMessage) { + log.info(colors.green(`App ${remote_path} lockfiles updated`)); + } } /** @@ -767,7 +771,7 @@ export async function inferRunnableSchemaFromFile( } } -function getAppFolders(elems: Record, extension: string) { +export function getAppFolders(elems: Record, extension: string) { return Object.keys(elems) .filter((p) => p.endsWith(SEP + extension)) .map((p) => p.substring(0, p.length - (SEP + extension).length)); diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index f0974cf354..3b01385fb9 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -308,12 +308,15 @@ async function preview( } } -async function generateLocks( +export async function generateLocks( opts: GlobalOptions & { yes?: boolean; } & SyncOptions, folder: string | undefined ) { + log.warn( + colors.yellow('This command is deprecated. Use "wmill generate-metadata" instead.') + ); const workspace = await resolveWorkspace(opts); await requireLogin(opts); opts = await mergeConfigWithConfigFile(opts); diff --git a/cli/src/commands/flow/flow_metadata.ts b/cli/src/commands/flow/flow_metadata.ts index d52d05a3ed..2e48efaa10 100644 --- a/cli/src/commands/flow/flow_metadata.ts +++ b/cli/src/commands/flow/flow_metadata.ts @@ -98,7 +98,7 @@ export async function generateFlowLockInternal( return remote_path; } - if (Object.keys(filteredDeps).length > 0) { + if (Object.keys(filteredDeps).length > 0 && !noStaleMessage) { log.info( (await blueColor())( `Found workspace dependencies (${workspaceDependenciesLanguages @@ -121,7 +121,9 @@ export async function generateFlowLockInternal( } } - log.info(`Recomputing locks of ${changedScripts.join(", ")} in ${folder}`); + if (!noStaleMessage) { + log.info(`Recomputing locks of ${changedScripts.join(", ")} in ${folder}`); + } const fileReader = async (path: string) => await readFile(folder + SEP + path, "utf-8"); await replaceInlineScripts( flowValue.value.modules, @@ -180,7 +182,9 @@ export async function generateFlowLockInternal( for (const [path, hash] of Object.entries(hashes)) { await updateMetadataGlobalLock(folder, hash, path); } - log.info(colors.green(`Flow ${remote_path} lockfiles updated`)); + if (!noStaleMessage) { + log.info(colors.green(`Flow ${remote_path} lockfiles updated`)); + } } /** diff --git a/cli/src/commands/generate-metadata/generate-metadata.ts b/cli/src/commands/generate-metadata/generate-metadata.ts new file mode 100644 index 0000000000..0d7435856c --- /dev/null +++ b/cli/src/commands/generate-metadata/generate-metadata.ts @@ -0,0 +1,332 @@ +import { Command } from "@cliffy/command"; +import { Confirm } from "@cliffy/prompt/confirm"; +import { colors } from "@cliffy/ansi/colors"; +import { sep as SEP } from "node:path"; +import { GlobalOptions } from "../../types.ts"; +import { SyncOptions, mergeConfigWithConfigFile } from "../../core/conf.ts"; +import { resolveWorkspace } from "../../core/context.ts"; +import { requireLogin } from "../../core/auth.ts"; +import * as log from "../../core/log.ts"; +import { + generateScriptMetadataInternal, + getRawWorkspaceDependencies, +} from "../../utils/metadata.ts"; +import { generateFlowLockInternal } from "../flow/flow_metadata.ts"; +import { generateAppLocksInternal, getAppFolders } from "../app/app_metadata.ts"; +import { + elementsToMap, + FSFSElement, + ignoreF, +} from "../sync/sync.ts"; +import { exts } from "../script/script.ts"; +import { isFlowPath, isAppPath } from "../../utils/resource_folders.ts"; +import { listSyncCodebases } from "../../utils/codebase.ts"; + +interface StaleItem { + type: "script" | "flow" | "app"; + path: string; + folder: string; + isRawApp?: boolean; +} + +async function generateMetadata( + opts: GlobalOptions & { + yes?: boolean; + lockOnly?: boolean; + schemaOnly?: boolean; + dryRun?: boolean; + skipScripts?: boolean; + skipFlows?: boolean; + skipApps?: boolean; + } & SyncOptions, + folder?: string +) { + if (folder === "") { + folder = undefined; + } + + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + opts = await mergeConfigWithConfigFile(opts); + + const rawWorkspaceDependencies = await getRawWorkspaceDependencies(); + const codebases = await listSyncCodebases(opts); + const ignore = await ignoreF(opts); + + const staleItems: StaleItem[] = []; + + // --schema-only implies skipping flows and apps (they only have locks, no schemas) + const skipScripts = opts.skipScripts ?? false; + const skipFlows = opts.skipFlows ?? opts.schemaOnly ?? false; + const skipApps = opts.skipApps ?? opts.schemaOnly ?? false; + + const checking: string[] = []; + if (!skipScripts) checking.push("scripts"); + if (!skipFlows) checking.push("flows"); + if (!skipApps) checking.push("apps"); + + if (checking.length === 0) { + log.info(colors.yellow("Nothing to check (all types skipped)")); + return; + } + + log.info(colors.gray(`Checking ${checking.join(", ")}...`)); + + // === Collect stale scripts === + if (!skipScripts) { + // TODO: run elementsToMap only once but for all runnable types. + const scriptElems = await elementsToMap( + await FSFSElement(process.cwd(), codebases, false), + (p, isD) => { + return ( + (!isD && !exts.some((ext) => p.endsWith(ext))) || + ignore(p, isD) || + isFlowPath(p) || + isAppPath(p) + ); + }, + false, + {} + ); + + for (const e of Object.keys(scriptElems)) { + const candidate = await generateScriptMetadataInternal( + e, + workspace, + opts, + true, // dryRun + true, // noStaleMessage + rawWorkspaceDependencies, + codebases, + false + ); + if (candidate) { + staleItems.push({ type: "script", path: candidate, folder: e }); + } + } + } + + // === Collect stale flows === + if (!skipFlows) { + const flowElems = Object.keys( + await elementsToMap( + await FSFSElement(process.cwd(), [], true), + (p, isD) => { + return ( + ignore(p, isD) || + (!isD && + !p.endsWith(SEP + "flow.yaml") && + !p.endsWith(SEP + "flow.json")) + ); + }, + false, + {} + ) + ).map((x) => x.substring(0, x.lastIndexOf(SEP))); + + for (const folder of flowElems) { + const candidate = await generateFlowLockInternal( + folder, + true, // dryRun + workspace, + opts, + false, + true // noStaleMessage + ); + if (candidate) { + staleItems.push({ type: "flow", path: candidate, folder }); + } + } + } + + // === Collect stale apps === + if (!skipApps) { + const elems = await elementsToMap( + await FSFSElement(process.cwd(), [], true), + (p, isD) => { + return ( + ignore(p, isD) || + (!isD && + !p.endsWith(SEP + "raw_app.yaml") && + !p.endsWith(SEP + "app.yaml")) + ); + }, + false, + {} + ); + + const rawAppFolders = getAppFolders(elems, "raw_app.yaml"); + const appFolders = getAppFolders(elems, "app.yaml"); + + for (const appFolder of rawAppFolders) { + const candidate = await generateAppLocksInternal( + appFolder, + true, // rawApp + true, // dryRun + workspace, + opts, + false, + true // noStaleMessage + ); + if (candidate) { + staleItems.push({ type: "app", path: candidate, folder: appFolder, isRawApp: true }); + } + } + + for (const appFolder of appFolders) { + const candidate = await generateAppLocksInternal( + appFolder, + false, // rawApp + true, // dryRun + workspace, + opts, + false, + true // noStaleMessage + ); + if (candidate) { + staleItems.push({ type: "app", path: candidate, folder: appFolder, isRawApp: false }); + } + } + } + + // === Filter by folder if specified === + let filteredItems = staleItems; + if (folder) { + // Strip trailing separator to match deprecated flow/app handler behavior + // (see generateFlowLockInternal line 64-66, generateAppLocksInternal line 109-110) + if (folder.endsWith(SEP)) { + folder = folder.substring(0, folder.length - 1); + } + filteredItems = staleItems.filter((item) => item.folder === folder || item.folder.startsWith(folder + SEP)); + } + + // === Show stale items and confirm === + if (filteredItems.length === 0) { + log.info(colors.green("All metadata up-to-date")); + return; + } + + // Group items by type for display + const scripts = filteredItems.filter((i) => i.type === "script"); + const flows = filteredItems.filter((i) => i.type === "flow"); + const apps = filteredItems.filter((i) => i.type === "app"); + + log.info(""); + log.info(`Found ${filteredItems.length} item(s) with stale metadata:`); + + if (scripts.length > 0) { + log.info(colors.gray(` Scripts (${scripts.length}):`)); + for (const item of scripts) { + log.info(colors.yellow(` ${item.path}`)); + } + } + if (flows.length > 0) { + log.info(colors.gray(` Flows (${flows.length}):`)); + for (const item of flows) { + log.info(colors.yellow(` ${item.path}`)); + } + } + if (apps.length > 0) { + log.info(colors.gray(` Apps (${apps.length}):`)); + for (const item of apps) { + log.info(colors.yellow(` ${item.path}`)); + } + } + + if (opts.dryRun) { + return; + } + + log.info(""); + + if ( + !opts.yes && + !(await Confirm.prompt({ + message: "Update metadata?", + default: true, + })) + ) { + return; + } + + log.info(""); + + // === Process all stale items with progress counter === + const total = filteredItems.length; + const maxWidth = `[${total}/${total}]`.length; + let current = 0; + + const formatProgress = (n: number) => { + const bracket = `[${n}/${total}]`; + return colors.gray(bracket.padEnd(maxWidth, " ")); + }; + + // Process scripts + for (const item of scripts) { + current++; + log.info(`${formatProgress(current)} script ${colors.cyan(item.path)}`); + await generateScriptMetadataInternal( + item.folder, + workspace, + opts, + false, // dryRun + true, // noStaleMessage - we handle output + rawWorkspaceDependencies, + codebases, + false + ); + } + + // Process flows + for (const item of flows) { + current++; + log.info(`${formatProgress(current)} flow ${colors.cyan(item.path)}`); + await generateFlowLockInternal( + item.folder, + false, // dryRun + workspace, + opts, + false, + true // noStaleMessage - we handle output + ); + } + // Process apps + for (const item of apps) { + current++; + log.info(`${formatProgress(current)} app ${colors.cyan(item.path)}`); + await generateAppLocksInternal( + item.folder, + item.isRawApp!, // rawApp + false, // dryRun + workspace, + opts, + false, + true // noStaleMessage - we handle output + ); + } + + log.info(""); + log.info(colors.green(`Done. Updated ${total} item(s).`)); +} + +const command = new Command() + .description("Generate metadata (locks, schemas) for all scripts, flows, and apps") + .arguments("[folder:string]") + .option("--yes", "Skip confirmation prompt") + .option("--dry-run", "Show what would be updated without making changes") + .option("--lock-only", "Re-generate only the lock files") + .option("--schema-only", "Re-generate only script schemas (skips flows and apps)") + .option("--skip-scripts", "Skip processing scripts") + .option("--skip-flows", "Skip processing flows") + .option("--skip-apps", "Skip processing apps") + .option( + "-i --includes ", + "Comma separated patterns to specify which files to include" + ) + .option( + "-e --excludes ", + "Comma separated patterns to specify which files to exclude" + ) + .action(generateMetadata as any); + +export default command; diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 9c6f094b41..3a6556b34d 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -978,7 +978,7 @@ export type GlobalDeps = Map< Record >; -async function generateMetadata( +export async function generateMetadata( opts: GlobalOptions & { lockOnly?: boolean; schemaOnly?: boolean; @@ -986,6 +986,9 @@ async function generateMetadata( } & SyncOptions, scriptPath: string | undefined ) { + log.warn( + colors.yellow('This command is deprecated. Use "wmill generate-metadata" instead.') + ); log.info( "This command only works for workspace scripts, for flows inline scripts use `wmill flow generate-locks`" ); diff --git a/cli/src/main.ts b/cli/src/main.ts index f923ef38fd..d81570e784 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -39,6 +39,7 @@ import queues from "./commands/queues/queues.ts"; import dependencies from "./commands/dependencies/dependencies.ts"; import init from "./commands/init/init.ts"; import jobs from "./commands/jobs/jobs.ts"; +import generateMetadata from "./commands/generate-metadata/generate-metadata.ts"; import docs from "./commands/docs/docs.ts"; import { fetchVersion } from "./core/context.ts"; @@ -129,6 +130,7 @@ const command = new Command() .command("queues", queues) .command("dependencies", dependencies) .command("jobs", jobs) + .command("generate-metadata", generateMetadata) .command("docs", docs) .command("version --version", "Show version information") .action(async (opts: any) => { diff --git a/cli/src/utils/metadata.ts b/cli/src/utils/metadata.ts index 590c3d7d8e..76ff33247a 100644 --- a/cli/src/utils/metadata.ts +++ b/cli/src/utils/metadata.ts @@ -35,7 +35,7 @@ function loadParser(pkgName: string): Promise { const wasmPath = _require.resolve( `${pkgName}/windmill_parser_wasm_bg.wasm` ); - await mod.default(readFileSync(wasmPath)); + await mod.default({ module_or_path: readFileSync(wasmPath) }); return mod; })(); _parserCache.set(pkgName, p); @@ -223,7 +223,7 @@ export async function generateScriptMetadataInternal( return `${remotePath} (${language})`; } - if (!justUpdateMetadataLock) { + if (!justUpdateMetadataLock && !noStaleMessage) { log.info(colors.gray(`Generating metadata for ${scriptPath}`)); } diff --git a/cli/test/sync_pull_push.test.ts b/cli/test/sync_pull_push.test.ts index 2b062eb157..613de2ef82 100644 --- a/cli/test/sync_pull_push.test.ts +++ b/cli/test/sync_pull_push.test.ts @@ -3,6 +3,13 @@ * * Tests the sync pull and push functionality with a simulated filesystem * containing every kind of Windmill resource type. + * + * CROSS-LINKS - Related test helper locations (keep in sync when adding new helpers): + * @see test_fixtures.ts - Shared local fixtures (prefer using this module for new tests) + * @see test_backend.ts - API-based creation helpers (createTestApp, createTestResource, etc.) + * + * This file contains: Local fixtures (should migrate to test_fixtures.ts) + createRemoteScript + * If you add new helpers, update cross-links in the files above. */ import { expect, test, describe } from "bun:test"; @@ -37,10 +44,13 @@ import { newPathAssigner } from "../windmill-utils-internal/src/path-utils/path- // ============================================================================= // Test Fixtures - Every Type of Windmill Resource +// See file header for cross-links to related helpers. +// Consider migrating these to test_fixtures.ts for reuse across tests. // ============================================================================= /** - * Creates a mock script file structure + * Creates a mock script file structure. + * See file header for cross-links to related helpers. */ function createScriptFixture( name: string, @@ -89,7 +99,8 @@ kind: script } /** - * Creates a mock flow file structure + * Creates a mock flow file structure. + * See file header for cross-links to related helpers. */ function createFlowFixture(name: string): Record { const flowSuffix = getFolderSuffix("flow"); @@ -123,7 +134,8 @@ schema: } /** - * Creates a mock app file structure + * Creates a mock app file structure. + * See file header for cross-links to related helpers. */ function createAppFixture(name: string): Record { const appSuffix = getFolderSuffix("app"); @@ -151,7 +163,8 @@ policy: } /** - * Creates a mock raw_app file structure + * Creates a mock raw_app file structure. + * See file header for cross-links to related helpers. */ function createRawAppFixture(name: string): Record { const rawAppSuffix = getFolderSuffix("raw_app"); @@ -1920,7 +1933,7 @@ excludes: [] import type { TestBackend } from "./test_backend.ts"; -/** Create a script on the remote via API */ +/** Create a script on the remote via API. See file header for cross-links. */ async function createRemoteScript( backend: TestBackend, scriptPath: string, diff --git a/cli/test/test_backend.ts b/cli/test/test_backend.ts index 63c7f3078d..16fb79528d 100644 --- a/cli/test/test_backend.ts +++ b/cli/test/test_backend.ts @@ -19,6 +19,13 @@ * // ... * }); * }); + * + * CROSS-LINKS - Related test helper locations (keep in sync when adding new helpers): + * @see test_fixtures.ts - Local file fixtures (createLocalScript, createLocalFlow, etc.) + * @see sync_pull_push.test.ts - Local fixtures + createRemoteScript (API-based) + * + * This file contains: API-based creation helpers (createTestApp, createTestResource, etc.) + * If you add new helpers, update cross-links in the files above. */ import { CargoBackend, CargoBackendConfig } from "./cargo_backend.ts"; @@ -109,6 +116,7 @@ class CargoBackendAdapter implements TestBackend { return this.backend.apiRequest(path, options); } + /** Seeds test data via API calls. See file header for cross-links to related helpers. */ async seedTestData(): Promise { // Create test folder first await this.createTestFolder("test"); @@ -124,6 +132,7 @@ class CargoBackendAdapter implements TestBackend { await this.createTestApp("f/test/test_dashboard"); } + /** See file header for cross-links to related helpers. */ private async createTestApp(path: string): Promise { const response = await this.backend.apiRequest(`/api/w/${this.workspace}/apps/create`, { method: "POST", @@ -156,6 +165,7 @@ class CargoBackendAdapter implements TestBackend { } } + /** See file header for cross-links to related helpers. */ private async createTestFolder(name: string): Promise { const response = await this.backend.apiRequest(`/api/w/${this.workspace}/folders/create`, { method: "POST", @@ -172,6 +182,7 @@ class CargoBackendAdapter implements TestBackend { } } + /** See file header for cross-links to related helpers. */ private async createTestGroup(name: string): Promise { const response = await this.backend.apiRequest(`/api/w/${this.workspace}/groups/create`, { method: "POST", @@ -189,6 +200,7 @@ class CargoBackendAdapter implements TestBackend { } + /** See file header for cross-links to related helpers. */ private async createTestResource(path: string, description: string): Promise { // First ensure the folder exists const folderPath = path.split("/").slice(0, 2).join("/"); // e.g., "f/test" @@ -228,6 +240,7 @@ class CargoBackendAdapter implements TestBackend { } } + /** See file header for cross-links to related helpers. */ private async createTestVariable(path: string, value: string): Promise { const response = await this.backend.apiRequest( `/api/w/${this.workspace}/variables/create`, diff --git a/cli/test/test_fixtures.ts b/cli/test/test_fixtures.ts new file mode 100644 index 0000000000..5f64869949 --- /dev/null +++ b/cli/test/test_fixtures.ts @@ -0,0 +1,531 @@ +/** + * Test Fixtures + * + * Shared helpers for creating test data (scripts, flows, apps, raw apps) in tests. + * + * Two types of helpers: + * - Fixture functions: Return data structures with paths and contents (no disk I/O) + * - Local creation functions: Create fixtures AND write them to disk + * + * CROSS-LINKS - Related test helper locations (keep in sync when adding new helpers): + * @see test_backend.ts - API-based creation helpers (createTestApp, createTestResource, etc.) + * @see sync_pull_push.test.ts - Local fixtures + createRemoteScript (API-based) + * + * This file contains: Shared local fixtures (createLocalScript, createLocalFlow, etc.) + * If you add new helpers, update cross-links in the files above. + * + * @example + * // Using fixtures (data only) + * const fixture = createScriptFixture("my_script", "bun"); + * + * // Using local creation (writes to disk) + * await createLocalScript(tempDir, "f/test", "my_script", "bun"); + * + * @keywords createLocal, local script, local flow, local app, raw app, fixture, test data + */ + +import { writeFile, mkdir } from "node:fs/promises"; +import { + getFolderSuffix, + getMetadataFileName, +} from "../src/utils/resource_folders.ts"; + +// ============================================================================= +// Fixture Types +// ============================================================================= + +export interface FileFixture { + path: string; + content: string; +} + +export interface ScriptFixture { + contentFile: FileFixture; + metadataFile: FileFixture; +} + +export interface FlowFixture { + metadata: FileFixture; + inlineScript: FileFixture; +} + +export interface AppFixture { + metadata: FileFixture; +} + +export interface RawAppFixture { + metadata: FileFixture; + indexHtml: FileFixture; + indexJs: FileFixture; + [key: string]: FileFixture; +} + +// ============================================================================= +// Script Fixtures +// ============================================================================= + +/** + * Creates a script fixture (data structure, no disk I/O). + * See file header for cross-links to related helpers. + * + * Use this when you need fine-grained control over the script structure. + * For simple cases, use {@link createLocalScript} instead. + * + * @param name - Script name (without extension) + * @param language - Script language + * @param content - Optional custom script content + * @returns Script fixture with content and metadata files + * + * @example + * const fixture = createScriptFixture("my_script", "bun"); + * const fixture = createScriptFixture("custom", "python3", "def main(): return 42"); + * + * @keywords script fixture, create script, local script + */ +export function createScriptFixture( + name: string, + language: "python3" | "deno" | "bun" | "bash" | "go" | "postgresql" = "bun", + content?: string +): ScriptFixture { + const extensions: Record = { + python3: ".py", + deno: ".ts", + bun: ".ts", + bash: ".sh", + go: ".go", + postgresql: ".sql", + }; + + const ext = extensions[language]; + const defaultContent: Record = { + python3: `def main():\n return "Hello from ${name}"`, + deno: `export async function main() {\n return "Hello from ${name}";\n}`, + bun: `export async function main() {\n return "Hello from ${name}";\n}`, + bash: `#!/bin/bash\necho "Hello from ${name}"`, + go: `package inner\n\nfunc main() string {\n return "Hello from ${name}"\n}`, + postgresql: `-- ${name}\nSELECT 'Hello from ${name}';`, + }; + + return { + contentFile: { + path: `${name}${ext}`, + content: content ?? defaultContent[language], + }, + metadataFile: { + path: `${name}.script.yaml`, + content: `summary: "${name} script" +description: "A ${language} script for testing" +schema: + $schema: "https://json-schema.org/draft/2020-12/schema" + type: object + properties: {} + required: [] +is_template: false +lock: "" +kind: script +`, + }, + }; +} + +// ============================================================================= +// Flow Fixtures +// ============================================================================= + +/** + * Creates a flow fixture (data structure, no disk I/O). + * See file header for cross-links to related helpers. + * + * TODO: Add optional params: language, summary, description + * + * Use this when you need fine-grained control over the flow structure. + * For simple cases, use {@link createLocalFlow} instead. + * + * @param name - Flow name + * @param inlineScriptContent - Optional custom inline script content + * @returns Flow fixture with metadata and inline script + * + * @example + * const fixture = createFlowFixture("my_flow"); + * + * @keywords flow fixture, create flow, local flow + */ +export function createFlowFixture( + name: string, + inlineScriptContent?: string +): FlowFixture { + const flowSuffix = getFolderSuffix("flow"); + const metadataFile = getMetadataFileName("flow", "yaml"); + + const scriptContent = + inlineScriptContent ?? + `export async function main() {\n return "Hello from flow ${name}";\n}`; + + return { + metadata: { + path: `${name}${flowSuffix}/${metadataFile}`, + content: `summary: "${name} flow" +description: "A flow for testing" +value: + modules: + - id: a + value: + type: rawscript + content: | + ${scriptContent.split("\n").join("\n ")} + language: bun + input_transforms: {} +schema: + $schema: "https://json-schema.org/draft/2020-12/schema" + type: object + properties: {} + required: [] +`, + }, + inlineScript: { + path: `${name}${flowSuffix}/a.inline_script.ts`, + content: scriptContent, + }, + }; +} + +// ============================================================================= +// App Fixtures +// ============================================================================= + +/** + * Creates an app fixture (data structure, no disk I/O). + * See file header for cross-links to related helpers. + * + * TODO: Add optional params: inlineScriptContent, summary, grid + * + * Use this when you need fine-grained control over the app structure. + * For simple cases, use {@link createLocalApp} instead. + * + * @param name - App name + * @returns App fixture with metadata + * + * @example + * const fixture = createAppFixture("my_app"); + * + * @keywords app fixture, create app, local app + */ +export function createAppFixture(name: string): AppFixture { + const appSuffix = getFolderSuffix("app"); + const metadataFile = getMetadataFileName("app", "yaml"); + + return { + metadata: { + path: `${name}${appSuffix}/${metadataFile}`, + content: `summary: "${name} app" +value: + type: app + grid: + - id: button1 + data: + type: buttoncomponent + componentInput: + type: runnable + runnable: + type: runnableByName + inlineScript: + content: | + export async function main() { + return "hello from app"; + } + language: bun + hiddenInlineScripts: [] + css: {} + norefreshbar: false +policy: + on_behalf_of: null + on_behalf_of_email: null + triggerables: {} + execution_mode: viewer +`, + }, + }; +} + +// ============================================================================= +// Raw App Fixtures +// ============================================================================= + +/** + * Creates a raw app fixture (data structure, no disk I/O). + * See file header for cross-links to related helpers. + * + * TODO: Add optional params: inlineScriptContent, htmlContent, jsContent + * + * Raw apps are React/frontend apps with separate inline scripts. + * Use this when you need fine-grained control over the raw app structure. + * For simple cases, use {@link createLocalRawApp} instead. + * + * @param name - Raw app name + * @returns Raw app fixture with metadata and frontend files + * + * @example + * const fixture = createRawAppFixture("my_raw_app"); + * + * @keywords raw app fixture, create raw app, local raw app, react app + */ +export function createRawAppFixture(name: string): RawAppFixture { + const rawAppSuffix = getFolderSuffix("raw_app"); + const metadataFile = getMetadataFileName("raw_app", "yaml"); + + return { + metadata: { + path: `${name}${rawAppSuffix}/${metadataFile}`, + content: `summary: "${name} raw app" +policy: + execution_mode: publisher + triggerables: {} +`, + }, + indexHtml: { + path: `${name}${rawAppSuffix}/index.html`, + content: ` + +${name} +
+`, + }, + indexJs: { + path: `${name}${rawAppSuffix}/index.tsx`, + content: `import React from 'react' +import { createRoot } from 'react-dom/client' + +const App = () =>

${name}

+ +const root = createRoot(document.getElementById('root')!) +root.render() +`, + }, + packageJson: { + path: `${name}${rawAppSuffix}/package.json`, + content: `{ + "dependencies": { + "react": "19.0.0", + "react-dom": "19.0.0" + } +}`, + }, + inlineScript: { + path: `${name}${rawAppSuffix}/inline_scripts/a.inline_script.ts`, + content: `export async function main(x: string) { + return x +} +`, + }, + inlineScriptLock: { + path: `${name}${rawAppSuffix}/inline_scripts/a.inline_script.lock`, + content: ``, + }, + }; +} + +// ============================================================================= +// Local Creation Functions (Fixture + Write to Disk) +// ============================================================================= + +/** + * Creates a script on the local filesystem. + * See file header for cross-links to related helpers. + * + * This is a convenience function that creates a script fixture and writes it to disk. + * + * @param tempDir - Base directory for the test workspace + * @param path - Relative path within the workspace (e.g., "f/test") + * @param name - Script name (without extension) + * @param language - Script language (default: "bun") + * @param content - Optional custom script content + * + * @example + * await createLocalScript(tempDir, "f/test", "my_script"); + * await createLocalScript(tempDir, "f/test", "custom", "python3", "def main(): return 42"); + * + * @keywords create local script, local script, write script, script on disk + */ +export async function createLocalScript( + tempDir: string, + path: string, + name: string, + language: "python3" | "deno" | "bun" | "bash" | "go" | "postgresql" = "bun", + content?: string +): Promise { + const fixture = createScriptFixture(name, language, content); + await mkdir(`${tempDir}/${path}`, { recursive: true }); + await writeFile( + `${tempDir}/${path}/${fixture.contentFile.path}`, + fixture.contentFile.content, + "utf-8" + ); + await writeFile( + `${tempDir}/${path}/${fixture.metadataFile.path}`, + fixture.metadataFile.content, + "utf-8" + ); +} + +/** + * Creates a flow on the local filesystem. + * See file header for cross-links to related helpers. + * + * This is a convenience function that creates a flow fixture and writes it to disk. + * + * @param tempDir - Base directory for the test workspace + * @param path - Relative path within the workspace (e.g., "f/test") + * @param name - Flow name + * @param inlineScriptContent - Optional custom inline script content + * + * @example + * await createLocalFlow(tempDir, "f/test", "my_flow"); + * + * @keywords create local flow, local flow, write flow, flow on disk + */ +export async function createLocalFlow( + tempDir: string, + path: string, + name: string, + inlineScriptContent?: string +): Promise { + const fixture = createFlowFixture(name, inlineScriptContent); + const flowDir = `${tempDir}/${path}/${name}${getFolderSuffix("flow")}`; + await mkdir(flowDir, { recursive: true }); + + for (const file of Object.values(fixture)) { + const fullPath = `${tempDir}/${path}/${file.path}`; + await writeFile(fullPath, file.content, "utf-8"); + } +} + +/** + * Creates an app on the local filesystem. + * See file header for cross-links to related helpers. + * + * This is a convenience function that creates an app fixture and writes it to disk. + * + * @param tempDir - Base directory for the test workspace + * @param path - Relative path within the workspace (e.g., "f/test") + * @param name - App name + * + * @example + * await createLocalApp(tempDir, "f/test", "my_app"); + * + * @keywords create local app, local app, write app, app on disk + */ +export async function createLocalApp( + tempDir: string, + path: string, + name: string +): Promise { + const fixture = createAppFixture(name); + const appDir = `${tempDir}/${path}/${name}${getFolderSuffix("app")}`; + await mkdir(appDir, { recursive: true }); + + for (const file of Object.values(fixture)) { + const fullPath = `${tempDir}/${path}/${file.path}`; + await writeFile(fullPath, file.content, "utf-8"); + } +} + +/** + * Creates a raw app on the local filesystem. + * See file header for cross-links to related helpers. + * + * Raw apps are React/frontend apps with separate inline scripts. + * This is a convenience function that creates a raw app fixture and writes it to disk. + * + * @param tempDir - Base directory for the test workspace + * @param path - Relative path within the workspace (e.g., "f/test") + * @param name - Raw app name + * + * @example + * await createLocalRawApp(tempDir, "f/test", "my_raw_app"); + * + * @keywords create local raw app, local raw app, write raw app, raw app on disk, react app + */ +export async function createLocalRawApp( + tempDir: string, + path: string, + name: string +): Promise { + const fixture = createRawAppFixture(name); + const rawAppSuffix = getFolderSuffix("raw_app"); + const appDir = `${tempDir}/${path}/${name}${rawAppSuffix}`; + await mkdir(`${appDir}/inline_scripts`, { recursive: true }); + + for (const file of Object.values(fixture)) { + const fullPath = `${tempDir}/${path}/${file.path}`; + const dir = fullPath.substring(0, fullPath.lastIndexOf("/")); + await mkdir(dir, { recursive: true }); + await writeFile(fullPath, file.content, "utf-8"); + } +} + +// ============================================================================= +// Resource Fixtures (Variables, Resources, Schedules, etc.) +// ============================================================================= + +/** + * Creates a resource fixture. + * + * @keywords resource fixture, create resource + */ +export function createResourceFixture( + name: string, + resourceType: string, + value: Record +): FileFixture { + return { + path: `${name}.resource.yaml`, + content: `resource_type: "${resourceType}" +value: +${Object.entries(value) + .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`) + .join("\n")} +`, + }; +} + +/** + * Creates a variable fixture. + * + * @keywords variable fixture, create variable + */ +export function createVariableFixture( + name: string, + value: string, + isSecret: boolean = false +): FileFixture { + return { + path: `${name}.variable.yaml`, + content: `value: "${value}" +is_secret: ${isSecret} +description: "Variable ${name} for testing" +`, + }; +} + +/** + * Creates a schedule fixture. + * + * @keywords schedule fixture, create schedule + */ +export function createScheduleFixture( + name: string, + scriptPath: string, + schedule: string = "0 * * * *" +): FileFixture { + return { + path: `${name}.schedule.yaml`, + content: `path: "${name}" +schedule: "${schedule}" +script_path: "${scriptPath}" +is_flow: false +args: {} +enabled: true +timezone: "UTC" +`, + }; +} diff --git a/cli/test/unified_generate_metadata.test.ts b/cli/test/unified_generate_metadata.test.ts new file mode 100644 index 0000000000..5cd240dce9 --- /dev/null +++ b/cli/test/unified_generate_metadata.test.ts @@ -0,0 +1,451 @@ +/** + * Unified generate-metadata Command Tests + * + * Tests the new unified `generate-metadata` command that processes + * scripts, flows, and apps together. + */ + +import { expect, test, describe } from "bun:test"; +import { withTestBackend } from "./test_backend.ts"; +import { addWorkspace } from "../workspace.ts"; +import { writeFile } from "node:fs/promises"; +import { + createLocalScript, + createLocalFlow, + createLocalApp, + createLocalRawApp, +} from "./test_fixtures.ts"; + +/** + * Helper to set up a workspace with wmill.yaml + */ +async function setupWorkspace(backend: any, tempDir: string, workspaceName: string) { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: workspaceName, + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +excludes: []`, "utf-8"); +} + +// ============================================================================= +// Main test: processes scripts, flows, and apps together +// ============================================================================= + +test("generate-metadata: processes scripts, flows, and apps together", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "unified_all_test"); + + // Create one of each type + await createLocalScript(tempDir, "f/test", "my_script"); + await createLocalFlow(tempDir, "f/test", "my_flow"); + await createLocalApp(tempDir, "f/test", "my_app"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "unified_all_test" + ); + + expect(result.code).toEqual(0); + // Should find stale items + expect(result.stdout).toContain("Found"); + expect(result.stdout).toContain("stale metadata"); + }); +}); + +// ============================================================================= +// Flag tests +// ============================================================================= + +describe("generate-metadata flags", () => { + test("--includes filters to specific paths", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "includes_test"); + + // Create two scripts in different folders + await createLocalScript(tempDir, "f/included", "script_a"); + await createLocalScript(tempDir, "f/excluded", "script_b"); + + // Run with --includes to only process f/included + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "-i", "f/included/**"], + tempDir, + "includes_test" + ); + + expect(result.code).toEqual(0); + // Should only mention the included script + const output = result.stdout + result.stderr; + expect(output).toContain("script_a"); + expect(output).not.toContain("script_b"); + }); + }); + + test("--excludes filters out specific paths", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "excludes_test"); + + // Create two scripts + await createLocalScript(tempDir, "f/keep", "script_keep"); + await createLocalScript(tempDir, "f/skip", "script_skip"); + + // Run with --excludes to skip f/skip + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "-e", "f/skip/**"], + tempDir, + "excludes_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("script_keep"); + expect(output).not.toContain("script_skip"); + }); + }); + + test("--dry-run shows stale items without updating", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "dry_run_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + // Run with --dry-run + const result = await backend.runCLICommand( + ["generate-metadata", "--dry-run"], + tempDir, + "dry_run_test" + ); + + expect(result.code).toEqual(0); + // Should show stale items (Scripts section header) + expect(result.stdout).toContain("Scripts"); + expect(result.stdout).toContain("my_script"); + // Should NOT show "Done" (didn't actually update) + expect(result.stdout).not.toContain("Done"); + + // Run again without --dry-run to verify it would still be stale + const result2 = await backend.runCLICommand( + ["generate-metadata", "--dry-run"], + tempDir, + "dry_run_test" + ); + expect(result2.stdout).toContain("Scripts"); + }); + }); + + test("--lock-only only regenerates locks", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "lock_only_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "--lock-only"], + tempDir, + "lock_only_test" + ); + + expect(result.code).toEqual(0); + }); + }); + + test("--schema-only only processes scripts (skips flows and apps)", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "schema_only_test"); + + // Create one of each type + await createLocalScript(tempDir, "f/test", "my_script"); + await createLocalFlow(tempDir, "f/test", "my_flow"); + await createLocalApp(tempDir, "f/test", "my_app"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "--schema-only"], + tempDir, + "schema_only_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + // Should show "Checking scripts..." only + expect(output).toContain("Checking scripts..."); + // Should find the script (Scripts section header) + expect(output).toContain("Scripts"); + // Should NOT find flows or apps + expect(output).not.toContain("Flows"); + expect(output).not.toContain("Apps"); + }); + }); + + test("--skip-scripts skips scripts", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "skip_scripts_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + await createLocalFlow(tempDir, "f/test", "my_flow"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "--skip-scripts"], + tempDir, + "skip_scripts_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + // Should NOT contain script + expect(output).not.toContain("Scripts"); + // Should contain flow + expect(output).toContain("Flows"); + }); + }); + + test("--skip-flows skips flows", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "skip_flows_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + await createLocalFlow(tempDir, "f/test", "my_flow"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "--skip-flows"], + tempDir, + "skip_flows_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + // Should contain script + expect(output).toContain("Scripts"); + // Should NOT contain flow + expect(output).not.toContain("Flows"); + }); + }); + + test("--skip-apps skips apps", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "skip_apps_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + await createLocalApp(tempDir, "f/test", "my_app"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "--skip-apps"], + tempDir, + "skip_apps_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + // Should contain script + expect(output).toContain("Scripts"); + // Should NOT contain app + expect(output).not.toContain("Apps"); + }); + }); + + test("shows 'All metadata up-to-date' when nothing to update", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "uptodate_test"); + + // Create a script and run generate-metadata twice + await createLocalScript(tempDir, "f/test", "my_script"); + + // First run - generates metadata + await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "uptodate_test" + ); + + // Second run - should be up-to-date + const result = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "uptodate_test" + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain("up-to-date"); + }); + }); + + test("skipping all types shows warning", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "skip_all_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--skip-scripts", "--skip-flows", "--skip-apps"], + tempDir, + "skip_all_test" + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain("Nothing to check"); + }); + }); +}); + +// ============================================================================= +// Folder argument tests +// ============================================================================= + +describe("generate-metadata folder argument", () => { + test("filters to specific script folder", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "folder_script_test"); + + // Create scripts in different folders + await createLocalScript(tempDir, "f/included", "script_a"); + await createLocalScript(tempDir, "f/excluded", "script_b"); + + // Run with folder argument + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/included/script_a.ts"], + tempDir, + "folder_script_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("script_a"); + expect(output).not.toContain("script_b"); + }); + }); + + test("filters to specific flow folder", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "folder_flow_test"); + + // Create flows in different folders + await createLocalFlow(tempDir, "f/included", "flow_a"); + await createLocalFlow(tempDir, "f/excluded", "flow_b"); + + // Run with folder argument (flow folder path - uses .flow suffix by default) + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/included/flow_a.flow"], + tempDir, + "folder_flow_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("flow_a"); + expect(output).not.toContain("flow_b"); + }); + }); + + test("filters to specific app folder", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "folder_app_test"); + + // Create apps in different folders + await createLocalApp(tempDir, "f/included", "app_a"); + await createLocalApp(tempDir, "f/excluded", "app_b"); + + // Run with folder argument (app folder path - uses .app suffix by default) + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/included/app_a.app"], + tempDir, + "folder_app_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("app_a"); + expect(output).not.toContain("app_b"); + }); + }); + + test("shows up-to-date when folder has no stale items", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "folder_uptodate_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + // First run to generate metadata + await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "folder_uptodate_test" + ); + + // Second run with folder - should be up-to-date + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/test/my_script.ts"], + tempDir, + "folder_uptodate_test" + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain("up-to-date"); + }); + }); + + test("trailing slash is stripped (matches deprecated behavior)", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "trailing_slash_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + // Run with trailing slash + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/test/my_script.ts/"], + tempDir, + "trailing_slash_test" + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain("my_script"); + }); + }); + + test("parent folder matches all children", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "parent_folder_test"); + + // Create scripts in nested folders + await createLocalScript(tempDir, "f/parent", "script_a"); + await createLocalScript(tempDir, "f/parent/child", "script_b"); + await createLocalScript(tempDir, "f/other", "script_c"); + + // Run with parent folder - should match both scripts in f/parent tree + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/parent"], + tempDir, + "parent_folder_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("script_a"); + expect(output).toContain("script_b"); + expect(output).not.toContain("script_c"); + }); + }); + + test("non-existent folder shows up-to-date", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "nonexistent_folder_test"); + + await createLocalScript(tempDir, "f/exists", "my_script"); + + // Run with non-existent folder + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/does_not_exist"], + tempDir, + "nonexistent_folder_test" + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain("up-to-date"); + }); + }); +}); From 2e430c4c0b8540df7b6997434a7a9f9134858026 Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Fri, 13 Mar 2026 04:21:15 -0400 Subject: [PATCH 033/204] feat: add GitHub Enterprise Server (GHES) support for GitHub App git sync (#8344) * feat: add GitHub Enterprise Server (GHES) support for GitHub App git sync Add a self-managed GitHub App mode alongside the existing managed (stats.windmill.dev) mode, enabling git sync for GitHub Enterprise Server and custom GitHub App installations. Backend: - Parameterize GitHub API URLs (no more hardcoded github.com) - Add GITHUB_ENTERPRISE_APP_SETTING global setting - Add OpenAPI specs for ghes_installation_callback and ghes_config endpoints Frontend: - Add instance settings UI for configuring self-managed GitHub Apps with setup instructions and validation - GHES installation flow in gh_success page - Dynamic installation URL based on GHES config - Increase git sync test connection timeout to 10s - Block "Review changes" save when settings are invalid EE companion PR: windmill-labs/windmill-ee-private# Co-Authored-By: Claude Opus 4.6 * chore: update ee-repo-ref to c74c86b78a66b976fd9968b21f77903723e668ec This commit updates the EE repository reference after PR #459 was merged in windmill-ee-private. Previous ee-repo-ref: 45e4550110799525b5502cf072c8af8132492638 New ee-repo-ref: c74c86b78a66b976fd9968b21f77903723e668ec Automated by sync-ee-ref workflow. * sqlx --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: windmill-internal-app[bot] Co-authored-by: Ruben Fiszel --- ...096bd0777da52c05e23262572089e0c3c6c46.json | 16 ++ ...b5eea1780226698e419ac59ba55012296913d.json | 28 +++ ...d531b05eaa17fccc599306eb1a96a65ee761.json} | 10 +- ...e98369e1d93514e1618723a34bf96080d4ebc.json | 16 -- ...95c7cda0cba0b119d4fdb8a0dea1b28a1d560.json | 22 --- backend/ee-repo-ref.txt | 2 +- backend/windmill-api/openapi.yaml | 52 ++++++ backend/windmill-common/src/git_sync_oss.rs | 11 +- .../windmill-common/src/global_settings.rs | 1 + .../src/lib/components/InstanceSetting.svelte | 3 + .../lib/components/InstanceSettings.svelte | 19 +- .../lib/components/SuperadminSettings.svelte | 4 +- .../components/SuperadminSettingsInner.svelte | 4 +- .../git_sync/GitSyncContext.svelte.ts | 4 +- .../src/lib/components/instanceSettings.ts | 31 +++- .../instanceSettings/GhesAppSettings.svelte | 172 ++++++++++++++++++ frontend/src/lib/githubApp.ts | 20 +- frontend/src/routes/gh_success/+page.svelte | 70 ++++++- 18 files changed, 426 insertions(+), 59 deletions(-) create mode 100644 backend/.sqlx/query-48b394bd9ca63d33a7ea97113b0096bd0777da52c05e23262572089e0c3c6c46.json create mode 100644 backend/.sqlx/query-ad5fc9212a123a8328397496ef3b5eea1780226698e419ac59ba55012296913d.json rename backend/.sqlx/{query-0ee14619dd81df460b2b8cc6df2b89646279f77469c35deffca8e17a11d7f6c8.json => query-ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761.json} (55%) delete mode 100644 backend/.sqlx/query-b4d48c820bf41619bffa8f62367e98369e1d93514e1618723a34bf96080d4ebc.json delete mode 100644 backend/.sqlx/query-be00ac55e8668a0ed3befda7d8595c7cda0cba0b119d4fdb8a0dea1b28a1d560.json create mode 100644 frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte diff --git a/backend/.sqlx/query-48b394bd9ca63d33a7ea97113b0096bd0777da52c05e23262572089e0c3c6c46.json b/backend/.sqlx/query-48b394bd9ca63d33a7ea97113b0096bd0777da52c05e23262572089e0c3c6c46.json new file mode 100644 index 0000000000..18a4671616 --- /dev/null +++ b/backend/.sqlx/query-48b394bd9ca63d33a7ea97113b0096bd0777da52c05e23262572089e0c3c6c46.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE workspace_settings\n SET git_app_installations = (\n SELECT jsonb_agg(\n CASE\n WHEN (elem->>'installation_id')::bigint = $2 THEN $1::jsonb\n ELSE elem\n END\n )\n FROM jsonb_array_elements(git_app_installations) AS elem\n )\n WHERE workspace_id = $3\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "48b394bd9ca63d33a7ea97113b0096bd0777da52c05e23262572089e0c3c6c46" +} diff --git a/backend/.sqlx/query-ad5fc9212a123a8328397496ef3b5eea1780226698e419ac59ba55012296913d.json b/backend/.sqlx/query-ad5fc9212a123a8328397496ef3b5eea1780226698e419ac59ba55012296913d.json new file mode 100644 index 0000000000..33ed0b518b --- /dev/null +++ b/backend/.sqlx/query-ad5fc9212a123a8328397496ef3b5eea1780226698e419ac59ba55012296913d.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n (elem->>'installation_id')::bigint as installation_id,\n elem->>'github_base_url' as github_base_url\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "installation_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "github_base_url", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "ad5fc9212a123a8328397496ef3b5eea1780226698e419ac59ba55012296913d" +} diff --git a/backend/.sqlx/query-0ee14619dd81df460b2b8cc6df2b89646279f77469c35deffca8e17a11d7f6c8.json b/backend/.sqlx/query-ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761.json similarity index 55% rename from backend/.sqlx/query-0ee14619dd81df460b2b8cc6df2b89646279f77469c35deffca8e17a11d7f6c8.json rename to backend/.sqlx/query-ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761.json index ac882aec64..4bb689c519 100644 --- a/backend/.sqlx/query-0ee14619dd81df460b2b8cc6df2b89646279f77469c35deffca8e17a11d7f6c8.json +++ b/backend/.sqlx/query-ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n (elem->>'installation_id')::bigint as installation_id,\n elem->>'account_id' as account_id\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n ", + "query": "\n SELECT\n (elem->>'installation_id')::bigint as installation_id,\n elem->>'account_id' as account_id,\n elem->>'github_base_url' as github_base_url\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n ", "describe": { "columns": [ { @@ -12,6 +12,11 @@ "ordinal": 1, "name": "account_id", "type_info": "Text" + }, + { + "ordinal": 2, + "name": "github_base_url", + "type_info": "Text" } ], "parameters": { @@ -20,9 +25,10 @@ ] }, "nullable": [ + null, null, null ] }, - "hash": "0ee14619dd81df460b2b8cc6df2b89646279f77469c35deffca8e17a11d7f6c8" + "hash": "ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761" } diff --git a/backend/.sqlx/query-b4d48c820bf41619bffa8f62367e98369e1d93514e1618723a34bf96080d4ebc.json b/backend/.sqlx/query-b4d48c820bf41619bffa8f62367e98369e1d93514e1618723a34bf96080d4ebc.json deleted file mode 100644 index 0efca7e867..0000000000 --- a/backend/.sqlx/query-b4d48c820bf41619bffa8f62367e98369e1d93514e1618723a34bf96080d4ebc.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE workspace_settings\n SET git_app_installations = (\n SELECT jsonb_agg(\n CASE\n WHEN (elem->>'installation_id')::bigint = $2 THEN $1::jsonb\n ELSE elem\n END\n )\n FROM jsonb_array_elements(git_app_installations) AS elem\n )\n WHERE workspace_id = $3\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Jsonb", - "Int8", - "Text" - ] - }, - "nullable": [] - }, - "hash": "b4d48c820bf41619bffa8f62367e98369e1d93514e1618723a34bf96080d4ebc" -} diff --git a/backend/.sqlx/query-be00ac55e8668a0ed3befda7d8595c7cda0cba0b119d4fdb8a0dea1b28a1d560.json b/backend/.sqlx/query-be00ac55e8668a0ed3befda7d8595c7cda0cba0b119d4fdb8a0dea1b28a1d560.json deleted file mode 100644 index 182fa4e788..0000000000 --- a/backend/.sqlx/query-be00ac55e8668a0ed3befda7d8595c7cda0cba0b119d4fdb8a0dea1b28a1d560.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n (elem->>'installation_id')::bigint as installation_id\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "installation_id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "be00ac55e8668a0ed3befda7d8595c7cda0cba0b119d4fdb8a0dea1b28a1d560" -} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index ee34a47736..87f42ac45b 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -344e2aa60d8cafe08b8c57445d2f9555bad7625a +c74c86b78a66b976fd9968b21f77903723e668ec diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 3eb3f7f2cf..ad494ee0e1 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1939,6 +1939,58 @@ paths: "200": description: Successfully imported the installation + /w/{workspace}/github_app/ghes_installation_callback: + post: + summary: GHES installation callback + description: Register a self-managed GitHub App installation from GitHub Enterprise Server + operationId: ghesInstallationCallback + tags: + - Git Sync + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - installation_id + properties: + installation_id: + type: integer + format: int64 + description: The GitHub App installation ID from GHES + responses: + "200": + description: GHES installation registered successfully + + /github_app/ghes_config: + get: + summary: Get GHES app config + description: Returns the GitHub Enterprise Server app configuration (without private key) for constructing the installation URL + operationId: getGhesConfig + tags: + - Git Sync + responses: + "200": + description: GHES app configuration + content: + application/json: + schema: + type: object + properties: + base_url: + type: string + app_slug: + type: string + client_id: + type: string + required: + - base_url + - app_slug + - client_id + /users/accept_invite: post: summary: accept invite to workspace diff --git a/backend/windmill-common/src/git_sync_oss.rs b/backend/windmill-common/src/git_sync_oss.rs index a350b74156..4cd7a7c3d1 100644 --- a/backend/windmill-common/src/git_sync_oss.rs +++ b/backend/windmill-common/src/git_sync_oss.rs @@ -21,15 +21,14 @@ pub fn prepend_token_to_github_url( ) -> crate::error::Result { let url = Url::parse(github_url)?; - if url.host_str() != Some("github.com") { - return Err(crate::error::Error::BadRequest( - "Invalid: not a github URL".to_string(), - )); - } + let host = url.host_str().ok_or_else(|| { + crate::error::Error::BadRequest("Invalid GitHub URL: no host".to_string()) + })?; Ok(format!( - "https://x-access-token:{}@github.com{}", + "https://x-access-token:{}@{}{}", installation_token, + host, url.path() )) } diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 6d2f225479..1d2aeafce8 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -58,6 +58,7 @@ pub const OTEL_TRACING_PROXY_SETTING: &str = "otel_tracing_proxy"; pub const APP_WORKSPACED_ROUTE_SETTING: &str = "app_workspaced_route"; pub const SECRET_BACKEND_SETTING: &str = "secret_backend"; pub const MIN_KEEP_ALIVE_VERSION_SETTING: &str = "min_keep_alive_version"; +pub const GITHUB_ENTERPRISE_APP_SETTING: &str = "github_enterprise_app"; pub const ENV_SETTINGS: &[&str] = &[ "DISABLE_NSJAIL", diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index 663bb8aa75..1c69ac8a80 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -22,6 +22,7 @@ import CriticalAlertChannels from './instanceSettings/CriticalAlertChannels.svelte' import SmtpSettings from './instanceSettings/SmtpSettings.svelte' import SecretBackendConfig from './instanceSettings/SecretBackendConfig.svelte' + import GhesAppSettings from './instanceSettings/GhesAppSettings.svelte' import IndexerMemorySettings from './instanceSettings/IndexerMemorySettings.svelte' import IndexerJobIndexSettings from './instanceSettings/IndexerJobIndexSettings.svelte' import IndexerLogIndexSettings from './instanceSettings/IndexerLogIndexSettings.svelte' @@ -716,6 +717,8 @@ {:else if setting.fieldType == 'secret_backend'} + {:else if setting.fieldType == 'github_enterprise_app'} + {/if} {#if hasError} diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index b9d355f323..5f5ac925f4 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -30,6 +30,7 @@ quickSetup?: boolean yamlMode?: boolean hasUnsavedChanges?: boolean + hasAnyInvalid?: boolean } let { @@ -40,7 +41,8 @@ onNavigateToTab, quickSetup = false, yamlMode = $bindable(false), - hasUnsavedChanges = $bindable(false) + hasUnsavedChanges = $bindable(false), + hasAnyInvalid = $bindable(false) }: Props = $props() let values: Writable> = writable({}) @@ -77,7 +79,8 @@ smtp_settings: {}, otel: {}, indexer_settings: {}, - critical_error_channels: [] + critical_error_channels: [], + github_enterprise_app: {} } function applyFormDefaults(vals: Record): void { @@ -438,6 +441,10 @@ return result }) + $effect(() => { + hasAnyInvalid = Object.values(invalidCategories).some(Boolean) + }) + export function isDirty(category: string): boolean { return dirtyCategories[category] ?? false } @@ -601,7 +608,8 @@ secret_backend: ['token'], object_store_cache_config: ['secret_key', 'serviceAccountKey'], custom_instance_pg_databases: ['user_pwd'], - rsa_keys: ['private_key'] + rsa_keys: ['private_key'], + github_enterprise_app: ['private_key'] } /** Returns SENSITIVE_UNCHANGED if the value is non-empty and matches the initial */ @@ -1008,6 +1016,11 @@ description="Configure where secrets (secret variables) are stored." link="https://www.windmill.dev/docs/core_concepts/workspace_secret_encryption" /> + {:else if category == 'GitHub Enterprise App'} + {:else if category == 'Auth/OAuth/SAML'} @@ -159,7 +161,7 @@ options={{ right: 'Unified' }} size="xs" /> - + {/snippet}
diff --git a/frontend/src/lib/components/SuperadminSettingsInner.svelte b/frontend/src/lib/components/SuperadminSettingsInner.svelte index 1f33e768d8..37fca0037b 100644 --- a/frontend/src/lib/components/SuperadminSettingsInner.svelte +++ b/frontend/src/lib/components/SuperadminSettingsInner.svelte @@ -46,7 +46,8 @@ closeDrawer, showHeaderInfo = true, yamlMode = $bindable(false), - hasUnsavedChanges = $bindable(false) + hasUnsavedChanges = $bindable(false), + hasAnyInvalid = $bindable(false) } = $props() function removeHash() { @@ -510,6 +511,7 @@ hideTabs bind:yamlMode bind:hasUnsavedChanges + bind:hasAnyInvalid tab={instanceSettingsCategory} {authSubTab} {closeDrawer} diff --git a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts index 1940296534..834982c3f3 100644 --- a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts +++ b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts @@ -564,8 +564,8 @@ export function createGitSyncContext(workspace: string) { // Use JobManager for polling await jobManager.runWithProgress(() => Promise.resolve(jobId), { workspace, - timeout: 5000, - timeoutMessage: 'Git sync test job timed out after 5s', + timeout: 10000, + timeoutMessage: 'Git sync test job timed out after 10s', onProgress: (status) => { gitSyncTestJobs[idx].status = status.status === 'success' diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 0c0fd51578..450555680c 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -51,6 +51,7 @@ export interface Setting { | 'otel' | 'otel_tracing_proxy' | 'secret_backend' + | 'github_enterprise_app' storage: SettingStorage advancedToggle?: { label: string @@ -665,6 +666,23 @@ export const settings: Record = { storage: 'setting', ee_only: 'HashiCorp Vault integration is an Enterprise Edition feature' } + ], + 'GitHub Enterprise App': [ + { + label: 'GitHub Enterprise App', + description: + 'Configure a self-managed GitHub App for GitHub Enterprise Server (or any GitHub instance) to enable git sync without stats.windmill.dev.', + key: 'github_enterprise_app', + fieldType: 'github_enterprise_app', + storage: 'setting', + ee_only: '', + error: + 'When self-managed mode is enabled, Base URL, App ID, App Slug, and Private Key are required.', + isValid: (v: any) => { + if (!v?.self_managed) return true + return !!(v?.base_url && v?.app_id && v?.app_slug && v?.private_key) + } + } ] } @@ -772,6 +790,13 @@ export const instanceSettingsNavigationGroups = [ { title: 'Advanced', items: [ + { + id: 'github_enterprise_app', + label: 'GitHub Enterprise App', + aiId: 'instance-settings-github-enterprise-app', + aiDescription: 'Self-managed GitHub App for GitHub Enterprise Server git sync', + isEE: true + }, { id: 'private_hub', label: 'Private Hub', @@ -809,7 +834,8 @@ export const tabToCategoryMap: Record = { secret_storage: 'Secret Storage', object_storage: 'Object Storage', jobs: 'Jobs', - private_hub: 'Private Hub' + private_hub: 'Private Hub', + github_enterprise_app: 'GitHub Enterprise App' } export const tabToAuthSubTab: Record = { @@ -838,7 +864,8 @@ export const categoryToTabMap: Record = { 'Secret Storage': 'secret_storage', 'Object Storage': 'object_storage', Jobs: 'jobs', - 'Private Hub': 'private_hub' + 'Private Hub': 'private_hub', + 'GitHub Enterprise App': 'github_enterprise_app' } export interface SearchableSettingItem { diff --git a/frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte b/frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte new file mode 100644 index 0000000000..6ded7d82f2 --- /dev/null +++ b/frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte @@ -0,0 +1,172 @@ + + +
+ { + $values['github_enterprise_app'] = { + ...$values['github_enterprise_app'], + self_managed: !selfManaged + } + }} + /> + + {#if !selfManaged} +

+ Using the managed Windmill GitHub App via stats.windmill.dev. Enable self-managed mode to + configure your own GitHub App (required for GitHub Enterprise Server). +

+ {:else} +
+ How to create a GitHub App +
+

+ 1. On your GitHub instance, go to + Settings → Developer settings → GitHub Apps → New GitHub App. +

+

2. Fill in the required fields:

+
    +
  • + GitHub App name: e.g. windmill-sync (this becomes the app + slug) +
  • +
  • + Homepage URL: your Windmill instance URL +
  • +
  • + Callback URL: <your-windmill-url>/gh_success +
  • +
  • + Setup URL (optional): + <your-windmill-url>/gh_success with "Redirect on update" checked +
  • +
  • Uncheck Active under Webhook (not needed)
  • +
+

3. Set repository permissions:

+
    +
  • Contents: Read & write
  • +
  • Metadata: Read-only
  • +
+

+ 4. Under "Where can this GitHub App be installed?", choose + Any account (or restrict to your organization). +

+

+ 5. Click Create GitHub App. On the next page, note the + App ID and Client ID. +

+

+ 6. Scroll down and click Generate a private key. Save the + downloaded .pem file — paste its contents into the Private Key field below. +

+

+ 7. The App Slug is the URL-friendly name shown in the + app's URL (e.g. github.com/apps/windmill-sync). +

+

+ 8. The Base URL is your GitHub instance root (e.g. + https://github.com or https://github.mycompany.com). +

+
+
+ {/if} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
diff --git a/frontend/src/lib/githubApp.ts b/frontend/src/lib/githubApp.ts index 74ce0438ed..48c2cd1b56 100644 --- a/frontend/src/lib/githubApp.ts +++ b/frontend/src/lib/githubApp.ts @@ -1,4 +1,8 @@ -import { GitSyncService, type GetGlobalConnectedRepositoriesResponse } from '$lib/gen' +import { + GitSyncService, + type GetGlobalConnectedRepositoriesResponse, + type GetGhesConfigResponse +} from '$lib/gen' import { sendUserToast } from '$lib/toast' import { base } from '$lib/base' @@ -129,7 +133,19 @@ export async function loadGithubInstallations( }) ) - state.githubInstallationUrl = `https://github.com/apps/windmill-sync-helper/installations/new?state=${stateParam}` + // Check if GHES app is configured; if so, use GHES installation URL + try { + const ghesConfig: GetGhesConfigResponse = await GitSyncService.getGhesConfig() + if (ghesConfig?.base_url && ghesConfig?.app_slug) { + const ghesBaseUrl = ghesConfig.base_url.replace(/\/$/, '') + state.githubInstallationUrl = `${ghesBaseUrl}/apps/${ghesConfig.app_slug}/installations/new?state=${stateParam}` + } else { + state.githubInstallationUrl = `https://github.com/apps/windmill-sync-helper/installations/new?state=${stateParam}` + } + } catch { + // No GHES config — use default github.com URL + state.githubInstallationUrl = `https://github.com/apps/windmill-sync-helper/installations/new?state=${stateParam}` + } } catch (err) { const githubError = handleGitHubAppError(err, 'load installations') sendUserToast(`Failed to load GitHub installations: ${githubError.message}`, true) diff --git a/frontend/src/routes/gh_success/+page.svelte b/frontend/src/routes/gh_success/+page.svelte index 3886857126..eab7502757 100644 --- a/frontend/src/routes/gh_success/+page.svelte +++ b/frontend/src/routes/gh_success/+page.svelte @@ -13,6 +13,74 @@ onMount(async () => { const url = new URL(window.location.href) + + // Check for GHES flow: GitHub redirects back with installation_id and state params + // (no jwt_token param — that's the managed flow via stats.windmill.dev) + const jwt_token = url.searchParams.get('jwt_token') || '' + const stateParam = url.searchParams.get('state') || '' + + if (!jwt_token && stateParam) { + // GHES self-managed flow + await handleGhesFlow(url, stateParam) + } else { + // Managed flow (existing) + await handleManagedFlow(url) + } + }) + + async function handleGhesFlow(url: URL, stateParam: string) { + const installation_id_str = url.searchParams.get('installation_id') || '' + const installation_id = parseInt(installation_id_str, 10) + + let workspace_id: string + try { + const state = JSON.parse(decodeURIComponent(stateParam)) + workspace_id = state.workspace_id + } catch { + isLoading = false + errorMessage = 'Invalid state parameter' + sendUserToast('Invalid state parameter in the URL', true) + return + } + + if (!workspace_id || isNaN(installation_id)) { + isLoading = false + errorMessage = 'Missing or invalid required parameters' + sendUserToast('Missing or invalid required parameters in the URL', true) + return + } + + try { + const response = await fetch( + `/api/w/${workspace_id}/github_app/ghes_installation_callback`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + installation_id + }) + } + ) + + if (!response.ok) { + const errorData = await response.text() + throw new Error(errorData || 'Failed to complete GitHub Enterprise app installation') + } + + isSuccess = true + sendUserToast('GitHub Enterprise app installed successfully', false) + } catch (error) { + console.error('Error during GitHub Enterprise app installation:', error) + errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + sendUserToast(`Error installing GitHub Enterprise app: ${errorMessage}`, true) + } finally { + isLoading = false + } + } + + async function handleManagedFlow(url: URL) { const workspace_id = url.searchParams.get('workspace_id') || '' const installation_id_str = url.searchParams.get('installation_id') || '' const account_id = url.searchParams.get('account_id') || '' @@ -54,7 +122,7 @@ } finally { isLoading = false } - }) + }
From 2d5b72b3cea7430a5dd239aff59da17be76fad45 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 13 Mar 2026 08:50:19 +0000 Subject: [PATCH 034/204] chore: update vite/vitest to stable v8/v4 and remove legacy-peer-deps (#8349) Co-authored-by: Claude Opus 4.5 --- frontend/.npmrc | 2 +- frontend/package-lock.json | 1552 ++++++++++++++++++++++++++++++------ frontend/package.json | 4 +- 3 files changed, 1298 insertions(+), 260 deletions(-) diff --git a/frontend/.npmrc b/frontend/.npmrc index 521a9f7c07..8b13789179 100644 --- a/frontend/.npmrc +++ b/frontend/.npmrc @@ -1 +1 @@ -legacy-peer-deps=true + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cae11fc06e..6de3434311 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -147,9 +147,9 @@ "tar": "^7.5.4", "tslib": "^2.6.1", "typescript": "^5.5.0", - "vite": "^8.0.0-beta.16", + "vite": "^8.0.0", "vite-plugin-mkcert": "^1.17.5", - "vitest": "^4.1.0-beta.5", + "vitest": "^4.1.0", "vitest-browser-svelte": "^2.0.1" }, "optionalDependencies": { @@ -256,6 +256,33 @@ "tslib": "^2.3.1" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/runtime": { "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", @@ -736,23 +763,91 @@ "@codingame/monaco-vscode-view-title-bar-service-override": "25.0.0" } }, - "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "node_modules/@csstools/css-parser-algorithms": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.7.1.tgz", + "integrity": "sha512-2SJS42gxmACHgikc1WGesXLIT8d/q2l0UFM7TaEeIzdFCE/FPMtTiizcPGGJtlPo2xuQzY09OhrLTzRxqJqwGw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^2.4.1" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.4.1.tgz", + "integrity": "sha512-eQ9DIktFJBhGjioABJRtUucoWR2mwllurfnM8LuNGAqX3ViZXaUchqk+1s7jjtkFiT9ySdACsFEA3etErkALUg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": "^14 || ^16 || >=18" + } + }, + "node_modules/@csstools/media-query-list-parser": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.13.tgz", + "integrity": "sha512-XaHr+16KRU9Gf8XLi3q8kDlI18d5vzKSKCY510Vrtc9iNR0NJzbY9hhTmwhzYZj/ZwGL4VmB3TA9hJW0Um2qFA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1" + } + }, + "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==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", - "dev": true, + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", "license": "MIT", "optional": true, "dependencies": { @@ -760,10 +855,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "dev": true, + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", "license": "MIT", "optional": true, "dependencies": { @@ -1098,7 +1192,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1109,7 +1202,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1120,7 +1212,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1130,14 +1221,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1258,7 +1347,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": { @@ -1326,7 +1414,7 @@ "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==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^20.19.0 || >=22.12.0" @@ -1336,7 +1424,7 @@ "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==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -1389,7 +1477,7 @@ "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@popperjs/core": { @@ -1409,13 +1497,12 @@ "license": "SEE LICENSE IN LICENSE" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.6.tgz", - "integrity": "sha512-kvjTSWGcrv+BaR2vge57rsKiYdVR8V8CoS0vgKrc570qRBfty4bT+1X0z3j2TaVV+kAYzA0PjeB9+mdZyqUZlg==", + "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==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1426,13 +1513,12 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.6.tgz", - "integrity": "sha512-+tJhD21KvGNtUrpLXrZQlT+j5HZKiEwR2qtcZb3vNOUpvoT9QjEykr75ZW/Kr0W89gose/HVXU6351uVZD8Qvw==", + "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==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1443,13 +1529,12 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.6.tgz", - "integrity": "sha512-DKNhjMk38FAWaHwUt1dFR3rA/qRAvn2NUvSG2UGvxvlMxSmN/qqww/j4ABAbXhNRXtGQNmrAINMXRuwHl16ZHg==", + "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==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1460,13 +1545,12 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.6.tgz", - "integrity": "sha512-8TThsRkCPAnfyMBShxrGdtoOE6h36QepqRQI97iFaQSCRbHFWHcDHppcojZnzXoruuhPnjMEygzaykvPVJsMRg==", + "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==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1477,13 +1561,12 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.6.tgz", - "integrity": "sha512-ZfmFoOwPUZCWtGOVC9/qbQzfc0249FrRUOzV2XabSMUV60Crp211OWLQN1zmQAsRIVWRcEwhJ46Z1mXGo/L/nQ==", + "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==", "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1494,13 +1577,12 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.6.tgz", - "integrity": "sha512-ZsGzbNETxPodGlLTYHaCSGVhNN/rvkMDCJYHdT7PZr5jFJRmBfmDi2awhF64Dt2vxrJqY6VeeYSgOzEbHRsb7Q==", + "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==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1511,13 +1593,44 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.6.tgz", - "integrity": "sha512-elPpdevtCdUOqziemR86C4CSCr/5sUxalzDrf/CJdMT+kZt2C556as++qHikNOz0vuFf52h+GJNXZM08eWgGPQ==", + "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==", "cpu": [ "arm64" ], - "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "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==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "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==", + "cpu": [ + "s390x" + ], "license": "MIT", "optional": true, "os": [ @@ -1528,13 +1641,12 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.6.tgz", - "integrity": "sha512-IBwXsf56o3xhzAyaZxdM1CX8UFiBEUFCjiVUgny67Q8vPIqkjzJj0YKhd3TbBHanuxThgBa59f6Pgutg2OGk5A==", + "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==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1545,13 +1657,12 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.6.tgz", - "integrity": "sha512-vOk7G8V9Zm+8a6PL6JTpCea61q491oYlGtO6CvnsbhNLlKdf0bbCPytFzGQhYmCKZDKkEbmnkcIprTEGCURnwg==", + "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==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1562,13 +1673,12 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.6.tgz", - "integrity": "sha512-ASjEDI4MRv7XCQb2JVaBzfEYO98JKCGrAgoW6M03fJzH/ilCnC43Mb3ptB9q/lzsaahoJyIBoAGKAYEjUvpyvQ==", + "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==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1579,13 +1689,12 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.6.tgz", - "integrity": "sha512-mYa1+h2l6Zc0LvmwUh0oXKKYihnw/1WC73vTqw+IgtfEtv47A+rWzzcWwVDkW73+UDr0d/Ie/HRXoaOY22pQDw==", + "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==", "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1596,13 +1705,12 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.6.tgz", - "integrity": "sha512-e2ABskbNH3MRUBMjgxaMjYIw11DSwjLJxBII3UgpF6WClGLIh8A20kamc+FKH5vIaFVnYQInmcLYSUVpqMPLow==", + "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==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1613,13 +1721,12 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.6.tgz", - "integrity": "sha512-dJVc3ifhaRXxIEh1xowLohzFrlQXkJ66LepHm+CmSprTWgVrPa8Fx3OL57xwIqDEH9hufcKkDX2v65rS3NZyRA==", + "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==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1630,10 +1737,10 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.6.tgz", - "integrity": "sha512-Y0+JT8Mi1mmW08K6HieG315XNRu4L0rkfCpA364HtytjgiqYnMYRdFPcxRl+BQQqNXzecL2S9nii+RUpO93XIA==", - "dev": true, + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", + "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", + "devOptional": true, "license": "MIT" }, "node_modules/@rollup/rollup-linux-x64-gnu": { @@ -1682,7 +1789,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@streamparser/json": { @@ -1704,7 +1811,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz", "integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -1724,7 +1830,7 @@ "version": "2.53.4", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.4.tgz", "integrity": "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -1819,7 +1925,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.0.0.tgz", "integrity": "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "deepmerge": "^4.3.1", @@ -1926,7 +2032,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": { @@ -1948,7 +2053,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/d3": { @@ -2256,7 +2361,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/geojson": { @@ -2304,12 +2408,28 @@ "@types/unist": "*" } }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", @@ -2321,7 +2441,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/unist": { @@ -2657,16 +2776,16 @@ "license": "ISC" }, "node_modules/@vitest/expect": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0-beta.5.tgz", - "integrity": "sha512-rGZIMfkb+iEjL5+ulpWON5NY1y3bEc+I3btLlayKZ8wvsLhpS+wduc6DaNLWhFWxYwFXmnrLdMd9NRUE9cwySw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.0-beta.5", - "@vitest/utils": "4.1.0-beta.5", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" }, @@ -2675,13 +2794,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0-beta.5.tgz", - "integrity": "sha512-oUE5vFOY7onbFjFGSbWSV6ryDbVmymRUQOQf978k6ZA7EmyRykUISqTiVZVzv/dpPH3IhnctJtHdoglYNG+SPQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.0-beta.5", + "@vitest/spy": "4.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2690,7 +2809,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -2702,9 +2821,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0-beta.5.tgz", - "integrity": "sha512-QH/FGecnl2uwLveL/n1awB/nm/dJL9M0vMKVwmW0tvLAqTOp5GQQOypRuVvpXNFGhIl2bfpUSjruuDQlCBeFjw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", "dev": true, "license": "MIT", "dependencies": { @@ -2715,13 +2834,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0-beta.5.tgz", - "integrity": "sha512-9OP3INBsI9NhX0+n5syXtZOHooSFl+ctkDVbav6KlCO3CBV2g4TUPllYRAHsKWb6FvkpM2BVO8yO5jgqaZqnBA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.0-beta.5", + "@vitest/utils": "4.1.0", "pathe": "^2.0.3" }, "funding": { @@ -2736,14 +2855,14 @@ "license": "MIT" }, "node_modules/@vitest/snapshot": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0-beta.5.tgz", - "integrity": "sha512-ny6wFeFmA700AzTuB5qMSTKsLXtCz8m7CI2ESlcuGMrI7d9kdVUVD6ziRt0Hp2M3C3jIWvd5C6f4ZczAEFoytQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0-beta.5", - "@vitest/utils": "4.1.0-beta.5", + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2759,9 +2878,9 @@ "license": "MIT" }, "node_modules/@vitest/spy": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0-beta.5.tgz", - "integrity": "sha512-eeiIMRR/xBXJxzGhbims+4UOim2bVXzSNf9bLRi0iHWWnXXaK/DS0pYpkLVO83EcqUWbBlqMx9R8Y6tLF6aCJQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", "dev": true, "license": "MIT", "funding": { @@ -2769,13 +2888,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0-beta.5.tgz", - "integrity": "sha512-yDobPgmVL/4YhVXsbBcmeUb5CIdZiJkoonPnuJXKOxmnj0XZyu7OgIX3KLOcRStbia3nJZ9VIIBWoSv+HS+wVA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0-beta.5", + "@vitest/pretty-format": "4.1.0", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" }, @@ -2813,6 +2932,13 @@ "@xterm/xterm": "^5.0.0" } }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT", + "peer": true + }, "node_modules/@xyflow/svelte": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-1.3.1.tgz", @@ -2887,7 +3013,6 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3066,7 +3191,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -3082,6 +3206,17 @@ "node": ">=8" } }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -3099,6 +3234,17 @@ "node": ">=12" } }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/async-limiter": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", @@ -3167,7 +3313,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -3183,6 +3328,14 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/balanced-match": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz", + "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3499,6 +3652,68 @@ "node": ">= 6" } }, + "node_modules/camelcase-keys": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-7.0.2.tgz", + "integrity": "sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "camelcase": "^6.3.0", + "map-obj": "^4.1.0", + "quick-lru": "^5.1.1", + "type-fest": "^1.2.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caniuse-api": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", @@ -3726,7 +3941,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3843,7 +4057,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -3862,6 +4076,34 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/cross-fetch": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", @@ -3899,6 +4141,17 @@ "postcss": "^8.0.9" } }, + "node_modules/css-functions-list": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.3.3.tgz", + "integrity": "sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -4241,6 +4494,60 @@ } } }, + "node_modules/decamelize": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-5.0.1.tgz", + "integrity": "sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", + "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "decamelize": "^1.1.0", + "map-obj": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys/node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decamelize-keys/node_modules/map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decode-named-character-reference": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", @@ -4318,7 +4625,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4420,7 +4727,6 @@ "version": "5.6.3", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", - "dev": true, "license": "MIT" }, "node_modules/devlop": { @@ -4716,6 +5022,17 @@ "errno": "cli.js" } }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -5143,7 +5460,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz", "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -5340,6 +5656,17 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.9.1" + } + }, "node_modules/fastpriorityqueue": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/fastpriorityqueue/-/fastpriorityqueue-0.7.2.tgz", @@ -5710,6 +6037,50 @@ "node": "*" } }, + "node_modules/global-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", + "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "global-prefix": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -5747,6 +6118,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globjoin": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", + "integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5816,6 +6195,17 @@ "uglify-js": "^3.1.4" } }, + "node_modules/hard-rejection": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", + "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6027,6 +6417,56 @@ "node": ">=12.0.0" } }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/html-tags": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", + "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", @@ -6097,6 +6537,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-lazy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", + "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -6107,6 +6558,20 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -6129,8 +6594,8 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC", - "optional": true + "devOptional": true, + "license": "ISC" }, "node_modules/inline-style-parser": { "version": "0.1.1", @@ -6165,6 +6630,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -6262,11 +6735,32 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" @@ -6346,7 +6840,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" @@ -6358,6 +6852,14 @@ "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", "license": "BSD-3-Clause" }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -6387,6 +6889,14 @@ "dev": true, "license": "MIT" }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/json-refs": { "version": "3.0.15", "resolved": "https://registry.npmjs.org/json-refs/-/json-refs-3.0.15.tgz", @@ -6546,11 +7056,22 @@ "json-buffer": "3.0.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -6782,10 +7303,10 @@ "license": "MIT" }, "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", - "dev": true, + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "devOptional": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -6798,27 +7319,26 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.31.1", - "lightningcss-darwin-arm64": "1.31.1", - "lightningcss-darwin-x64": "1.31.1", - "lightningcss-freebsd-x64": "1.31.1", - "lightningcss-linux-arm-gnueabihf": "1.31.1", - "lightningcss-linux-arm64-gnu": "1.31.1", - "lightningcss-linux-arm64-musl": "1.31.1", - "lightningcss-linux-x64-gnu": "1.31.1", - "lightningcss-linux-x64-musl": "1.31.1", - "lightningcss-win32-arm64-msvc": "1.31.1", - "lightningcss-win32-x64-msvc": "1.31.1" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6833,13 +7353,12 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6854,13 +7373,12 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6875,13 +7393,12 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6896,13 +7413,12 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6917,13 +7433,12 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6938,13 +7453,12 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6959,13 +7473,12 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6980,13 +7493,12 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7001,13 +7513,12 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7022,13 +7533,12 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7066,7 +7576,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -7131,6 +7640,14 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -7186,12 +7703,25 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/map-obj": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mapbox-to-css-font": { "version": "2.4.5", "resolved": "https://registry.npmjs.org/mapbox-to-css-font/-/mapbox-to-css-font-2.4.5.tgz", @@ -7229,6 +7759,18 @@ "node": ">= 0.4" } }, + "node_modules/mathml-tag-names": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", + "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -7458,6 +8000,48 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/meow": { + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/meow/-/meow-10.1.5.tgz", + "integrity": "sha512-/d+PQ4GKmGvM9Bee/DPa8z3mXs/pkvJE2KEThngVNOqtmljC6K7NMPxtc2JeZYTmpWb9k/TmxjeL18ez3h7vCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/minimist": "^1.2.2", + "camelcase-keys": "^7.0.0", + "decamelize": "^5.0.0", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^3.0.2", + "read-pkg-up": "^8.0.0", + "redent": "^4.0.0", + "trim-newlines": "^4.0.2", + "type-fest": "^1.2.2", + "yargs-parser": "^20.2.9" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -8138,6 +8722,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minimist-options": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -8267,7 +8867,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -8424,6 +9024,23 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-package-data": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", + "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "hosted-git-info": "^4.0.1", + "is-core-module": "^2.5.0", + "semver": "^7.3.4", + "validate-npm-package-license": "^3.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -8547,7 +9164,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, + "devOptional": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" @@ -8753,6 +9370,26 @@ "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", "license": "MIT" }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -8908,7 +9545,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/picomatch": { @@ -9020,10 +9657,10 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "devOptional": true, "funding": [ { "type": "opencollective", @@ -9611,6 +10248,14 @@ "postcss": "^8.4.31" } }, + "node_modules/postcss-resolve-nested-selector": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz", + "integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/postcss-safe-parser": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz", @@ -9727,7 +10372,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -10089,6 +10734,73 @@ "pify": "^2.3.0" } }, + "node_modules/read-pkg": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-6.0.0.tgz", + "integrity": "sha512-X1Fu3dPuk/8ZLsMhEj5f4wFAF0DWoK7qhGJvgaijocXxBmSToKfbFtqbxMO7bVjNA1dmE5huAzjXj/ey86iw9Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^3.0.2", + "parse-json": "^5.2.0", + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-8.0.0.tgz", + "integrity": "sha512-snVCqPczksT0HS2EC+SxUndvSzn6LRCwpfSvLrIfR5BKDQQZMaI6jPRC9dYvYFDRAuFEAnkwww8kBBNE/3VvzQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "find-up": "^5.0.0", + "read-pkg": "^6.0.0", + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -10118,6 +10830,24 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-4.0.0.tgz", + "integrity": "sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "indent-string": "^5.0.0", + "strip-indent": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -10324,14 +11054,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.6.tgz", - "integrity": "sha512-B8vFPV1ADyegoYfhg+E7RAucYKv0xdVlwYYsIJgfPNeiSxZGWNxts9RqhyGzC11ULK/VaeXyKezGCwpMiH8Ktw==", - "dev": true, + "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==", + "devOptional": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.115.0", - "@rolldown/pluginutils": "1.0.0-rc.6" + "@rolldown/pluginutils": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" @@ -10340,19 +11070,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.6", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.6", - "@rolldown/binding-darwin-x64": "1.0.0-rc.6", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.6", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.6", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.6", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.6", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.6", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.6", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.6", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.6", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.6", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.6" + "@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" } }, "node_modules/run-parallel": { @@ -10469,7 +11201,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/set-function-length": { @@ -10680,7 +11412,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@polka/url": "^1.0.0-next.24", @@ -10701,6 +11433,25 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/sort-asc": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.1.0.tgz", @@ -10743,7 +11494,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -10759,6 +11510,46 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0", + "peer": true + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0", + "peer": true + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -10774,9 +11565,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true, "license": "MIT" }, @@ -10845,6 +11636,20 @@ "node": ">=8" } }, + "node_modules/strip-indent": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", + "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -10858,6 +11663,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-search": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz", + "integrity": "sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==", + "dev": true, + "license": "ISC", + "peer": true + }, "node_modules/style-to-object": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.4.tgz", @@ -10899,6 +11712,66 @@ "node": ">=4" } }, + "node_modules/stylelint": { + "version": "15.11.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-15.11.0.tgz", + "integrity": "sha512-78O4c6IswZ9TzpcIiQJIN49K3qNoXTM8zEJzhaTE/xRTCZswaovSEVIa/uwbOltZrk16X4jAxjaOhzz/hTm1Kw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@csstools/css-parser-algorithms": "^2.3.1", + "@csstools/css-tokenizer": "^2.2.0", + "@csstools/media-query-list-parser": "^2.1.4", + "@csstools/selector-specificity": "^3.0.0", + "balanced-match": "^2.0.0", + "colord": "^2.9.3", + "cosmiconfig": "^8.2.0", + "css-functions-list": "^3.2.1", + "css-tree": "^2.3.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.1", + "fastest-levenshtein": "^1.0.16", + "file-entry-cache": "^7.0.0", + "global-modules": "^2.0.0", + "globby": "^11.1.0", + "globjoin": "^0.1.4", + "html-tags": "^3.3.1", + "ignore": "^5.2.4", + "import-lazy": "^4.0.0", + "imurmurhash": "^0.1.4", + "is-plain-object": "^5.0.0", + "known-css-properties": "^0.29.0", + "mathml-tag-names": "^2.1.3", + "meow": "^10.1.5", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.28", + "postcss-resolve-nested-selector": "^0.1.1", + "postcss-safe-parser": "^6.0.0", + "postcss-selector-parser": "^6.0.13", + "postcss-value-parser": "^4.2.0", + "resolve-from": "^5.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "style-search": "^0.1.0", + "supports-hyperlinks": "^3.0.0", + "svg-tags": "^1.0.0", + "table": "^6.8.1", + "write-file-atomic": "^5.0.1" + }, + "bin": { + "stylelint": "bin/stylelint.mjs" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + } + }, "node_modules/stylelint-config-recommended": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-13.0.0.tgz", @@ -10912,6 +11785,78 @@ "stylelint": "^15.10.0" } }, + "node_modules/stylelint/node_modules/@csstools/selector-specificity": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-3.1.1.tgz", + "integrity": "sha512-a7cxGcJ2wIlMFLlh8z2ONm+715QkPHiyJcxwQlKOz/03GPw1COpfhcmC9wm4xlZfp//jWHNNMwzjtqHXVWU9KA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peer": true, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^6.0.13" + } + }, + "node_modules/stylelint/node_modules/file-entry-cache": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-7.0.2.tgz", + "integrity": "sha512-TfW7/1iI4Cy7Y8L6iqNdZQVvdXn0f8B4QcIXmkIbtTIe/Okm/nSlHb4IwGzRVOd3WfSieCgvf5cMzEfySAIl0g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "flat-cache": "^3.2.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/stylelint/node_modules/known-css-properties": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.29.0.tgz", + "integrity": "sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/stylelint/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/stylelint/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -11036,6 +11981,24 @@ "node": ">=8" } }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -11053,7 +12016,6 @@ "version": "5.53.5", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.5.tgz", "integrity": "sha512-YkqERnF05g8KLdDZwZrF8/i1eSbj6Eoat8Jjr2IfruZz9StLuBqo8sfCSzjosNKd+ZrQ8DkKZDjpO5y3ht1Pow==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -11377,6 +12339,13 @@ "typescript": "^4.9.4 || ^5.0.0" } }, + "node_modules/svg-tags": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", + "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", + "dev": true, + "peer": true + }, "node_modules/svgo": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", @@ -11420,6 +12389,24 @@ "dev": true, "license": "MIT" }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/tailwind-merge": { "version": "1.14.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.14.0.tgz", @@ -11651,7 +12638,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -11668,7 +12655,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -11686,7 +12673,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -11696,9 +12683,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -11722,7 +12709,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -11744,6 +12731,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/trim-newlines": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-4.1.1.tgz", + "integrity": "sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/trough": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", @@ -11843,7 +12844,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", @@ -12052,6 +13053,18 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -12095,17 +13108,17 @@ } }, "node_modules/vite": { - "version": "8.0.0-beta.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0-beta.16.tgz", - "integrity": "sha512-c0t7hYkxsjws89HH+BUFh/sL3BpPNhNsL9CJrTpMxBmwKQBRSa5OJ5w4o9O0bQVI/H/vx7UpUUIevvXa37NS/Q==", - "dev": true, + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", + "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", + "devOptional": true, "license": "MIT", "dependencies": { "@oxc-project/runtime": "0.115.0", - "lightningcss": "^1.31.1", + "lightningcss": "^1.32.0", "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rolldown": "1.0.0-rc.6", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.9", "tinyglobby": "^0.2.15" }, "bin": { @@ -12195,7 +13208,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -12208,7 +13221,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", - "dev": true, + "devOptional": true, "license": "MIT", "workspaces": [ "tests/deps/*", @@ -12225,26 +13238,26 @@ } }, "node_modules/vitest": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0-beta.5.tgz", - "integrity": "sha512-oFoeAOQednbyV7mR1hAmT4/yQ4xnNzvHUcU0lFwxo8riim0wsuh2EXF/xOtsT3q33ACeufP5BkWFVyJUC5B/DQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.0-beta.5", - "@vitest/mocker": "4.1.0-beta.5", - "@vitest/pretty-format": "4.1.0-beta.5", - "@vitest/runner": "4.1.0-beta.5", - "@vitest/snapshot": "4.1.0-beta.5", - "@vitest/spy": "4.1.0-beta.5", - "@vitest/utils": "4.1.0-beta.5", + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", @@ -12265,10 +13278,10 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.0-beta.5", - "@vitest/browser-preview": "4.1.0-beta.5", - "@vitest/browser-webdriverio": "4.1.0-beta.5", - "@vitest/ui": "4.1.0-beta.5", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" @@ -12779,6 +13792,21 @@ "devOptional": true, "license": "ISC" }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/xml-utils": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", @@ -12960,6 +13988,17 @@ "node": ">=12" } }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "peer": true, + "engines": { + "node": ">=10" + } + }, "node_modules/yargs/node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", @@ -13003,7 +14042,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, "license": "MIT" }, "node_modules/zod": { diff --git a/frontend/package.json b/frontend/package.json index 1412bceecc..d34ea12a90 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -70,9 +70,9 @@ "tar": "^7.5.4", "tslib": "^2.6.1", "typescript": "^5.5.0", - "vite": "^8.0.0-beta.16", + "vite": "^8.0.0", "vite-plugin-mkcert": "^1.17.5", - "vitest": "^4.1.0-beta.5", + "vitest": "^4.1.0", "vitest-browser-svelte": "^2.0.1" }, "overrides": { From 96229575e6641b9c294cf45ed0d9c530a94ffca9 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 13 Mar 2026 13:07:49 +0100 Subject: [PATCH 035/204] =?UTF-8?q?chore:=20dev=20tooling=20=E2=80=94=20wm?= =?UTF-8?q?-ts-nav=20navigator,=20format=20hooks,=20review=20skill=20(#833?= =?UTF-8?q?7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: remove wm-cursor, add local-review skill, update PR skill for EE - Remove the unused wm-cursor script and all references to it in README_WORKMUX_DEV.md and worktree-common.sh - Add /local-review skill for code review (bugs + CLAUDE.md compliance) - Add EE companion PR workflow to the /pr skill Co-Authored-By: Claude Opus 4.6 * chore: add wm-ts-nav tree-sitter navigator and fix format hooks - Add wm-ts-nav: standalone tree-sitter code navigator with SQLite index for fast symbol search, definition lookup, and file outlines across Rust, TypeScript, and Svelte files (~12ms warm, ~1s cold for 482 files) - Fix format hooks: surface errors instead of swallowing with 2>/dev/null, use direct prettier path with svelte plugin, add success feedback - Add wm-ts-nav commands to settings allow list - Document wm-ts-nav usage in CLAUDE.md Co-Authored-By: Claude Opus 4.6 * feat(wm-ts-nav): add refs command and --parent filter - refs: find usages of a symbol in code, skipping comments and strings (tree-sitter AST walk, ~46ms for 482 files vs grep's 4ms but no noise) - --parent filter on search: find all methods on a type across all files (e.g. search "%" --kind function --parent ServiceName) - Update CLAUDE.md with clearer when-to-use guidance Co-Authored-By: Claude Opus 4.6 * feat(wm-ts-nav): index refs in DB with import-path resolution Co-Authored-By: Claude Opus 4.5 * feat(wm-ts-nav): add body, callers, callees commands and refs --file/--caller - body: extract a symbol's source code from disk using indexed line ranges - callers: cross-file call graph via SQL join of refs + symbols tables - callees: list all identifiers referenced within a symbol's body - refs --file: scope results to files matching a substring - refs --caller: annotate each ref with the containing function name Co-Authored-By: Claude Opus 4.6 * feat(wm-ts-nav): add auto-rebuilding wrapper script The `wm-ts-nav/nav` wrapper checks if source files are newer than the binary and rebuilds automatically. Invoked via `sh wm-ts-nav/nav` to avoid needing executable permissions after clone. Co-Authored-By: Claude Opus 4.6 * docs: tighten CLAUDE.md nav section for actionable guidance Remove redundant question→command mapping, latency numbers, and excessive examples. Lead with "prefer wm-ts-nav over Read to save context window" and keep only the patterns that change behavior. Co-Authored-By: Claude Opus 4.6 * chore: revert backend/Cargo.lock to main Co-Authored-By: Claude Opus 4.6 * chore: promote wm-ts-nav in workflow, copy binary to worktrees - CLAUDE.md: integrate wm-ts-nav into Workflow step 1 and Core Principles so agents use outline/body before full file reads - workmux: copy built binary via files.copy - worktree-common.sh: copy binary in wm_copy_dependencies for webmux Co-Authored-By: Claude Opus 4.6 * fix(wm-ts-nav): fix double indexing, add TSX grammar, remove needless clone - Reuse index stats from the pre-query update instead of indexing twice on the Index command - Add Lang::Tsx variant so .tsx/.jsx files use LANGUAGE_TSX instead of LANGUAGE_TYPESCRIPT (Svelte stays on TS since script blocks are pure TS) - Remove source.clone() for non-Svelte files — move directly instead Co-Authored-By: Claude Opus 4.6 * fix(wm-ts-nav): fix svelte line numbers, add class methods, innermost caller Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.6 --- .claude/hooks/format-backend.sh | 6 +- .claude/hooks/format-frontend.sh | 6 +- .claude/settings.json | 6 + .claude/skills/local-review/SKILL.md | 98 ++++ .claude/skills/pr/SKILL.md | 23 + .workmux.yaml | 1 + CLAUDE.md | 38 +- README_WORKMUX_DEV.md | 78 --- docs/enterprise.md | 15 +- scripts/wm-cursor | 401 --------------- scripts/worktree-common.sh | 8 +- wm-ts-nav/Cargo.lock | 620 +++++++++++++++++++++++ wm-ts-nav/Cargo.toml | 21 + wm-ts-nav/nav | 10 + wm-ts-nav/src/db.rs | 422 ++++++++++++++++ wm-ts-nav/src/indexer.rs | 102 ++++ wm-ts-nav/src/main.rs | 270 ++++++++++ wm-ts-nav/src/parser.rs | 716 +++++++++++++++++++++++++++ 18 files changed, 2351 insertions(+), 490 deletions(-) create mode 100644 .claude/skills/local-review/SKILL.md delete mode 100755 scripts/wm-cursor create mode 100644 wm-ts-nav/Cargo.lock create mode 100644 wm-ts-nav/Cargo.toml create mode 100755 wm-ts-nav/nav create mode 100644 wm-ts-nav/src/db.rs create mode 100644 wm-ts-nav/src/indexer.rs create mode 100644 wm-ts-nav/src/main.rs create mode 100644 wm-ts-nav/src/parser.rs diff --git a/.claude/hooks/format-backend.sh b/.claude/hooks/format-backend.sh index d6077d7482..2b77f71432 100755 --- a/.claude/hooks/format-backend.sh +++ b/.claude/hooks/format-backend.sh @@ -13,8 +13,10 @@ fi # Check if the file is in the backend directory and is a Rust file if [[ "$FILE_PATH" == *"/backend/"* ]] && [[ "$FILE_PATH" =~ \.rs$ ]]; then cd "$CLAUDE_PROJECT_DIR/backend" || exit 0 - # Run rustfmt with config from rustfmt.toml (edition=2021) - rustfmt --config-path rustfmt.toml "$FILE_PATH" 2>/dev/null || true + # Run rustfmt, surface errors as context but don't block Claude + if rustfmt --config-path rustfmt.toml "$FILE_PATH" 2>&1; then + echo "Formatted $(basename "$FILE_PATH")" + fi fi exit 0 diff --git a/.claude/hooks/format-frontend.sh b/.claude/hooks/format-frontend.sh index d0b4f0559b..37c3f8d4ec 100755 --- a/.claude/hooks/format-frontend.sh +++ b/.claude/hooks/format-frontend.sh @@ -15,8 +15,10 @@ if [[ "$FILE_PATH" == *"/frontend/"* ]]; then # Check if it's a formattable file type if [[ "$FILE_PATH" =~ \.(ts|js|svelte|json|css|html|md)$ ]]; then cd "$CLAUDE_PROJECT_DIR/frontend" || exit 0 - # Run prettier silently, don't fail the hook if prettier fails - npx prettier --write "$FILE_PATH" 2>/dev/null || true + # Run prettier, surface errors as context but don't block Claude + if ./node_modules/.bin/prettier --plugin prettier-plugin-svelte --write "$FILE_PATH" 2>&1; then + echo "Formatted $(basename "$FILE_PATH")" + fi fi fi diff --git a/.claude/settings.json b/.claude/settings.json index cf8bfdd284..0596b17e91 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -28,6 +28,12 @@ "Bash(git show:*)", "Bash(git blame:*)", "Bash(cargo check:*)", + "Bash(cargo build --release:*)", + "Bash(sh wm-ts-nav/nav:*)", + "Bash(wm-ts-nav/nav:*)", + "Bash(./wm-ts-nav/nav:*)", + "Bash(wm-ts-nav/target/release/wm-ts-nav:*)", + "Bash(./wm-ts-nav/target/release/wm-ts-nav:*)", "mcp__ide__getDiagnostics", "Bash(npm run generate-backend-client:*)", "Bash(npm run check:*)", diff --git a/.claude/skills/local-review/SKILL.md b/.claude/skills/local-review/SKILL.md new file mode 100644 index 0000000000..f14f5608db --- /dev/null +++ b/.claude/skills/local-review/SKILL.md @@ -0,0 +1,98 @@ +--- +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 + +Review a pull request for real bugs and CLAUDE.md compliance violations. This review targets HIGH SIGNAL issues only. + +## Review Philosophy + +- **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. + +## 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 + +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. **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 + +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. () + + +2. () + +``` + +If no issues are found: + +``` +## Code review + +No issues found. Checked for bugs and CLAUDE.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/pr/SKILL.md b/.claude/skills/pr/SKILL.md index f0c4e822e4..ab67b58748 100644 --- a/.claude/skills/pr/SKILL.md +++ b/.claude/skills/pr/SKILL.md @@ -33,6 +33,7 @@ Follow conventional commit format for the PR title: - 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 @@ -85,3 +86,25 @@ Generated with [Claude Code](https://claude.com/claude-code) )" ``` 7. 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/.workmux.yaml b/.workmux.yaml index 46049109c0..b36a8f16bf 100644 --- a/.workmux.yaml +++ b/.workmux.yaml @@ -67,6 +67,7 @@ files: copy: - backend/.env - scripts/ + - wm-ts-nav/target/release/wm-ts-nav sandbox: enabled: false diff --git a/CLAUDE.md b/CLAUDE.md index fe22fae0f7..f77dbb0600 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Open-source platform for internal tools, workflows, API integrations, background ## Workflow -1. **Understand**: Before coding, read relevant docs from `docs/` to understand the area you're changing +1. **Understand**: Before coding, use `wm-ts-nav` to explore (see Code Navigation below). Use `outline` to understand file structure, `body` to read specific symbols, `def`/`callers`/`callees` to trace code. Read `docs/` for domain context. 2. **Plan**: For non-trivial changes, use plan mode. For large features, break into reviewable stages 3. **Execute**: Follow coding patterns from skills (`rust-backend`, `svelte-frontend`) 4. **Validate**: After every change, run the appropriate checks per `docs/validation.md` @@ -15,6 +15,7 @@ Open-source platform for internal tools, workflows, API integrations, background - **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow - **Backend patterns**: use the `rust-backend` skill when writing Rust code - **Frontend patterns**: use the `svelte-frontend` skill when writing Svelte code. Do NOT edit svelte files unless you have read that skill. +- **Code review**: use `/local-review` to review a PR for bugs and CLAUDE.md compliance - **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc` - **Brand/UI guidelines**: `frontend/brand-guidelines.md` @@ -49,8 +50,43 @@ let { my_prop = $bindable(default_value) }: { my_prop?: string } = $props() 2. **Create a `useMyPropState()` helper** — encapsulate the undefined-handling logic in a reusable function and call it higher in the component tree, so the child component always receives a defined value. +## Code Navigation + +`wm-ts-nav` is an AST-aware code navigator. Use **Grep** for regex/pattern search. Use **wm-ts-nav** for structural queries — it skips comments/strings and understands symbol boundaries. + +**Prefer wm-ts-nav over Read** to save context window: +- `outline ` instead of reading a full file — understand structure first, then `body` or Read for specifics +- `body "X"` instead of reading a full file to see one function/struct +- `refs "X" --caller` instead of reading files to find which function contains each reference +- `callers "X"` / `callees "X"` for call-graph questions + +```bash +NAV="sh wm-ts-nav/nav" +# Use --root backend for Rust, --root frontend/src for TS/Svelte +$NAV --root backend outline backend/path/to/file.rs # file structure +$NAV --root backend def "ServiceName" # find definition +$NAV --root backend body "decrypt_oauth_data" # extract source code +$NAV --root backend search "%" --parent ServiceName # methods on a type +$NAV --root backend search "Trigger" --kind struct # find by kind +$NAV --root backend refs "X" --file handler.rs --caller # scoped refs with caller +$NAV --root backend callers "X" # who calls X? +$NAV --root backend callees "X" # what does X call? +``` + +**Limitations** — syntax-level analysis, no type inference: +- Import paths are stored literally — `crate::X` and `super::X` pointing to the same type won't be linked +- Re-export chains (`pub use`) aren't followed — refs through different re-export paths won't connect +- Trait methods can't be resolved to their trait definition +- Nested `use` trees (`use foo::{bar::{A, B}, baz::C}`) aren't parsed correctly +- Glob imports (`use foo::*`) — refs won't show import origin +- Macro-generated symbols (e.g. `sqlx::FromRow`) — invisible to tree-sitter +- Single-char identifiers — intentionally filtered out of refs +- `callees` shows all identifiers in a function body, not just actual calls +- `import * as ns` namespace imports — member accesses through `ns.X` aren't resolved + ## Core Principles +- **Use `outline`/`body` to explore, then `Read` with offset/limit from the results before editing** — avoid reading full files - Search for existing code to reuse before writing new code - Follow established patterns in the codebase - Keep changes focused — don't refactor beyond what's asked diff --git a/README_WORKMUX_DEV.md b/README_WORKMUX_DEV.md index 0b113e47ee..20d16d0352 100644 --- a/README_WORKMUX_DEV.md +++ b/README_WORKMUX_DEV.md @@ -192,70 +192,6 @@ sandbox: This mounts both the main EE repo (used by the main worktree) and the EE worktrees directory (used by feature worktrees) into every sandbox container. -## Cursor SSH Integration (`wmc`) - -`wm-cursor` (aliased as `wmc`) gives each worktree its own Cursor SSH remote window with an independently-focused tmux session. All windows are visible in the status bar across all Cursor terminals, but each one is focused on its own worktree. - -This uses **grouped tmux sessions** — multiple sessions that share the same window list but track focus independently: - -``` -tmux session: main <-- your main Cursor terminal -tmux session: cursor-feat-a <-- Cursor window for feat-a (focused on wm-feat-a) -tmux session: cursor-feat-b <-- Cursor window for feat-b (focused on wm-feat-b) - \__ all three share the same windows in the status bar -``` - -### Setup - -Run once from inside tmux on the remote: - -```bash -./scripts/wm-cursor setup /home/hugo/projects/windmill -``` - -This: - -1. **Merges `.vscode/settings.json`** — adds the `wm-tmux` terminal profile (auto-attaches to the `main` tmux session), disables auto port forwarding, configures forwarding for ports 8000/3000/5432, and stops rust-analyzer from auto-starting. Existing settings are preserved. -2. **Creates `.vscode/tasks.json`** — auto-starts the dev database (`start-dev-db.sh`) when the folder opens. -3. **Adds `wmc` alias to `~/.zshrc`** — so you can use `wmc` from any tmux window. -4. **Adds `eval "$(wmc completions)"`** to `~/.zshrc` — provides tab-completion for subcommands and worktree names (for `open`, `open-ee`, and `close`). - -After setup, reopen Cursor's terminal to pick up the new profile. - -### Usage - -All commands run from inside a tmux session (i.e., from Cursor's integrated terminal after setup). - -**Create a new worktree + open Cursor:** - -```bash -wmc add -A -p "implement feature X" -``` - -This runs `workmux add`, creates a grouped tmux session, writes `.vscode/settings.json` in the worktree (with port forwarding matching the worktree's assigned ports), and opens a new Cursor window. - -**Open Cursor for an existing worktree:** - -```bash -wmc open my-feature -``` - -**Open the EE worktree in Cursor (no tmux session):** - -```bash -wmc open-ee my-feature -``` - -This finds the matching `windmill-ee-private__worktrees/` directory and opens it in a new Cursor window. - -**Close a worktree's Cursor window and tmux window (keeps the worktree):** - -```bash -wmc close my-feature -``` - -This kills the grouped tmux session and calls `workmux close` to close the tmux window. The worktree and branch are preserved. Grouped sessions are also automatically cleaned up when you `workmux rm` a worktree (via `scripts/worktree-cleanup`). - ## Cargo Features To build the backend with specific Cargo features (e.g., `enterprise`, `parquet`), pass them via `CARGO_FEATURES`. The backend pane reads this from `.env.local` and appends `--features ` to the `cargo watch` command. @@ -270,20 +206,6 @@ CARGO_FEATURES="enterprise,parquet" wm add my-feature This gets written to `.env.local` by the `post_create` hook (`scripts/worktree-env`), and the backend pane picks it up automatically. -**With `wmc` (wm-cursor):** - -Use the `--features` flag: - -```bash -# Create a new worktree with features -wmc add --features "enterprise,parquet" -A -p "implement feature X" - -# Open an existing worktree with different features -wmc open my-feature --features "enterprise,parquet" -``` - -The `--features` flag exports `CARGO_FEATURES` so the `post_create` hook writes it to `.env.local`. When using `wmc open`, it updates the existing `.env.local` with the new features. - ## Login Default credentials: `admin@windmill.dev` / `changeme` diff --git a/docs/enterprise.md b/docs/enterprise.md index bfed61a2e6..5e2b7c9b61 100644 --- a/docs/enterprise.md +++ b/docs/enterprise.md @@ -15,17 +15,22 @@ - Standard location: `~/windmill-ee-private` - Worktree location: `~/windmill-ee-private__worktrees//` +## Detecting EE Changes + +The `*_ee.rs` files in the windmill repo are symlinks — changes won't appear in `git diff` of the windmill repo. Check the EE repo directly: `git -C status --short` + ## EE PR Workflow (MUST DO when modifying `*_ee.rs` files) When you modify any `*_ee.rs` file and create a PR on windmill: -1. **Create a matching branch** in `windmill-ee-private` (same branch name) -2. **Commit and push** the `_ee.rs` changes in that branch -3. **Create a PR** on `windmill-ee-private` with a link to the companion windmill PR -4. **Update `ee-repo-ref.txt`**: Run `bash write_latest_ee_ref.sh` from `backend/` +1. **Prefix the windmill PR title** with `[ee]`: `[ee] : ` +2. **Create a matching branch** in `windmill-ee-private` (same branch name) +3. **Commit and push** the `_ee.rs` changes in that branch +4. **Create a companion PR** on `windmill-ee-private` with a link to the windmill PR (no `[ee]` prefix on this one) +5. **Update `ee-repo-ref.txt`**: Run `bash write_latest_ee_ref.sh` from `backend/` - **Verify** it wrote the correct commit hash from your branch, not from main (the script may fall back to `~/windmill-ee-private` on main) - If wrong, manually write the correct hash -5. **Commit `ee-repo-ref.txt`** in the windmill repo so CI picks up the correct EE ref +6. **Commit `ee-repo-ref.txt`** in the windmill repo so CI picks up the correct EE ref ## Validation diff --git a/scripts/wm-cursor b/scripts/wm-cursor deleted file mode 100755 index 3c47da1179..0000000000 --- a/scripts/wm-cursor +++ /dev/null @@ -1,401 +0,0 @@ -#!/usr/bin/env zsh -emulate -L zsh -setopt err_exit no_unset pipe_fail - -# wm-cursor: Manage Cursor SSH remote windows with grouped tmux sessions -# Each worktree gets its own Cursor window with an independently-focused -# grouped tmux session, sharing the same window list in the status bar. - -# --- Resolve script path (must be at top level, not inside a function) --- - -local script_path=${0:A} - -# --- Lazy Cursor CLI resolution (only when needed) --- - -local cursor_bin= - -resolve_cursor_cli() { - [[ -n $cursor_bin ]] && return 0 - - local -a cursor_bins=(~/.cursor-server/cli/servers/*/server/bin/remote-cli/cursor(NOm)) - if (( ${#cursor_bins} == 0 )); then - print -u2 "Error: Cursor remote CLI not found in ~/.cursor-server/cli/servers/" - exit 1 - fi - cursor_bin=${cursor_bins[1]} - - # Refresh Cursor IPC socket (tmux may hold a stale one) - # Multiple stale sockets may exist; probe to find a live one - local sock - for sock in /tmp/vscode-ipc-*.sock(NOm); do - if timeout 2 env VSCODE_IPC_HOOK_CLI=$sock $cursor_bin --status &>/dev/null; then - export VSCODE_IPC_HOOK_CLI=$sock - break - fi - done -} - -# --- Helper functions --- - -ensure_tmux() { - if [[ -z ${TMUX-} ]]; then - print -u2 "Error: Not inside a tmux session" - exit 1 - fi -} - -check_dev_db() { - if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^windmill-db-dev$'; then - print -u2 "Warning: windmill-db-dev container is not running" - fi -} - -setup_grouped_session() { - local handle=$1 worktree_path=$2 - local session_name=cursor-${handle} - - # Detect the current main tmux session - local main_session=$(tmux display-message -p '#S') - - # Create grouped session (shares windows with the main session) - if ! tmux has-session -t $session_name 2>/dev/null; then - tmux new-session -d -t $main_session -s $session_name - fi - - # Focus on the worktree's window - tmux select-window -t ${session_name}:wm-${handle} 2>/dev/null || true - - # Write .vscode/settings.json in the worktree if it doesn't already exist - local settings_file=${worktree_path}/.vscode/settings.json - if [[ ! -f $settings_file ]]; then - mkdir -p ${settings_file:h} - - # Read ports from .env.local if available - local env_file=${worktree_path}/.env.local - local ports_config="" - if [[ -f $env_file ]]; then - local backend_port frontend_port - source $env_file - backend_port=${BACKEND_PORT-} - frontend_port=${FRONTEND_PORT-} - if [[ -n $backend_port && -n $frontend_port ]]; then - ports_config=', - "remote.autoForwardPorts": true, - "remote.otherPortsAttributes": { - "onAutoForward": "ignore" - }, - "remote.portsAttributes": { - "'$backend_port'": { "label": "Backend", "onAutoForward": "silent" }, - "'$frontend_port'": { "label": "Frontend", "onAutoForward": "openBrowserOnce" } - }' - fi - fi - - cat > $settings_file < from args, exports CARGO_FEATURES, returns remaining args. -# Usage: parse_features_flag "$@"; set -- "${remaining_args[@]}" - -parse_flags() { - remaining_args=() - while (( $# )); do - case $1 in - --features) - if (( $# < 2 )); then - print -u2 "Error: --features requires a value" - exit 1 - fi - export CARGO_FEATURES=$2 - shift 2 - ;; - --features=*) - export CARGO_FEATURES=${1#--features=} - shift - ;; - --clone-db) - export WM_CLONE_DB=1 - shift - ;; - *) - remaining_args+=("$1") - shift - ;; - esac - done -} - -# --- Subcommands --- - -cmd_add() { - resolve_cursor_cli - ensure_tmux - check_dev_db - - parse_flags "$@" - set -- "${remaining_args[@]}" - - # Snapshot worktree list before - local -a before=("${(@f)$(git worktree list --porcelain | grep '^worktree ')}") - - workmux add -b "$@" - - # Diff to find the new entry - local -a after=("${(@f)$(git worktree list --porcelain | grep '^worktree ')}") - local -a new=(${after:|before}) - - if (( ${#new} == 0 )); then - print -u2 "Error: Could not detect new worktree path" - exit 1 - fi - - local new_path=${new[1]#worktree } - local handle=${new_path:t} - - print "New worktree: ${handle} at ${new_path}" - setup_grouped_session $handle $new_path - $cursor_bin -n $new_path - print "Opened Cursor for ${handle}" -} - -cmd_open() { - local name=${1:?Usage: wm-cursor open } - shift - - resolve_cursor_cli - ensure_tmux - check_dev_db - - parse_flags "$@" - set -- "${remaining_args[@]}" - - # Write CARGO_FEATURES to .env.local if specified - if [[ -n ${CARGO_FEATURES-} ]]; then - local wt_env=$(workmux path $name)/.env.local - if [[ -f $wt_env ]]; then - # Remove existing CARGO_FEATURES line and append new one - sed -i '/^CARGO_FEATURES=/d' $wt_env - echo "CARGO_FEATURES=$CARGO_FEATURES" >> $wt_env - fi - fi - - local wt_path=$(workmux path $name) - local prev_target=$(tmux display-message -p '#{session_name}:#{window_index}') - - workmux open $name "$@" - tmux select-window -t $prev_target - setup_grouped_session $name $wt_path - $cursor_bin -n $wt_path - print "Opened Cursor for ${name}" -} - -cmd_close() { - local name=${1:?Usage: wm-cursor close } - - tmux kill-session -t cursor-${name} 2>/dev/null || true - workmux close $name -} - -cmd_open_ee() { - local name=${1:?Usage: wm-cursor open-ee } - - resolve_cursor_cli - local wt_path=$(workmux path $name) - local main_repo_root="$(cd "$(git -C "$wt_path" rev-parse --git-common-dir 2>/dev/null)/.." && pwd)" - - # Find ee repo (same discovery logic as worktree-env) - local ee_repo="" candidate - for candidate in \ - "${main_repo_root:+${main_repo_root}/../windmill-ee-private}" \ - "${wt_path}/../windmill-ee-private" \ - "${HOME}/windmill-ee-private" \ - "${HOME}/projects/windmill-ee-private"; do - if [[ -n $candidate ]] && [[ -d $candidate ]]; then - ee_repo=${candidate:A} - break - fi - done - - if [[ -z $ee_repo ]]; then - print -u2 "Error: Could not find windmill-ee-private repo" - exit 1 - fi - - local ee_worktree_dir="${ee_repo}__worktrees/${name}" - if [[ ! -d $ee_worktree_dir ]]; then - print -u2 "Error: EE worktree not found at ${ee_worktree_dir}" - exit 1 - fi - - $cursor_bin -n $ee_worktree_dir - print "Opened Cursor for EE worktree: ${ee_worktree_dir}" -} - -cmd_setup() { - local repo_root=${1:?Usage: wm-cursor setup } - repo_root=${repo_root:A} - local vscode_dir=${repo_root}/.vscode - - mkdir -p $vscode_dir - - # --- tasks.json --- - local tasks_file=${vscode_dir}/tasks.json - local write_tasks=true - if [[ -f $tasks_file ]]; then - print -n "tasks.json already exists. Overwrite? [y/N] " - read -q || { print; write_tasks=false } - print - fi - if $write_tasks; then - cat > $tasks_file <<'TASKS' -{ - "version": "2.0.0", - "tasks": [ - { - "label": "Start dev DB", - "type": "shell", - "command": "./start-dev-db.sh", - "options": { "shell": { "executable": "/bin/bash" } }, - "runOptions": { "runOn": "folderOpen" }, - "presentation": { "reveal": "silent", "close": true }, - "problemMatcher": [] - } - ] -} -TASKS - print "Wrote ${tasks_file}" - fi - - # --- settings.json (merge wm-cursor keys, preserve existing) --- - local settings_file=${vscode_dir}/settings.json - local wmc_settings=' - { - "rust-analyzer.initializeStopped": true, - "terminal.integrated.defaultProfile.linux": "wm-tmux", - "terminal.integrated.profiles.linux": { - "wm-tmux": { - "path": "tmux", - "args": ["new-session", "-A", "-s", "main"] - } - }, - "remote.autoForwardPorts": true, - "remote.otherPortsAttributes": { - "onAutoForward": "ignore" - }, - "remote.portsAttributes": { - "8000": { "label": "Backend", "onAutoForward": "silent" }, - "3000": { "label": "Frontend", "onAutoForward": "openBrowserOnce" }, - "5432": { "label": "PostgreSQL", "onAutoForward": "silent" } - } - }' - - if [[ -f $settings_file ]]; then - # Strip // comments so jq can parse, merge, then write back - local existing - existing=$(python3 -c ' -import json, re, sys -text = sys.stdin.read() -# Remove // comments only outside of strings -text = re.sub(r'"'"'("(?:[^"\\]|\\.)*")|//[^\n]*'"'"', lambda m: m.group(1) or "", text) -json.dump(json.loads(text), sys.stdout, indent=2) -' < $settings_file) - jq --argjson wmc "$wmc_settings" '. * $wmc' <<< "$existing" > ${settings_file}.tmp \ - && mv ${settings_file}.tmp $settings_file - print "Merged wm-cursor settings into ${settings_file}" - else - jq . <<< "$wmc_settings" > $settings_file - print "Created ${settings_file}" - fi - - # --- zsh alias + completion --- - local rc=${ZDOTDIR:-$HOME}/.zshrc - local alias_line="alias wmc=${(q)script_path}" - - if [[ -f $rc ]] && grep -qF 'alias wmc=' $rc; then - sed -i "s|^alias wmc=.*|${alias_line}|" $rc - print "Updated wmc alias in ${rc}" - else - print "\n# wm-cursor alias\n${alias_line}" >> $rc - print "Added wmc alias to ${rc}" - fi - - local eval_line='eval "$(wmc completions)"' - if ! grep -qF 'wmc completions' $rc; then - print "${eval_line}" >> $rc - print "Added completions eval to ${rc}" - fi -} - -cmd_completions() { - cat <<'COMP' -_wmc_worktree_names() { - local -a names - names=(${(f)"$(git worktree list --porcelain 2>/dev/null | sed -n 's|^worktree .*/||p' | tail -n +2)"}) - _describe 'worktree' names -} - -_wmc() { - local -a subcmds=( - 'add:Create worktree + open Cursor' - 'open:Open Cursor for existing worktree' - 'open-ee:Open EE worktree in Cursor' - 'close:Clean up grouped tmux session' - 'setup:Set up .vscode settings, tasks + wmc alias' - 'completions:Print zsh completions' - ) - - if (( CURRENT == 2 )); then - _describe 'subcommand' subcmds - else - case $words[2] in - open|open-ee|close) - _wmc_worktree_names - ;; - esac - fi -} - -compdef _wmc wmc wm-cursor -COMP -} - -# --- Main --- - -case ${1-} in - add) shift; cmd_add "$@" ;; - open) shift; cmd_open "$@" ;; - open-ee) shift; cmd_open_ee "$@" ;; - close) shift; cmd_close "$@" ;; - setup) shift; cmd_setup "$@" ;; - completions) cmd_completions ;; - *) - print -u2 "Usage: wm-cursor [args...]" - print -u2 "" - print -u2 "Subcommands:" - print -u2 " add [--features ] [workmux-add-args...] Create worktree + open Cursor" - print -u2 " open [--features ] Open Cursor for existing worktree" - print -u2 " open-ee Open EE worktree in Cursor" - print -u2 " close Clean up grouped tmux session" - print -u2 " setup Set up .vscode settings, tasks + wmc alias" - print -u2 " completions Print zsh completions (use with eval)" - print -u2 "" - print -u2 "Options:" - print -u2 " --features Cargo features for the backend (e.g. \"enterprise,parquet\")" - print -u2 " --clone-db Clone the main 'windmill' database instead of creating an empty one" - exit 1 - ;; -esac diff --git a/scripts/worktree-common.sh b/scripts/worktree-common.sh index 91ba00a5e1..598469aad7 100755 --- a/scripts/worktree-common.sh +++ b/scripts/worktree-common.sh @@ -80,6 +80,13 @@ wm_copy_dependencies() { && echo "CLI deps installed and client generated" \ || echo "WARNING: CLI setup failed" >&2 fi + + local nav_bin="${main_repo_root}/wm-ts-nav/target/release/wm-ts-nav" + if [[ -f "$nav_bin" ]]; then + mkdir -p "${repo_root}/wm-ts-nav/target/release" + cp "$nav_bin" "${repo_root}/wm-ts-nav/target/release/" + echo "Copied wm-ts-nav binary" + fi } wm_allow_direnv() { @@ -261,5 +268,4 @@ wm_shared_pre_remove() { fi fi - tmux kill-session -t "cursor-${wt_basename}" 2>/dev/null || true } diff --git a/wm-ts-nav/Cargo.lock b/wm-ts-nav/Cargo.lock new file mode 100644 index 0000000000..b0f1343e5d --- /dev/null +++ b/wm-ts-nav/Cargo.lock @@ -0,0 +1,620 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tree-sitter" +version = "0.24.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5387dffa7ffc7d2dae12b50c6f7aab8ff79d6210147c6613561fc3d474c6f75" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-rust" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wm-ts-nav" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "ignore", + "rayon", + "rusqlite", + "serde", + "serde_json", + "tree-sitter", + "tree-sitter-rust", + "tree-sitter-typescript", +] + +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/wm-ts-nav/Cargo.toml b/wm-ts-nav/Cargo.toml new file mode 100644 index 0000000000..0dcf2c5081 --- /dev/null +++ b/wm-ts-nav/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "wm-ts-nav" +version = "0.1.0" +edition = "2021" + +[dependencies] +tree-sitter = "0.24" +tree-sitter-rust = "0.23" +tree-sitter-typescript = "0.23" + +rusqlite = { version = "0.32", features = ["bundled"] } +rayon = "1.10" +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +ignore = "0.4" + +[profile.release] +opt-level = 2 +lto = "thin" diff --git a/wm-ts-nav/nav b/wm-ts-nav/nav new file mode 100755 index 0000000000..3d0bbf3557 --- /dev/null +++ b/wm-ts-nav/nav @@ -0,0 +1,10 @@ +#!/bin/sh +# Auto-rebuilding wrapper for wm-ts-nav +DIR="$(cd "$(dirname "$0")" && pwd)" +BIN="$DIR/target/release/wm-ts-nav" + +if [ ! -f "$BIN" ] || [ -n "$(find "$DIR/src" "$DIR/Cargo.toml" -newer "$BIN" 2>/dev/null | head -1)" ]; then + cargo build --release --manifest-path "$DIR/Cargo.toml" >&2 || exit 1 +fi + +exec "$BIN" "$@" diff --git a/wm-ts-nav/src/db.rs b/wm-ts-nav/src/db.rs new file mode 100644 index 0000000000..eb6556262c --- /dev/null +++ b/wm-ts-nav/src/db.rs @@ -0,0 +1,422 @@ +use anyhow::{Context, Result}; +use rusqlite::{params, Connection}; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use crate::parser::{IdentRef, Symbol}; + +pub struct Db { + conn: Connection, +} + +impl Db { + pub fn open(cache_dir: &Path) -> Result { + std::fs::create_dir_all(cache_dir) + .with_context(|| format!("creating cache dir: {}", cache_dir.display()))?; + let db_path = cache_dir.join("index.db"); + let conn = Connection::open(&db_path) + .with_context(|| format!("opening db: {}", db_path.display()))?; + + conn.execute_batch( + "PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + mtime_secs INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS symbols ( + id INTEGER PRIMARY KEY, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + name TEXT NOT NULL, + kind TEXT NOT NULL, + line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + signature TEXT, + parent TEXT + ); + CREATE TABLE IF NOT EXISTS refs ( + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + name TEXT NOT NULL, + line INTEGER NOT NULL, + import_path TEXT + ); + CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name); + CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_id); + CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind); + CREATE INDEX IF NOT EXISTS idx_files_path ON files(path); + CREATE INDEX IF NOT EXISTS idx_refs_name ON refs(name); + CREATE INDEX IF NOT EXISTS idx_refs_file ON refs(file_id);", + )?; + + Ok(Self { conn }) + } + + pub fn begin(&self) -> Result<()> { + self.conn.execute_batch("BEGIN")?; + Ok(()) + } + + pub fn commit(&self) -> Result<()> { + self.conn.execute_batch("COMMIT")?; + Ok(()) + } + + pub fn upsert_file( + &self, + path: &str, + mtime_secs: i64, + symbols: &[Symbol], + refs: &[IdentRef], + ) -> Result<()> { + // Delete old entry if exists + self.conn.execute( + "DELETE FROM refs WHERE file_id IN (SELECT id FROM files WHERE path = ?1)", + params![path], + )?; + self.conn.execute( + "DELETE FROM symbols WHERE file_id IN (SELECT id FROM files WHERE path = ?1)", + params![path], + )?; + self.conn + .execute("DELETE FROM files WHERE path = ?1", params![path])?; + + // Insert new file + self.conn.execute( + "INSERT INTO files (path, mtime_secs) VALUES (?1, ?2)", + params![path, mtime_secs], + )?; + let file_id = self.conn.last_insert_rowid(); + + // Insert symbols + let mut stmt = self.conn.prepare_cached( + "INSERT INTO symbols (file_id, name, kind, line, end_line, signature, parent) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + )?; + for sym in symbols { + stmt.execute(params![ + file_id, + sym.name, + sym.kind, + sym.line, + sym.end_line, + sym.signature, + sym.parent, + ])?; + } + + // Insert refs + let mut ref_stmt = self.conn.prepare_cached( + "INSERT INTO refs (file_id, name, line, import_path) VALUES (?1, ?2, ?3, ?4)", + )?; + for r in refs { + ref_stmt.execute(params![file_id, r.name, r.line, r.import_path])?; + } + + Ok(()) + } + + pub fn remove_file(&self, path: &str) -> Result<()> { + self.conn.execute( + "DELETE FROM refs WHERE file_id IN (SELECT id FROM files WHERE path = ?1)", + params![path], + )?; + self.conn.execute( + "DELETE FROM symbols WHERE file_id IN (SELECT id FROM files WHERE path = ?1)", + params![path], + )?; + self.conn + .execute("DELETE FROM files WHERE path = ?1", params![path])?; + Ok(()) + } + + pub fn all_indexed_paths(&self) -> Result> { + let mut stmt = self + .conn + .prepare("SELECT path, mtime_secs FROM files")?; + let rows = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::, _>>()?; + Ok(rows) + } + + pub fn search_symbols( + &self, + pattern: &str, + kind_filter: Option<&str>, + parent_filter: Option<&str>, + limit: usize, + ) -> Result> { + let mut conditions = vec!["s.name LIKE ?1".to_string()]; + if let Some(kind) = kind_filter { + conditions.push(format!("s.kind = '{kind}'")); + } + if let Some(parent) = parent_filter { + conditions.push(format!("s.parent LIKE '%{parent}%'")); + } + let where_clause = conditions.join(" AND "); + let query = format!( + "SELECT s.name, s.kind, s.line, s.end_line, s.signature, s.parent, f.path + FROM symbols s JOIN files f ON s.file_id = f.id + WHERE {where_clause} + ORDER BY s.name LIMIT ?2" + ); + + let like_pattern = if pattern.contains('%') || pattern.contains('_') { + pattern.to_string() + } else { + format!("%{pattern}%") + }; + + let mut stmt = self.conn.prepare(&query)?; + let rows = stmt + .query_map(params![like_pattern, limit as i64], |row| { + Ok(SearchResult { + name: row.get(0)?, + kind: row.get(1)?, + line: row.get(2)?, + end_line: row.get(3)?, + signature: row.get(4)?, + parent: row.get(5)?, + path: row.get(6)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } + + pub fn file_symbols(&self, path: &str) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT s.name, s.kind, s.line, s.end_line, s.signature, s.parent, f.path + FROM symbols s JOIN files f ON s.file_id = f.id + WHERE f.path = ?1 + ORDER BY s.line", + )?; + let rows = stmt + .query_map(params![path], |row| { + Ok(SearchResult { + name: row.get(0)?, + kind: row.get(1)?, + line: row.get(2)?, + end_line: row.get(3)?, + signature: row.get(4)?, + parent: row.get(5)?, + path: row.get(6)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } + + pub fn find_refs( + &self, + name: &str, + limit: usize, + file_filter: Option<&str>, + with_caller: bool, + ) -> Result> { + let mut conditions = vec!["r.name = ?1".to_string()]; + if let Some(file) = file_filter { + conditions.push(format!("f.path LIKE '%{}'", file.replace('\'', "''"))); + } + let where_clause = conditions.join(" AND "); + + if with_caller { + let query = format!( + "SELECT path, line, import_path, caller_name, caller_kind FROM ( + SELECT f.path, r.line, r.import_path, s.name AS caller_name, s.kind AS caller_kind, + ROW_NUMBER() OVER ( + PARTITION BY r.file_id, r.line + ORDER BY (s.end_line - s.line) ASC + ) AS rn + FROM refs r + JOIN files f ON r.file_id = f.id + LEFT JOIN symbols s ON s.file_id = r.file_id + AND s.line <= r.line AND r.line <= s.end_line + AND s.kind IN ('function', 'impl', 'class', 'interface', 'method') + WHERE {where_clause} + ) WHERE rn = 1 + ORDER BY path, line + LIMIT ?2" + ); + let mut stmt = self.conn.prepare(&query)?; + let rows = stmt + .query_map(params![name, limit as i64], |row| { + Ok(RefResult { + path: row.get(0)?, + line: row.get(1)?, + import_path: row.get(2)?, + caller_name: row.get(3)?, + caller_kind: row.get(4)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } else { + let query = format!( + "SELECT f.path, r.line, r.import_path + FROM refs r JOIN files f ON r.file_id = f.id + WHERE {where_clause} + ORDER BY f.path, r.line + LIMIT ?2" + ); + let mut stmt = self.conn.prepare(&query)?; + let rows = stmt + .query_map(params![name, limit as i64], |row| { + Ok(RefResult { + path: row.get(0)?, + line: row.get(1)?, + import_path: row.get(2)?, + caller_name: None, + caller_kind: None, + }) + })? + .collect::, _>>()?; + Ok(rows) + } + } + + pub fn find_callers(&self, name: &str, limit: usize) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT caller_name, caller_kind, caller_line, caller_end_line, path, ref_line FROM ( + SELECT s.name AS caller_name, s.kind AS caller_kind, + s.line AS caller_line, s.end_line AS caller_end_line, + f.path, r.line AS ref_line, + ROW_NUMBER() OVER ( + PARTITION BY r.file_id, r.line + ORDER BY (s.end_line - s.line) ASC + ) AS rn + FROM refs r + JOIN symbols s ON s.file_id = r.file_id + AND s.line <= r.line AND r.line <= s.end_line + AND s.kind IN ('function', 'impl', 'class', 'interface', 'method') + JOIN files f ON r.file_id = f.id + WHERE r.name = ?1 + ) WHERE rn = 1 + ORDER BY path, caller_line + LIMIT ?2", + )?; + let rows = stmt + .query_map(params![name, limit as i64], |row| { + Ok(CallerResult { + caller_name: row.get(0)?, + caller_kind: row.get(1)?, + caller_line: row.get(2)?, + caller_end_line: row.get(3)?, + path: row.get(4)?, + ref_line: row.get(5)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } + + pub fn find_callees( + &self, + name: &str, + kind_filter: Option<&str>, + file_filter: Option<&str>, + ) -> Result> { + // First find the symbol + let results = self.search_symbols(name, kind_filter, None, 100)?; + let exact: Vec<_> = results.into_iter().filter(|r| r.name == name).collect(); + if exact.is_empty() { + return Ok(vec![]); + } + + let mut all_callees = Vec::new(); + for sym in &exact { + if let Some(file) = file_filter { + if !sym.path.contains(file) { + continue; + } + } + let mut stmt = self.conn.prepare( + "SELECT DISTINCT r.name, r.import_path + FROM refs r + JOIN files f ON r.file_id = f.id + WHERE f.path = ?1 AND r.line >= ?2 AND r.line <= ?3 + ORDER BY r.name", + )?; + let rows = stmt + .query_map(params![sym.path, sym.line, sym.end_line], |row| { + Ok(CalleeResult { + name: row.get(0)?, + import_path: row.get(1)?, + }) + })? + .collect::, _>>()?; + all_callees.extend(rows); + } + // Deduplicate by name + all_callees.sort_by(|a, b| a.name.cmp(&b.name)); + all_callees.dedup_by(|a, b| a.name == b.name); + Ok(all_callees) + } +} + +#[derive(Debug, serde::Serialize)] +pub struct RefResult { + pub path: String, + pub line: i64, + pub import_path: Option, + pub caller_name: Option, + pub caller_kind: Option, +} + +#[derive(Debug, serde::Serialize)] +pub struct CallerResult { + pub caller_name: String, + pub caller_kind: String, + pub caller_line: i64, + pub caller_end_line: i64, + pub path: String, + pub ref_line: i64, +} + +#[derive(Debug, serde::Serialize)] +pub struct CalleeResult { + pub name: String, + pub import_path: Option, +} + +#[derive(Debug, serde::Serialize)] +pub struct SearchResult { + pub name: String, + pub kind: String, + pub line: i64, + pub end_line: i64, + pub signature: Option, + pub parent: Option, + pub path: String, +} + +pub fn mtime_secs(path: &Path) -> Result { + let meta = std::fs::metadata(path)?; + let mtime = meta + .modified()? + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default(); + Ok(mtime.as_secs() as i64) +} + +pub fn cache_dir_for(root: &Path) -> PathBuf { + let hash = { + let s = root.to_string_lossy(); + let mut h: u64 = 5381; + for b in s.bytes() { + h = h.wrapping_mul(33).wrapping_add(b as u64); + } + h + }; + dirs_cache().join(format!("{hash:x}")) +} + +fn dirs_cache() -> PathBuf { + if let Ok(d) = std::env::var("XDG_CACHE_HOME") { + PathBuf::from(d).join("wm-ts-nav") + } else if let Ok(d) = std::env::var("HOME") { + PathBuf::from(d).join(".cache").join("wm-ts-nav") + } else { + PathBuf::from("/tmp/wm-ts-nav") + } +} diff --git a/wm-ts-nav/src/indexer.rs b/wm-ts-nav/src/indexer.rs new file mode 100644 index 0000000000..ef69fbd1f4 --- /dev/null +++ b/wm-ts-nav/src/indexer.rs @@ -0,0 +1,102 @@ +use anyhow::Result; +use ignore::WalkBuilder; +use rayon::prelude::*; +use std::collections::HashSet; +use std::path::Path; + +use crate::db::{self, Db}; +use crate::parser::{self, Lang}; + +pub struct IndexStats { + pub files_scanned: usize, + pub files_updated: usize, + pub files_removed: usize, + pub files_unchanged: usize, +} + +/// Incrementally update the index for the given root directory. +/// Only re-parses files whose mtime has changed since last index. +pub fn update_index(db: &Db, root: &Path) -> Result { + // Collect all supported files using `ignore` crate (respects .gitignore) + let files: Vec<_> = WalkBuilder::new(root) + .hidden(true) + .git_ignore(true) + .git_global(false) + .build() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false)) + .filter(|e| Lang::from_path(e.path()).is_some()) + .map(|e| e.into_path()) + .collect(); + + let disk_paths: HashSet = files + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + + // Check which files need updating + let existing = db.all_indexed_paths()?; + // Remove files no longer on disk + let mut files_removed = 0; + db.begin()?; + for (path, _) in &existing { + if !disk_paths.contains(path) { + db.remove_file(path)?; + files_removed += 1; + } + } + db.commit()?; + + // Figure out which files need re-parsing + let existing_map: std::collections::HashMap<&str, i64> = existing + .iter() + .map(|(p, m)| (p.as_str(), *m)) + .collect(); + + let to_parse: Vec<_> = files + .iter() + .filter(|path| { + let path_str = path.to_string_lossy(); + match existing_map.get(path_str.as_ref()) { + Some(&old_mtime) => { + // Check if mtime changed + db::mtime_secs(path).unwrap_or(0) != old_mtime + } + None => true, // New file + } + }) + .collect(); + + let files_unchanged = files.len() - to_parse.len(); + + // Parse files in parallel + let results: Vec<_> = to_parse + .par_iter() + .filter_map(|path| { + let mtime = db::mtime_secs(path).ok()?; + match parser::parse_file(path) { + Ok(result) => Some((path.to_string_lossy().to_string(), mtime, result)), + Err(e) => { + eprintln!("warning: failed to parse {}: {e}", path.display()); + None + } + } + }) + .collect(); + + let files_updated = results.len(); + + // Write to db in a single transaction + db.begin()?; + for (path, mtime, result) in &results { + db.upsert_file(path, *mtime, &result.symbols, &result.refs)?; + } + db.commit()?; + + Ok(IndexStats { + files_scanned: files.len(), + files_updated, + files_removed, + files_unchanged, + }) +} diff --git a/wm-ts-nav/src/main.rs b/wm-ts-nav/src/main.rs new file mode 100644 index 0000000000..8ae8c11f5b --- /dev/null +++ b/wm-ts-nav/src/main.rs @@ -0,0 +1,270 @@ +mod db; +mod indexer; +mod parser; + +use anyhow::Result; +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "wm-ts-nav", about = "Tree-sitter code navigator for Windmill")] +struct Cli { + /// Root directory to index (defaults to current directory) + #[arg(short, long)] + root: Option, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Index/re-index the codebase + Index, + /// Show symbols in a file + Outline { + /// File path + file: PathBuf, + }, + /// Search symbols by name pattern + Search { + /// Name pattern (supports SQL LIKE % wildcards) + pattern: String, + /// Filter by kind (function, struct, enum, trait, impl, etc.) + #[arg(short, long)] + kind: Option, + /// Filter by parent (e.g. --parent ServiceName to find methods on that type) + #[arg(short, long)] + parent: Option, + /// Max results + #[arg(short, long, default_value = "50")] + limit: usize, + }, + /// Find symbol definition by exact name + Def { + /// Exact symbol name + name: String, + /// Filter by kind + #[arg(short, long)] + kind: Option, + }, + /// Find references to a symbol in code (skips comments and strings) + Refs { + /// Symbol name to find + name: String, + /// Max results + #[arg(short, long, default_value = "50")] + limit: usize, + /// Filter to files matching this substring + #[arg(short, long)] + file: Option, + /// Show which function/symbol contains each reference + #[arg(short, long)] + caller: bool, + }, + /// Extract and print a symbol's source code + Body { + /// Exact symbol name + name: String, + /// Filter by kind + #[arg(short, long)] + kind: Option, + /// Filter to files matching this substring + #[arg(short, long)] + file: Option, + }, + /// Find what calls a symbol (who calls X?) + Callers { + /// Symbol name to find callers of + name: String, + /// Max results + #[arg(short, long, default_value = "50")] + limit: usize, + }, + /// Find what a symbol calls (what does X call?) + Callees { + /// Exact symbol name + name: String, + /// Filter by kind + #[arg(short, long)] + kind: Option, + /// Filter to files matching this substring + #[arg(short, long)] + file: Option, + }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let root = cli + .root + .unwrap_or_else(|| std::env::current_dir().expect("no cwd")); + let root = std::fs::canonicalize(&root)?; + let cache_dir = db::cache_dir_for(&root); + let db = db::Db::open(&cache_dir)?; + + // Always update index incrementally before any query + let stats = indexer::update_index(&db, &root)?; + + match cli.command { + Command::Index => { + println!( + "Indexed {} files: {} updated, {} unchanged, {} removed", + stats.files_scanned, stats.files_updated, stats.files_unchanged, stats.files_removed + ); + } + Command::Outline { file } => { + let file = std::fs::canonicalize(&file)?; + let symbols = db.file_symbols(&file.to_string_lossy())?; + if symbols.is_empty() { + println!("No symbols found"); + return Ok(()); + } + for s in &symbols { + let parent = s + .parent + .as_deref() + .map(|p| format!(" [{p}]")) + .unwrap_or_default(); + let sig = s + .signature + .as_deref() + .map(|s| format!(" {s}")) + .unwrap_or_default(); + println!("L{}-{} {:12} {}{}{}", s.line, s.end_line, s.kind, s.name, parent, sig); + } + } + Command::Search { + pattern, + kind, + parent, + limit, + } => { + let results = db.search_symbols(&pattern, kind.as_deref(), parent.as_deref(), limit)?; + if results.is_empty() { + println!("No symbols matching '{pattern}'"); + return Ok(()); + } + for r in &results { + let sig = r + .signature + .as_deref() + .map(|s| format!(" {s}")) + .unwrap_or_default(); + let parent_info = r + .parent + .as_deref() + .map(|p| format!(" [{p}]")) + .unwrap_or_default(); + println!("{}:{} {:12} {}{}{}", r.path, r.line, r.kind, r.name, parent_info, sig); + } + } + Command::Def { name, kind } => { + let results = db.search_symbols(&name, kind.as_deref(), None, 100)?; + let exact: Vec<_> = results.iter().filter(|r| r.name == name).collect(); + if exact.is_empty() { + println!("No definition found for '{name}'"); + return Ok(()); + } + for r in &exact { + let sig = r + .signature + .as_deref() + .map(|s| format!("\n {s}")) + .unwrap_or_default(); + let parent = r + .parent + .as_deref() + .map(|p| format!(" [{p}]")) + .unwrap_or_default(); + println!( + "{}:L{}-{} {} {}{}{}", + r.path, r.line, r.end_line, r.kind, r.name, parent, sig + ); + } + } + Command::Refs { + name, + limit, + file, + caller, + } => { + let results = db.find_refs(&name, limit, file.as_deref(), caller)?; + if results.is_empty() { + println!("No references found for '{name}'"); + return Ok(()); + } + for r in &results { + let origin = r + .import_path + .as_deref() + .map(|p| format!(" ({p})")) + .unwrap_or_default(); + let caller_info = r + .caller_name + .as_deref() + .map(|c| format!(" [{c}]")) + .unwrap_or_default(); + println!("{}:{}{}{}", r.path, r.line, caller_info, origin); + } + } + Command::Body { name, kind, file } => { + let results = db.search_symbols(&name, kind.as_deref(), None, 100)?; + let mut exact: Vec<_> = results.into_iter().filter(|r| r.name == name).collect(); + if let Some(ref f) = file { + exact.retain(|r| r.path.contains(f.as_str())); + } + if exact.is_empty() { + println!("No definition found for '{name}'"); + return Ok(()); + } + for (i, r) in exact.iter().enumerate() { + if i > 0 { + println!("\n---\n"); + } + println!("{}:L{}-{}", r.path, r.line, r.end_line); + match std::fs::read_to_string(&r.path) { + Ok(contents) => { + let lines: Vec<&str> = contents.lines().collect(); + let start = (r.line as usize).saturating_sub(1); + let end = (r.end_line as usize).min(lines.len()); + for line in &lines[start..end] { + println!("{line}"); + } + } + Err(e) => println!(" (error reading file: {e})"), + } + } + } + Command::Callers { name, limit } => { + let results = db.find_callers(&name, limit)?; + if results.is_empty() { + println!("No callers found for '{name}'"); + return Ok(()); + } + for r in &results { + println!( + "{}:L{}-{} {} {} → L{}", + r.path, r.caller_line, r.caller_end_line, r.caller_kind, r.caller_name, r.ref_line + ); + } + } + Command::Callees { name, kind, file } => { + let results = db.find_callees(&name, kind.as_deref(), file.as_deref())?; + if results.is_empty() { + println!("No callees found for '{name}'"); + return Ok(()); + } + for r in &results { + let origin = r + .import_path + .as_deref() + .map(|p| format!(" ({p})")) + .unwrap_or_default(); + println!("{}{}", r.name, origin); + } + } + } + + Ok(()) +} diff --git a/wm-ts-nav/src/parser.rs b/wm-ts-nav/src/parser.rs new file mode 100644 index 0000000000..43d3bfe0af --- /dev/null +++ b/wm-ts-nav/src/parser.rs @@ -0,0 +1,716 @@ +use anyhow::{Context, Result}; +use std::path::Path; +use tree_sitter::{Node, Parser}; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct Symbol { + pub name: String, + pub kind: String, + pub line: usize, + pub end_line: usize, + pub signature: Option, + pub parent: Option, +} + +#[derive(Debug, Clone)] +pub struct IdentRef { + pub name: String, + pub line: usize, + /// Resolved import path if known (e.g. "windmill_common::error::Error") + pub import_path: Option, +} + +/// A `use` import with its scope +#[derive(Debug, Clone)] +pub struct ImportEntry { + /// The short name (e.g. "Error") + pub name: String, + /// Full path (e.g. "windmill_common::error::Error") + pub full_path: String, + /// Line where the use is declared + pub line: usize, + /// End of the scope this use lives in (file end for top-level, block end for scoped) + pub scope_end: usize, +} + +pub struct ParseResult { + pub symbols: Vec, + pub refs: Vec, +} + +pub enum Lang { + Rust, + Typescript, + Tsx, +} + +impl Lang { + pub fn from_path(path: &Path) -> Option { + match path.extension()?.to_str()? { + "rs" => Some(Self::Rust), + "tsx" | "jsx" => Some(Self::Tsx), + "ts" | "js" => Some(Self::Typescript), + "svelte" => Some(Self::Typescript), // we extract {#if flowStore.val?.value?.failure_module} @@ -67,7 +71,7 @@ + {/if} + + {#if smallFailureModule} - + {/if}
{:else} @@ -124,14 +142,17 @@ {#snippet trigger()} {/snippet} diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte index eada672f24..fc8b68afb9 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte @@ -271,6 +271,8 @@ let sidebarMode: 'list' | 'graph' = 'graph' let minHeight = $state(0) + let flowPaneWidth = $state(0) + let compactTopbar = $derived(flowPaneWidth < 700) export function selectNextId(id: any) { if (flowStore.val.value.modules) { @@ -505,11 +507,12 @@ {/each} -
+
void disableAi?: boolean diffManager?: FlowDiffManager + compact?: boolean } let { @@ -33,7 +34,8 @@ noteMode, toggleNoteMode, disableAi, - diffManager + diffManager, + compact = false }: Props = $props() const { selectionManager, flowStore } = getContext('FlowEditorContext') @@ -42,23 +44,31 @@
{#if !disableSettings} - + + + {#snippet text()} + Settings + {/snippet} + {/if} - + {#snippet text()} Error Handler {/snippet} From bc35c946161629b17483130b9634de2416235cff Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 13 Mar 2026 12:29:58 +0000 Subject: [PATCH 037/204] ci --- .github/workflows/npm_on_release.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/npm_on_release.yml b/.github/workflows/npm_on_release.yml index 6aa537060e..ea8bdeb482 100644 --- a/.github/workflows/npm_on_release.yml +++ b/.github/workflows/npm_on_release.yml @@ -8,17 +8,21 @@ on: jobs: publish_typescript_client: runs-on: ubicloud-standard-8 + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 with: node-version: "20.x" registry-url: "https://registry.npmjs.org" - - run: cd typescript-client && ./publish.sh && cd .. - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - run: cd typescript-client && ./publish.sh --provenance --access public && cd .. publish_cli: runs-on: ubicloud-standard-8 + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 @@ -28,6 +32,4 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: latest - - run: cd cli && ./build.sh && cd npm && npm publish - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - run: cd cli && ./build.sh && cd npm && npm publish --provenance --access public From 2a8e276b6d2761bb2798b6bc5f8d90ab34fbb403 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 13 Mar 2026 12:38:41 +0000 Subject: [PATCH 038/204] fix(ci): add NODE_AUTH_TOKEN for npm publish authentication Co-Authored-By: Claude Opus 4.6 --- .github/workflows/npm_on_release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/npm_on_release.yml b/.github/workflows/npm_on_release.yml index ea8bdeb482..5f84cb9ae8 100644 --- a/.github/workflows/npm_on_release.yml +++ b/.github/workflows/npm_on_release.yml @@ -18,6 +18,8 @@ jobs: node-version: "20.x" registry-url: "https://registry.npmjs.org" - run: cd typescript-client && ./publish.sh --provenance --access public && cd .. + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} publish_cli: runs-on: ubicloud-standard-8 permissions: @@ -33,3 +35,5 @@ jobs: with: bun-version: latest - run: cd cli && ./build.sh && cd npm && npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From 44dd3ee8cd05d288828d1d46c84cbcdf40f8fa78 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 13 Mar 2026 12:43:45 +0000 Subject: [PATCH 039/204] fix(ci): remove provenance flag and use NPM_TOKEN for npm publish Co-Authored-By: Claude Opus 4.6 --- .github/workflows/npm_on_release.yml | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/workflows/npm_on_release.yml b/.github/workflows/npm_on_release.yml index 5f84cb9ae8..a41bd80854 100644 --- a/.github/workflows/npm_on_release.yml +++ b/.github/workflows/npm_on_release.yml @@ -8,23 +8,17 @@ on: jobs: publish_typescript_client: runs-on: ubicloud-standard-8 - permissions: - contents: read - id-token: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 with: node-version: "20.x" registry-url: "https://registry.npmjs.org" - - run: cd typescript-client && ./publish.sh --provenance --access public && cd .. + - run: cd typescript-client && ./publish.sh --access public && cd .. env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} publish_cli: runs-on: ubicloud-standard-8 - permissions: - contents: read - id-token: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 @@ -34,6 +28,6 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: latest - - run: cd cli && ./build.sh && cd npm && npm publish --provenance --access public + - run: cd cli && ./build.sh && cd npm && npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From 8301d86800a65bb70e5b29663c7f4d6bf15b0785 Mon Sep 17 00:00:00 2001 From: HugoCasa Date: Fri, 13 Mar 2026 17:45:06 +0100 Subject: [PATCH 040/204] docs: rewrite Code Navigation section with MUST for outline/body and condensed limitations Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f77dbb0600..1e768c2142 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Open-source platform for internal tools, workflows, API integrations, background ## Workflow -1. **Understand**: Before coding, use `wm-ts-nav` to explore (see Code Navigation below). Use `outline` to understand file structure, `body` to read specific symbols, `def`/`callers`/`callees` to trace code. Read `docs/` for domain context. +1. **Understand**: Before coding, explore the codebase (see Code Navigation below). Use `outline` to understand file structure, `body` to read specific symbols, `def`/`callers`/`callees` to trace code, `Grep` to find usages. Read `docs/` for domain context. 2. **Plan**: For non-trivial changes, use plan mode. For large features, break into reviewable stages 3. **Execute**: Follow coding patterns from skills (`rust-backend`, `svelte-frontend`) 4. **Validate**: After every change, run the appropriate checks per `docs/validation.md` @@ -52,11 +52,9 @@ let { my_prop = $bindable(default_value) }: { my_prop?: string } = $props() ## Code Navigation -`wm-ts-nav` is an AST-aware code navigator. Use **Grep** for regex/pattern search. Use **wm-ts-nav** for structural queries — it skips comments/strings and understands symbol boundaries. +`wm-ts-nav` is an AST-aware code navigator. Use **wm-ts-nav** for structural queries — it skips comments/strings and understands symbol boundaries. -**Prefer wm-ts-nav over Read** to save context window: -- `outline ` instead of reading a full file — understand structure first, then `body` or Read for specifics -- `body "X"` instead of reading a full file to see one function/struct +**MUST use `outline` before `Read`** on unfamiliar files — a 500-line file costs ~500 lines of context, while `outline` costs ~20. Then **MUST use `body "X"`** instead of reading a full file to see one function/struct. Use `Read` with offset/limit only when you need surrounding context that `body` doesn't capture. - `refs "X" --caller` instead of reading files to find which function contains each reference - `callers "X"` / `callees "X"` for call-graph questions @@ -73,20 +71,14 @@ $NAV --root backend callers "X" # who calls X? $NAV --root backend callees "X" # what does X call? ``` -**Limitations** — syntax-level analysis, no type inference: -- Import paths are stored literally — `crate::X` and `super::X` pointing to the same type won't be linked -- Re-export chains (`pub use`) aren't followed — refs through different re-export paths won't connect -- Trait methods can't be resolved to their trait definition -- Nested `use` trees (`use foo::{bar::{A, B}, baz::C}`) aren't parsed correctly -- Glob imports (`use foo::*`) — refs won't show import origin -- Macro-generated symbols (e.g. `sqlx::FromRow`) — invisible to tree-sitter -- Single-char identifiers — intentionally filtered out of refs +**Limitations** — syntax-level analysis, no type inference. Use **Grep** instead when completeness matters (finding all usages, exhaustiveness checks): +- `refs`/`callers`/`callees` can't follow re-exports, glob imports, or different import paths to the same symbol +- Trait impls, macro-generated symbols (`sqlx::FromRow`), and namespace member access (`ns.X`) are invisible - `callees` shows all identifiers in a function body, not just actual calls -- `import * as ns` namespace imports — member accesses through `ns.X` aren't resolved ## Core Principles -- **Use `outline`/`body` to explore, then `Read` with offset/limit from the results before editing** — avoid reading full files +- **MUST `outline` before `Read`** on unfamiliar files — then `body` or `Read` with offset/limit for specifics - Search for existing code to reuse before writing new code - Follow established patterns in the codebase - Keep changes focused — don't refactor beyond what's asked From 060687b1fa6b627a7b06fbdc4b3f4eb0b63411c0 Mon Sep 17 00:00:00 2001 From: Pyra <92104930+pyranota@users.noreply.github.com> Date: Fri, 13 Mar 2026 21:30:23 +0100 Subject: [PATCH 041/204] fix(cli): exclude raw app backend files from script metadata generation (#8362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files inside .raw_app/backend/ were incorrectly being processed by `script generate-metadata` and `generate-metadata --skip-flows --skip-apps` because the filter only checked isFlowPath and isAppPath, but not isRawAppPath. This caused backend runnables to be treated as standalone scripts, creating incorrect .script.yaml files at wrong locations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 --- .../generate-metadata/generate-metadata.ts | 5 +- cli/src/commands/script/script.ts | 4 +- cli/test/raw_app_sync.test.ts | 55 ++++++++++++++++++- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/cli/src/commands/generate-metadata/generate-metadata.ts b/cli/src/commands/generate-metadata/generate-metadata.ts index 0d7435856c..ce1b1eb051 100644 --- a/cli/src/commands/generate-metadata/generate-metadata.ts +++ b/cli/src/commands/generate-metadata/generate-metadata.ts @@ -19,7 +19,7 @@ import { ignoreF, } from "../sync/sync.ts"; import { exts } from "../script/script.ts"; -import { isFlowPath, isAppPath } from "../../utils/resource_folders.ts"; +import { isFlowPath, isAppPath, isRawAppPath } from "../../utils/resource_folders.ts"; import { listSyncCodebases } from "../../utils/codebase.ts"; interface StaleItem { @@ -82,7 +82,8 @@ async function generateMetadata( (!isD && !exts.some((ext) => p.endsWith(ext))) || ignore(p, isD) || isFlowPath(p) || - isAppPath(p) + isAppPath(p) || + isRawAppPath(p) ); }, false, diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 3a6556b34d..817b9947d7 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -58,6 +58,7 @@ import { isFlowInlineScriptPath as isFlowInlineScriptPathInternal, isFlowPath, isAppPath, + isRawAppPath, } from "../../utils/resource_folders.ts"; export interface ScriptFile { @@ -1027,7 +1028,8 @@ export async function generateMetadata( (!isD && !exts.some((ext) => p.endsWith(ext))) || ignore(p, isD) || isFlowPath(p) || - isAppPath(p) + isAppPath(p) || + isRawAppPath(p) ); }, false, diff --git a/cli/test/raw_app_sync.test.ts b/cli/test/raw_app_sync.test.ts index f77b4b045b..95d812511f 100644 --- a/cli/test/raw_app_sync.test.ts +++ b/cli/test/raw_app_sync.test.ts @@ -107,7 +107,7 @@ async function readFileContent(filePath: string): Promise { * Create a raw app directory structure on disk * Uses .raw_app folder suffix with raw_app.yaml metadata */ -async function createRawAppOnDisk(appDir: string): Promise { +async function createRawAppOnDisk(appDir: string, includeBackend: boolean = false): Promise { await mkdir(appDir, { recursive: true }); await mkdir(path.join(appDir, "inline_scripts"), { recursive: true }); @@ -131,6 +131,16 @@ async function createRawAppOnDisk(appDir: string): Promise { INLINE_SCRIPT_A_LOCK, "utf-8" ); + + // Optionally create backend runnable (type: inline) + if (includeBackend) { + await mkdir(path.join(appDir, "backend"), { recursive: true }); + await writeFile(path.join(appDir, "backend", "query.yaml"), "type: inline\n", "utf-8"); + await writeFile(path.join(appDir, "backend", "query.ts"), `export async function main(x: number): Promise { + return \`Result: \${x}\`; +} +`, "utf-8"); + } } test("Raw App: full sync workflow - push, pull, modify, push, clear, pull", async () => { @@ -153,7 +163,7 @@ excludes: []`, "utf-8"); // Create folder structure const appDir = path.join(tempDir, "f", "test", "my_raw_app.raw_app"); await mkdir(path.join(tempDir, "f", "test"), { recursive: true }); - await createRawAppOnDisk(appDir); + await createRawAppOnDisk(appDir, true); // Include backend for metadata test // ========================================================================= // STEP 1: Initial push - create raw app on backend @@ -266,6 +276,47 @@ excludes: []`, "utf-8"); const pulledInlineScript = await readFileContent(inlineScriptPath); expect(pulledInlineScript).toContain("modified:"); + + // ========================================================================= + // STEP 7: Test that script generate-metadata does NOT process backend runnables + // ========================================================================= + + // Create a standalone script (should be processed by script generate-metadata) + await writeFile(path.join(tempDir, "f", "test", "standalone.ts"), `export async function main(): Promise { + return "hello"; +} +`, "utf-8"); + + // Run script generate-metadata + const metaResult1 = await backend.runCLICommand( + ['script', 'generate-metadata', '--yes'], + tempDir, "raw_app_test" + ); + expect(metaResult1.code).toEqual(0); + + // Run generate-metadata --skip-flows --skip-apps + const metaResult2 = await backend.runCLICommand( + ['generate-metadata', '--skip-flows', '--skip-apps', '--yes'], + tempDir, "raw_app_test" + ); + expect(metaResult2.code).toEqual(0); + + // Backend runnables should NOT have .script.yaml files + const backendDir = path.join(appDir, "backend"); + expect(await fileExists(path.join(backendDir, "query.yaml"))).toBeTruthy(); + expect(await fileExists(path.join(backendDir, "query.ts"))).toBeTruthy(); + expect(await fileExists(path.join(backendDir, "query.script.yaml"))).toBeFalsy(); + expect(await fileExists(path.join(backendDir, "query.script.lock"))).toBeFalsy(); + + // Bug: raw app backend files get misprocessed and create script files at wrong location + // The path f/test/my_raw_app.raw_app/backend/query.ts gets truncated at first "." + // becoming f/test/my_raw_app.script.yaml (stripping .raw_app/backend/query.ts) + expect(await fileExists(path.join(tempDir, "f", "test", "my_raw_app.script.yaml"))).toBeFalsy(); + expect(await fileExists(path.join(tempDir, "f", "test", "my_raw_app.script.lock"))).toBeFalsy(); + + // Standalone script SHOULD have metadata + expect(await fileExists(path.join(tempDir, "f", "test", "standalone.script.yaml"))).toBeTruthy(); + expect(await fileExists(path.join(tempDir, "f", "test", "standalone.script.lock"))).toBeTruthy(); }); }); From 0d31c35f3e12d637c757a95fe350294002cbf640 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 13 Mar 2026 21:31:06 +0100 Subject: [PATCH 042/204] fix(frontend): filter webhook/email tokens by scope instead of label (#8363) The backend already filters tokens by scope matching the script/flow path. Remove the redundant client-side label prefix filter so that all tokens with matching scopes are shown, not just those with a specific label convention. Co-authored-by: Claude Opus 4.6 --- .../components/triggers/TriggerTokens.svelte | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/frontend/src/lib/components/triggers/TriggerTokens.svelte b/frontend/src/lib/components/triggers/TriggerTokens.svelte index 4cf1a5d027..11d0bfca74 100644 --- a/frontend/src/lib/components/triggers/TriggerTokens.svelte +++ b/frontend/src/lib/components/triggers/TriggerTokens.svelte @@ -1,5 +1,5 @@
+ {#if workspaceDependencies.language === 'powershell'} +
+ JSON object with a "modules" key mapping module names to versions. Use + "*" + or null for latest version, or a specific version string to pin. These + modules are merged with script-level + Import-Module statements at runtime (workspace versions take precedence). +
+ {/if}
From 9554876d8b0b94516185ded1a3e34cc347e1854a Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 16 Mar 2026 15:41:17 +0000 Subject: [PATCH 076/204] chore(main): release 1.658.0 (#8382) * chore(main): release 1.658.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 20 +++ backend/Cargo.lock | 158 +++++++++--------- backend/Cargo.toml | 4 +- 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 +- 15 files changed, 114 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0095c052ff..f3522f9c43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [1.658.0](https://github.com/windmill-labs/windmill/compare/v1.657.2...v1.658.0) (2026-03-16) + + +### Features + +* add GET /api/saml/metadata endpoint ([#8394](https://github.com/windmill-labs/windmill/issues/8394)) ([50b24cf](https://github.com/windmill-labs/windmill/commit/50b24cfdc8bf54656adbdc3315037aa773632076)) +* support custom headers in customai resource type ([#8364](https://github.com/windmill-labs/windmill/issues/8364)) ([5acb367](https://github.com/windmill-labs/windmill/commit/5acb367cf9b4b96ac7129c91df229d1a25258f5b)) +* support multiple secret variables during resource creation ([#8386](https://github.com/windmill-labs/windmill/issues/8386)) ([54841b7](https://github.com/windmill-labs/windmill/commit/54841b7549d5c9719d4dc3cb43e282ba057cd0f3)) + + +### Bug Fixes + +* /updatesqlx now uses ee-repo-ref.txt commit hash ([#8387](https://github.com/windmill-labs/windmill/issues/8387)) ([a519d41](https://github.com/windmill-labs/windmill/commit/a519d4113086430ace1d7ac8795bd2c2a8cf99e9)) +* **native-triggers:** preserve API error response body in HttpRequestError ([#8392](https://github.com/windmill-labs/windmill/issues/8392)) ([1eee89d](https://github.com/windmill-labs/windmill/commit/1eee89d99fbf31751d6257a4015e0b22e3871372)) +* OutputPicker shows stale result after 'Test up to here' ([#8390](https://github.com/windmill-labs/windmill/issues/8390)) ([2907084](https://github.com/windmill-labs/windmill/commit/2907084ca653fc5540bb04a409d2789ddaeec05b)) +* propagate enterprise feature to windmill-api-schedule ([#8391](https://github.com/windmill-labs/windmill/issues/8391)) ([50ef9e7](https://github.com/windmill-labs/windmill/commit/50ef9e79fcef8ee2cccd789b5eb1aacf5647365f)) +* set nsjail time_limit from job timeout so configured defaults are respected ([#8389](https://github.com/windmill-labs/windmill/issues/8389)) ([65a92d9](https://github.com/windmill-labs/windmill/commit/65a92d98994dbe4ae90a5e554e55b3ab44463f86)) +* soft error on AI agent max iterations + rename retries tab to error handling ([#8366](https://github.com/windmill-labs/windmill/issues/8366)) ([1a1e8a1](https://github.com/windmill-labs/windmill/commit/1a1e8a164cccbfcc663b963cb062af9208ff51be)) +* use bookworm-based php image to fix glibc 2.38 incompatibility ([#8381](https://github.com/windmill-labs/windmill/issues/8381)) ([68fd900](https://github.com/windmill-labs/windmill/commit/68fd900076ecf8b20f6622cd5794f1b52c0f5cab)) + ## [1.657.2](https://github.com/windmill-labs/windmill/compare/v1.657.1...v1.657.2) (2026-03-15) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a841b0c356..1343e441ad 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -9247,9 +9247,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -9257,9 +9257,9 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -15741,7 +15741,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-nats", @@ -15808,7 +15808,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15821,7 +15821,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "argon2", @@ -15962,7 +15962,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15985,7 +15985,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15998,7 +15998,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16024,7 +16024,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.657.2" +version = "1.658.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16034,7 +16034,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16051,7 +16051,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -16074,7 +16074,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16097,7 +16097,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16113,7 +16113,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16133,7 +16133,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16153,7 +16153,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16167,7 +16167,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-nats", @@ -16195,7 +16195,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16220,7 +16220,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "flate2", @@ -16238,7 +16238,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16259,7 +16259,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16279,7 +16279,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16309,7 +16309,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16336,7 +16336,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.657.2" +version = "1.658.0" dependencies = [ "lazy_static", "serde", @@ -16348,7 +16348,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.657.2" +version = "1.658.0" dependencies = [ "argon2", "axum 0.7.9", @@ -16371,7 +16371,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16385,7 +16385,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.657.2" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16416,7 +16416,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.657.2" +version = "1.658.0" dependencies = [ "chrono", "lazy_static", @@ -16430,7 +16430,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16449,7 +16449,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.657.2" +version = "1.658.0" dependencies = [ "aes-gcm", "anyhow", @@ -16548,7 +16548,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.657.2" +version = "1.658.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16567,7 +16567,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.657.2" +version = "1.658.0" dependencies = [ "regex", "serde", @@ -16582,7 +16582,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16606,7 +16606,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "futures", @@ -16623,7 +16623,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.657.2" +version = "1.658.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16639,7 +16639,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -16660,7 +16660,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -16691,7 +16691,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-oauth2", @@ -16715,7 +16715,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-stream", @@ -16749,7 +16749,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "futures", @@ -16767,7 +16767,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.657.2" +version = "1.658.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16776,7 +16776,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "lazy_static", @@ -16788,7 +16788,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "serde_json", @@ -16800,7 +16800,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "gosyn", @@ -16812,7 +16812,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "lazy_static", @@ -16824,7 +16824,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "serde_json", @@ -16836,7 +16836,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "nu-parser", @@ -16847,7 +16847,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16858,7 +16858,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16870,7 +16870,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16881,7 +16881,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-recursion", @@ -16905,7 +16905,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "lazy_static", @@ -16919,7 +16919,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16936,7 +16936,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "lazy_static", @@ -16950,7 +16950,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "serde", @@ -16962,7 +16962,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "lazy_static", @@ -16980,7 +16980,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16996,7 +16996,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17012,7 +17012,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "serde", @@ -17023,7 +17023,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-recursion", @@ -17060,7 +17060,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "const_format", @@ -17098,7 +17098,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.657.2" +version = "1.658.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17109,7 +17109,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-recursion", @@ -17138,7 +17138,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -17161,7 +17161,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17194,7 +17194,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17214,7 +17214,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17248,7 +17248,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17283,7 +17283,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17306,7 +17306,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17330,7 +17330,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-nats", @@ -17354,7 +17354,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17389,7 +17389,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17417,7 +17417,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17440,7 +17440,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17458,7 +17458,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.657.2" +version = "1.658.0" dependencies = [ "anyhow", "async-once-cell", @@ -17565,7 +17565,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.657.2" +version = "1.658.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index f787e0d73f..2cabf47d41 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.657.2" +version = "1.658.0" authors.workspace = true edition.workspace = true @@ -82,7 +82,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal"] [workspace.package] -version = "1.657.2" +version = "1.658.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 3ccd769c58..7158f315ec 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.657.2 + version: 1.658.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 50fe0a3281..6e6459b2c9 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.657.2"; +export const VERSION = "v1.658.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 24cc45a1e9..01d4040f4f 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -68,7 +68,7 @@ export { workspaceAdd, }; -export const VERSION = "1.657.2"; +export const VERSION = "1.658.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 f20b5559b5..010d797e34 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.657.2", + "version": "1.658.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.657.2", + "version": "1.658.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index e14c1ae9c9..2a14c5f01f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.657.2", + "version": "1.658.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 3b6d1a73f6..6cace22096 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.657.2" +wmill = ">=1.658.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 4e9490f2d5..0e9a0a8214 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.657.2 + version: 1.658.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index e685867f17..d3f906eccc 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.657.2' + ModuleVersion = '1.658.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 795eba8bca..b0ce39509a 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.657.2" +version = "1.658.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 6efa859cfa..96450d49f6 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.657.2", + "version": "1.658.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 843e33fed9..2f3d36abf2 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.657.2", + "version": "1.658.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 6b7047454e..d460528676 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.657.2 +1.658.0 From 0b65c3d8fa848edb17cf23344648c7f06579f706 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 16 Mar 2026 17:57:38 +0100 Subject: [PATCH 077/204] index EE files in wm-ts-nav code navigator (#8400) EE files (*_ee.rs, *_ee.ts, *_ee.svelte) are symlinks from windmill-ee-private that are gitignored. The walker skipped them because it respects .gitignore. This adds a separate recursive scan for _ee files and merges them into the index. Also fixes outline resolving symlinks via canonicalize, causing path mismatches. Co-authored-by: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 ++ wm-ts-nav/src/indexer.rs | 40 ++++++++++++++++++++++++++++++++++++++-- wm-ts-nav/src/main.rs | 9 ++++++++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1e768c2142..0f01e67c57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,8 @@ let { my_prop = $bindable(default_value) }: { my_prop?: string } = $props() - `refs "X" --caller` instead of reading files to find which function contains each reference - `callers "X"` / `callees "X"` for call-graph questions +EE files (`*_ee.rs`, `*_ee.ts`, `*_ee.svelte`) are indexed — you can `outline`, `def`, `body`, `refs` etc. on them just like regular files. + ```bash NAV="sh wm-ts-nav/nav" # Use --root backend for Rust, --root frontend/src for TS/Svelte diff --git a/wm-ts-nav/src/indexer.rs b/wm-ts-nav/src/indexer.rs index ef69fbd1f4..7b1da49a74 100644 --- a/wm-ts-nav/src/indexer.rs +++ b/wm-ts-nav/src/indexer.rs @@ -2,7 +2,7 @@ use anyhow::Result; use ignore::WalkBuilder; use rayon::prelude::*; use std::collections::HashSet; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::db::{self, Db}; use crate::parser::{self, Lang}; @@ -14,14 +14,40 @@ pub struct IndexStats { pub files_unchanged: usize, } +/// Find gitignored *_ee files (EE symlinks from windmill-ee-private). +fn find_ee_files(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name(); + let name_str = name.to_str().unwrap_or(""); + // path.is_dir() follows symlinks + if path.is_dir() { + if !matches!(name_str, "target" | "node_modules" | ".git") { + find_ee_files(&path, out); + } + } else if Lang::from_path(&path).is_some() + && (name_str.ends_with("_ee.rs") + || name_str.ends_with("_ee.ts") + || name_str.ends_with("_ee.svelte")) + && path.exists() // follows symlink, checks target exists + { + out.push(path); + } + } +} + /// Incrementally update the index for the given root directory. /// Only re-parses files whose mtime has changed since last index. pub fn update_index(db: &Db, root: &Path) -> Result { // Collect all supported files using `ignore` crate (respects .gitignore) - let files: Vec<_> = WalkBuilder::new(root) + let mut files: Vec<_> = WalkBuilder::new(root) .hidden(true) .git_ignore(true) .git_global(false) + .follow_links(true) .build() .filter_map(|e| e.ok()) .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false)) @@ -29,6 +55,16 @@ pub fn update_index(db: &Db, root: &Path) -> Result { .map(|e| e.into_path()) .collect(); + // Also include gitignored *_ee files (EE symlinks from windmill-ee-private) + let file_set: HashSet<_> = files.iter().cloned().collect(); + let mut ee_files = Vec::new(); + find_ee_files(root, &mut ee_files); + for f in ee_files { + if !file_set.contains(&f) { + files.push(f); + } + } + let disk_paths: HashSet = files .iter() .map(|p| p.to_string_lossy().to_string()) diff --git a/wm-ts-nav/src/main.rs b/wm-ts-nav/src/main.rs index 8ae8c11f5b..a498b77c09 100644 --- a/wm-ts-nav/src/main.rs +++ b/wm-ts-nav/src/main.rs @@ -94,6 +94,13 @@ enum Command { }, } +/// Get absolute path without resolving symlinks (canonicalize the parent, keep the filename). +fn absolute_no_symlink(p: &std::path::Path) -> Result { + let parent = p.parent().unwrap_or(std::path::Path::new(".")); + let parent = std::fs::canonicalize(parent)?; + Ok(parent.join(p.file_name().unwrap_or_default())) +} + fn main() -> Result<()> { let cli = Cli::parse(); let root = cli @@ -114,7 +121,7 @@ fn main() -> Result<()> { ); } Command::Outline { file } => { - let file = std::fs::canonicalize(&file)?; + let file = absolute_no_symlink(&file)?; let symbols = db.file_symbols(&file.to_string_lossy())?; if symbols.is_empty() { println!("No symbols found"); From 5751e9b26b3dfab129ef8ec2c5b2db5f8855ea6b Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:12:29 +0100 Subject: [PATCH 078/204] chore: return structured error object on AI agent max iterations (#8403) * fix: return structured error object on AI agent max iterations Co-Authored-By: Claude Opus 4.6 * refactor: avoid double serialization in max iterations error Co-Authored-By: Claude Opus 4.6 * fix: replace unwrap with ? for to_raw_value in max iterations error Co-Authored-By: Claude Opus 4.6 * fix: add step_id to max iterations error for consistency with SerializedError Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- backend/windmill-worker/src/ai_executor.rs | 30 +++++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 001b930c6b..710daacef2 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -1038,13 +1038,29 @@ pub async fn run_agent( if tool_calls.is_empty() { break; } else if i == max_iterations - 1 { - let partial_result = serde_json::to_string(&serde_json::json!({ - "messages": messages, - })).unwrap_or_default(); - return Err(Error::internal_err(format!( - "AI agent reached max iterations ({}), you can either increase max_iterations or enable the \"continue on error\" option from the advanced options of the step. Partial result:\n{}", - max_iterations, partial_result - ))); + #[derive(serde::Serialize)] + struct MaxIterError<'a> { + message: String, + name: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + step_id: Option<&'a str>, + result: MaxIterPartialResult<'a>, + } + #[derive(serde::Serialize)] + struct MaxIterPartialResult<'a> { + messages: &'a [OpenAIMessage], + } + return Err(Error::ExecutionRawError( + serde_json::value::to_raw_value(&MaxIterError { + message: format!( + "AI agent reached max iterations ({}), you can either increase max_iterations or enable the \"continue on error\" option from the advanced options of the step.", + max_iterations + ), + name: "ExecutionErr", + step_id: effective_flow_step_id, + result: MaxIterPartialResult { messages: &messages }, + })?, + )); } messages.push(OpenAIMessage { From de5b13b840f90e23df1871f80317fdcc2b98174d Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 17 Mar 2026 01:17:03 +0100 Subject: [PATCH 079/204] feat: add end_user_email claim to OIDC ID tokens (#8401) * feat: add end_user_email claim to OIDC ID tokens When a job is triggered by an app end user, the OIDC ID token now includes the end_user_email claim automatically. The claim is omitted for jobs without an end user (regular script/flow runs). Co-Authored-By: Claude Opus 4.6 (1M context) * update ee ref --------- Co-authored-by: Claude Opus 4.6 (1M context) --- ...629a3e94247c696ce1e222a0e830630141c13.json | 22 +++++++++++++++++++ backend/ee-repo-ref.txt | 2 +- backend/windmill-common/src/oidc_oss.rs | 2 ++ 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 backend/.sqlx/query-741e92197bdaeab62b5b0cff86e629a3e94247c696ce1e222a0e830630141c13.json diff --git a/backend/.sqlx/query-741e92197bdaeab62b5b0cff86e629a3e94247c696ce1e222a0e830630141c13.json b/backend/.sqlx/query-741e92197bdaeab62b5b0cff86e629a3e94247c696ce1e222a0e830630141c13.json new file mode 100644 index 0000000000..e006a18f4b --- /dev/null +++ b/backend/.sqlx/query-741e92197bdaeab62b5b0cff86e629a3e94247c696ce1e222a0e830630141c13.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT end_user_email FROM job_perms WHERE job_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "end_user_email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "741e92197bdaeab62b5b0cff86e629a3e94247c696ce1e222a0e830630141c13" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 01d198fe94..fd93c7df88 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -b2fde51087d0d0ee0223c15cafb4e8badddd2d13 +04689be519d724e437b64c2ec86d47b3270c9be8 diff --git a/backend/windmill-common/src/oidc_oss.rs b/backend/windmill-common/src/oidc_oss.rs index 0a42cee2ed..e212558377 100644 --- a/backend/windmill-common/src/oidc_oss.rs +++ b/backend/windmill-common/src/oidc_oss.rs @@ -60,6 +60,8 @@ pub struct JobClaim { pub username: String, pub email: String, pub workspace: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub end_user_email: Option, } #[cfg(not(feature = "private"))] From fd41cd12b444fb2439214fcd25536280e5baacb2 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 17 Mar 2026 00:49:21 +0000 Subject: [PATCH 080/204] fix: improve OOM killer observability for debugging pod-level kills (#8398) * fix: improve OOM killer observability for debugging pod-level kills Co-Authored-By: Claude Opus 4.6 (1M context) * fix: lower worker oom_score_adj to protect it from OOM killer Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address PR review feedback on OOM observability Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- backend/src/cgroups.rs | 57 +++++++++++++++++++-- backend/src/main.rs | 50 +++++++++++++++++- backend/windmill-worker/src/handle_child.rs | 27 ++++++---- 3 files changed, 118 insertions(+), 16 deletions(-) diff --git a/backend/src/cgroups.rs b/backend/src/cgroups.rs index c6120c3524..285b28dfe7 100644 --- a/backend/src/cgroups.rs +++ b/backend/src/cgroups.rs @@ -42,24 +42,73 @@ pub fn disable_oom_group() -> Result<(), CgroupError> { let oom_group_file = cgroup_path.join("memory.oom.group"); if !oom_group_file.exists() { + tracing::warn!( + "memory.oom.group not found at {:?} — cgroups v2 memory controller may not be enabled. \ + OOM killer may kill the entire pod instead of individual jobs", + oom_group_file + ); return Err(CgroupError::NotSupported); } let current = fs::read_to_string(&oom_group_file)?; if current.trim() == "0" { - tracing::info!("memory.oom.group already disabled"); + tracing::info!("memory.oom.group already disabled at {:?}", cgroup_path); return Ok(()); } + tracing::info!( + "memory.oom.group is currently '{}' at {:?}, attempting to disable", + current.trim(), + cgroup_path + ); + match fs::write(&oom_group_file, "0") { Ok(_) => { - tracing::info!("Disabled memory.oom.group at {:?}", cgroup_path); + // Verify the write took effect + match fs::read_to_string(&oom_group_file) { + Ok(val) if val.trim() == "0" => { + tracing::info!("Disabled memory.oom.group at {:?}", cgroup_path); + } + Ok(val) => { + tracing::error!( + "Wrote 0 to memory.oom.group but read back '{}' at {:?}. \ + OOM killer may kill the entire pod instead of individual jobs", + val.trim(), + cgroup_path + ); + return Err(CgroupError::Io(std::io::Error::new( + std::io::ErrorKind::Other, + format!( + "memory.oom.group write did not take effect, read back '{}'", + val.trim() + ), + ))); + } + Err(e) => { + tracing::warn!( + "Wrote 0 to memory.oom.group but could not verify at {:?}: {e}", + cgroup_path + ); + } + } Ok(()) } Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { - tracing::error!("Failed to disable memory.oom.group (need privileged mode)"); + tracing::error!( + "Failed to disable memory.oom.group at {:?} (permission denied). \ + The container needs SYS_RESOURCE capability or privileged mode. \ + OOM killer WILL kill the entire pod instead of individual jobs", + oom_group_file + ); Err(CgroupError::PermissionDenied) } - Err(e) => Err(CgroupError::Io(e)), + Err(e) => { + tracing::error!( + "Failed to disable memory.oom.group at {:?}: {e}. \ + OOM killer may kill the entire pod instead of individual jobs", + oom_group_file + ); + Err(CgroupError::Io(e)) + } } } diff --git a/backend/src/main.rs b/backend/src/main.rs index 08d59c9dd9..aa9f16af89 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -870,7 +870,55 @@ async fn windmill_main() -> anyhow::Result<()> { if worker_mode { #[cfg(any(target_os = "linux"))] if let Err(e) = disable_oom_group() { - tracing::warn!("failed to disable oom group: {:?}", e); + tracing::warn!( + "Failed to disable cgroup OOM group kill: {e:?}. \ + When a job exceeds memory, the OOM killer will kill the entire pod \ + instead of just the offending job process" + ); + } + + // Lower the worker's oom_score_adj so the OOM killer strongly prefers killing + // job subprocesses (oom_score_adj=1000) over the worker itself. + // Kubernetes sets it high for burstable QoS (e.g. 937), leaving a tiny gap vs jobs. + // Requires CAP_SYS_RESOURCE to lower it; if missing, we just warn. + #[cfg(any(target_os = "linux"))] + match std::fs::read_to_string("/proc/self/oom_score_adj") { + Ok(current) => { + let current = current.trim().to_string(); + let current_val = match current.parse::() { + Ok(v) => v, + Err(e) => { + tracing::warn!("Could not parse oom_score_adj '{current}': {e}"); + 0 + } + }; + if current_val > 0 { + match std::fs::write("/proc/self/oom_score_adj", "0") { + Ok(_) => { + tracing::info!( + "Lowered worker oom_score_adj from {current} to 0 \ + (jobs get 1000, gap=1000)" + ); + } + Err(e) => { + tracing::warn!( + "Could not lower worker oom_score_adj from {current} to 0: {e}. \ + Gap to jobs is only {} — OOM killer may target the worker instead. \ + Add CAP_SYS_RESOURCE to the container to fix this", + 1000 - current_val + ); + } + } + } else { + tracing::info!( + "Worker oom_score_adj={current} (jobs get 1000, gap={})", + 1000 - current_val + ); + } + } + Err(e) => { + tracing::warn!("Could not read worker oom_score_adj: {e}"); + } } } diff --git a/backend/windmill-worker/src/handle_child.rs b/backend/windmill-worker/src/handle_child.rs index 205da6700a..06db11ab24 100644 --- a/backend/windmill-worker/src/handle_child.rs +++ b/backend/windmill-worker/src/handle_child.rs @@ -128,17 +128,22 @@ pub async fn handle_child( let pid = child.id(); #[cfg(target_os = "linux")] if let Some(pid) = pid { - //set the highest oom priority - if let Some(mut file) = File::create(format!("/proc/{pid}/oom_score_adj")) - .await - .map_err(|e| { - tracing::error!("Could not create oom_score_file to pid {pid}: {e:#}"); - e - }) - .ok() - { - let _ = file.write_all(b"1000").await; - let _ = file.sync_all().await; + //set the highest oom priority so OOM killer targets this job, not the worker + match File::create(format!("/proc/{pid}/oom_score_adj")).await { + Ok(mut file) => { + if let Err(e) = file.write_all(b"1000").await { + tracing::error!("Failed to write oom_score_adj for pid {pid}: {e:#}"); + } + if let Err(e) = file.sync_all().await { + tracing::warn!("Failed to sync oom_score_adj for pid {pid}: {e:#}"); + } + } + Err(e) => { + tracing::error!( + "Could not open /proc/{pid}/oom_score_adj: {e:#}. \ + OOM killer may target the worker instead of this job" + ); + } } } else { tracing::info!("could not get child pid"); From f2be625348ef308e9768d487e110abbd44d27855 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 17 Mar 2026 01:15:38 +0000 Subject: [PATCH 081/204] feat: store hashed tokens instead of plaintext (#8217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: store hashed tokens in the token table instead of plaintext Co-Authored-By: Claude Opus 4.6 * fix: address review issues in token hash migration - Update all base.sql fixtures to include token_hash/token_prefix columns - Keep plaintext token for webhook tokens (needed for URL reconstruction) - Restore get_token_by_prefix to query DB for webhook tokens - Fix down migration to delete NULL-token rows before restoring NOT NULL - Update parser fixture standalone schema - Update EE dedicated_worker_ee.rs to use token_hash/token_prefix Co-Authored-By: Claude Opus 4.6 * fix: restore sqlx offline cache (only add new query files) Co-Authored-By: Claude Opus 4.6 * refactor: keep writing plaintext token column for backward compat Write to token column alongside token_hash until MIN_VERSION_SUPPORTS_TOKEN_HASH (1.649.0) is reached. This ensures older workers can still authenticate during rolling upgrades. Remove the separate UPDATE in new_webhook_token since create_token_internal now writes plaintext directly. Co-Authored-By: Claude Opus 4.6 * feat: branch on MIN_VERSION to write plaintext token or null Check MIN_VERSION_SUPPORTS_TOKEN_HASH at runtime: write plaintext to token column while old workers exist, switch to NULL once all workers are >= 1.649.0. Co-Authored-By: Claude Opus 4.6 * fix: set MIN_VERSION_SUPPORTS_TOKEN_HASH to 1.650.0 Co-Authored-By: Claude Opus 4.6 * fix: use token_hash for email lookup and expiry notifications Co-Authored-By: Claude Opus 4.6 * refactor: rotate webhook tokens instead of recovering plaintext from DB Co-Authored-By: Claude Opus 4.6 * refactor: use token_hash for native trigger token lookups and deletes Co-Authored-By: Claude Opus 4.6 * sqlx * refactor: drop webhook_token_prefix from native_trigger table Co-Authored-By: Claude Opus 4.5 * fix: backward compat for token rotation and make webhook_token_hash NOT NULL Co-Authored-By: Claude Opus 4.5 * fix: prevent panic on short superadmin secret token prefix Co-Authored-By: Claude Opus 4.5 * fix: prevent panic on short superadmin secret token prefix Replace all `token[0..TOKEN_PREFIX_LEN]` slicing with `token.get(..TOKEN_PREFIX_LEN).unwrap_or(token)` to prevent panics when a token shorter than 10 chars is provided (e.g. malformed Authorization header, short superadmin secret). Co-authored-by: hugocasa * fix: prevent panic on short token prefix slicing Replace all `token[0..TOKEN_PREFIX_LEN]` with safe `token.get(..TOKEN_PREFIX_LEN).unwrap_or(token)` to prevent panics on malformed tokens shorter than 10 characters. Co-authored-by: hugocasa Co-Authored-By: Claude Opus 4.6 * Revert "fix: prevent panic on short superadmin secret token prefix" This reverts commit 37ec2e5ad52a0552da694bf9ef991b4bc267561e. * revert: remove unnecessary defensive token prefix slicing Co-Authored-By: Claude Opus 4.6 * fix: add token_hash to end_user_email test fixture Co-Authored-By: Claude Opus 4.5 * test: add integration tests for token hash migration Co-Authored-By: Claude Opus 4.5 * fix: correct token_hash test assertions for cache and version Co-Authored-By: Claude Opus 4.5 * chore: add plaintext column removal reminder to test fixtures Co-Authored-By: Claude Opus 4.5 * fix: log count of orphaned triggers deleted during migration Co-Authored-By: Claude Opus 4.5 * fix: preserve orphaned triggers with error instead of deleting Co-Authored-By: Claude Opus 4.5 * fix: rename token_expiry_notification.token to token_hash and copy owner/expiration in rotate Co-Authored-By: Claude Opus 4.5 * fix: hash existing plaintext values before renaming token_expiry_notification column Co-Authored-By: Claude Opus 4.5 * fix: remove unnecessary length check in token_expiry_notification migration Co-Authored-By: Claude Opus 4.5 * update dates and version * updat ee ref + sqlx * improve mcp migration * fix: atomic token rotation with rollback on trigger update failure rotate_webhook_token now atomically inserts the new token and deletes the old one in a single transaction, preventing token leaks. Returns new_token_hash so callers can clean up the new token if their subsequent trigger update fails (which involves external HTTP calls and cannot be in the same DB transaction). - Handler: wraps post-rotation work; deletes new token on failure - Google renewal: deletes new token if service_config update fails - Tests updated to match new atomic semantics Co-Authored-By: Claude Opus 4.6 (1M context) * higher min version * fix: defer old token deletion to avoid breaking triggers on update failure rotate_webhook_token now keeps the old token alive and returns old_token_hash. Callers delete it only after the trigger row has been successfully updated. If the external service call or DB update fails, the trigger keeps working with the old token. Worst case: if the best-effort delete fails, the old token leaks as an extra DB row — harmless compared to breaking the trigger. Also update summarized_schema.txt for renamed columns. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 2d0823a471014e2bc2d898c63518323946b7474f This commit updates the EE repository reference after PR #437 was merged in windmill-ee-private. Previous ee-repo-ref: 7aef8b06cb6f54c2bc89dd57b70947deed72553c New ee-repo-ref: 2d0823a471014e2bc2d898c63518323946b7474f Automated by sync-ee-ref workflow. * fix: prevent panic on short tokens by using safe prefix extraction Add safe_token_prefix() helper that uses .get(..TOKEN_PREFIX_LEN).unwrap_or(token) instead of direct slice indexing, preventing panics when tokens are shorter than 10 characters (e.g., short superadmin secrets or malformed Bearer tokens). Co-authored-by: Ruben Fiszel Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: HugoCasa Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: hugocasa Co-authored-by: windmill-internal-app[bot] Co-authored-by: Ruben Fiszel --- ...39a0405d479e02ac79c2f4ec9a97636bd80d.json} | 7 +- ...85f937776a6b47cd99c648bb4d819d3cfe57.json} | 8 +- ...54d01fe0a2c7e9692ac4ce29a86eb509e1a1d.json | 17 - ...2749ef04e1a8c944c0fb7696e387d10370217.json | 16 + ...a182f3953150ade1756259b31a50e9308fce.json} | 6 +- ...408dd2aa4e47ef7a38a0d3ccca944a09f5bb.json} | 8 +- ...b7e169d07463f67f1da79c8bc23c1079c185.json} | 8 +- ...1a47475dcf1a388c613a9e0154aef6582a23.json} | 4 +- ...2e1f4901b0382fca50704c9544cdec5f9352c.json | 21 + ...1b372e9d40dee29a10fb8fec89492fc501556.json | 20 + ...49b8082c0052d626bf67e08317e56ab9ad026.json | 58 +++ ...6f2e18cb911352551d0a34bf1933e558674e.json} | 4 +- ...4b936c3ffff44ae58f0709d05ba3ff534f79.json} | 4 +- ...aab2b35961f750600500e3736edcff1c191fe.json | 20 + ...697cd321b397422a7c689b85216bbb1fd525.json} | 8 +- ...e2e60e3183fa81a411622891caea6dc03fa90.json | 15 + ...27a85270c25c5a0f0d4d7670bd602bd3cebf.json} | 6 +- ...c84c3bb44adc490309954359845c06b6e607c.json | 18 - ...b25915f671e7c52426fc54b2fd533b90596e2.json | 20 + ...fa0e94550eb617612c2bba81b9ce150d355b5.json | 22 + ...578261fb9ebd92a81e1f2c8edaf93cece0819.json | 14 - ...3c0bcddc0943346f02315b962db3b13590d30.json | 22 + ...86a4f4ea8880cc5d0b511aa70b6fd50072c5.json} | 6 +- ...25bf2f3a35d90f7354c7d3aef8a3538a03a7.json} | 4 +- ...b994db0dc1728945f90fd23b707ee355d0472.json | 22 + ...2e1900d97721d931cea80a4fe240901b3604.json} | 4 +- ...d8e56f0c70dccd0edbc9667f550b31ab54c18.json | 12 - ...71f5a0ab04cc4ac9f236ffd056d3acdb79711.json | 23 + ...ebf080df3f76ac3e6e6373a89c8d46388125d.json | 18 - ...e955820f7b2761df6b38a6a6615b518188f9.json} | 4 +- ...c6c06aa6934b87200da14231f12f65149cd3.json} | 8 +- ...9b09a60d732099a2de9e001d2b40a8e919a9.json} | 7 +- ...3642a89b16701e71d624c6adacb652e231a1a.json | 22 + ...5c57375fdb528a30de49adbe5305fc237b2c5.json | 20 + ...675a2a05eaf30237e21359c52f31bb1bddc73.json | 22 + ...dc715d80b00c0e79b61b0057a3ae3886a85e.json} | 6 +- ...da8d6a3b425ea0590a66f1db6692dd2ddb437.json | 15 - ...702443285e4d1d11e5a0f4cd2a3d4add51bb.json} | 6 +- ...2c11ed88327cf033e1abfd7c0458b71f53da.json} | 6 +- ...24d43eaab99b9f1b7d55eff25b0225bb1504.json} | 4 +- ...dfdf104e3d77b0725c5355305d19f67359e90.json | 19 + ...30c0cf40a99edb4ae9fe3e9be485ab1077b3a.json | 19 - ...9945b6baeb1f1e9e36c9949b9d5fe1f78431.json} | 4 +- ...5209e1ff2c235df1e39ddc3dd21f60f66ef4.json} | 4 +- ...d19d914307a95d72bb0fb447e5b367d52f6a0.json | 20 - ...030140350134ed4f42478daba17655802f238.json | 15 + ...706d78a6f24cb0e614d7d81ba1b643805bf06.json | 22 - ...32caa6e6682620c90f38b29dd32b9a8fe633.json} | 8 +- backend/ee-repo-ref.txt | 2 +- .../20260316000000_token_hash.down.sql | 8 + .../20260316000000_token_hash.up.sql | 31 ++ ...20260316000001_token_hash_pk_swap.down.sql | 26 ++ .../20260316000001_token_hash_pk_swap.up.sql | 22 + ...6000002_native_trigger_token_hash.down.sql | 9 + ...316000002_native_trigger_token_hash.up.sql | 22 + ..._token_expiry_notification_column.down.sql | 5 + ...me_token_expiry_notification_column.up.sql | 5 + ...cp_oauth_rename_access_token_hash.down.sql | 4 + ..._mcp_oauth_rename_access_token_hash.up.sql | 6 + .../tests/fixtures/base.sql | 8 +- backend/src/main.rs | 2 +- backend/src/monitor.rs | 10 +- backend/summarized_schema.txt | 8 +- backend/tests/fixtures/base.sql | 8 +- backend/tests/fixtures/end_user_email.sql | 10 +- backend/tests/fixtures/permissions_test.sql | 12 +- .../tests/fixtures/preserve_on_behalf_of.sql | 16 +- backend/windmill-api-auth/src/auth.rs | 52 ++- backend/windmill-api-auth/src/lib.rs | 42 +- .../tests/fixtures/base.sql | 8 +- .../tests/fixtures/permissions_test.sql | 12 +- .../tests/permissions.rs | 113 ++++- .../tests/token_hash.rs | 399 ++++++++++++++++++ backend/windmill-api-users/src/users.rs | 74 +++- .../windmill-api-workspaces/src/workspaces.rs | 2 +- backend/windmill-api/src/mcp/oauth_server.rs | 63 ++- backend/windmill-api/src/workspaces_export.rs | 2 +- backend/windmill-common/src/auth.rs | 15 + backend/windmill-common/src/min_version.rs | 1 + .../windmill-common/tests/fixtures/base.sql | 6 +- .../windmill-common/tests/notify_events.rs | 335 +++++++++++---- .../src/google/external.rs | 40 +- .../windmill-native-triggers/src/handler.rs | 87 ++-- backend/windmill-native-triggers/src/lib.rs | 138 ++++-- backend/windmill-native-triggers/src/sync.rs | 1 + .../src/workspace_integrations.rs | 12 +- .../windmill-queue/tests/fixtures/base.sql | 6 +- 87 files changed, 1725 insertions(+), 543 deletions(-) rename backend/.sqlx/{query-3317484a9c09c07c2c9db9debaecc4a4d518093ab48e79365dbb808068e0b8ff.json => query-0f5a31f328e59befb7dd3c3cb44439a0405d479e02ac79c2f4ec9a97636bd80d.json} (54%) rename backend/.sqlx/{query-bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f.json => query-104fc7e5433abd7247323c5ef76b85f937776a6b47cd99c648bb4d819d3cfe57.json} (75%) delete mode 100644 backend/.sqlx/query-15ef5759a2ccd7b7f9fd3f2ce0d54d01fe0a2c7e9692ac4ce29a86eb509e1a1d.json create mode 100644 backend/.sqlx/query-1a2470da1015634d15952819f482749ef04e1a8c944c0fb7696e387d10370217.json rename backend/.sqlx/{query-ee537def1ead8bee48bb9f5c1f57d42e7add6011c34d91761ba23e2c74c4032c.json => query-1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce.json} (80%) rename backend/.sqlx/{query-8be2919c3511575c89b882b112b987fd5724c299cb285f819a2561260404e513.json => query-1bf4a93cb85c6eed313a2f393da9408dd2aa4e47ef7a38a0d3ccca944a09f5bb.json} (68%) rename backend/.sqlx/{query-6a254de9005594dc75a59a545546417c8a5aa7635be1dc0b37dc29d0f9e7c163.json => query-207106aa8267fe756989f3ee1eadb7e169d07463f67f1da79c8bc23c1079c185.json} (61%) rename backend/.sqlx/{query-1bdf186d3b99bbd913cbf95150105470cd5f1d4ddbb147cb8ce46f9d1da5dfaf.json => query-215163b5a2791c51f9b28681c1ca1a47475dcf1a388c613a9e0154aef6582a23.json} (55%) create mode 100644 backend/.sqlx/query-223fbd972728d5b3ec5b1708e3f2e1f4901b0382fca50704c9544cdec5f9352c.json create mode 100644 backend/.sqlx/query-27cafd840e5f2c85d1c1e02d84a1b372e9d40dee29a10fb8fec89492fc501556.json create mode 100644 backend/.sqlx/query-406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026.json rename backend/.sqlx/{query-27ada97cb533c8595f1d73987c7823d8e54c96889e06895c57cafae9ca27bf8b.json => query-40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e.json} (63%) rename backend/.sqlx/{query-66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a.json => query-4b76c4a387786bc5bb69e4c684c34b936c3ffff44ae58f0709d05ba3ff534f79.json} (54%) create mode 100644 backend/.sqlx/query-4bf2f3c6771ab4a15b94ba713ebaab2b35961f750600500e3736edcff1c191fe.json rename backend/.sqlx/{query-29673d489fbf45fc249da04c1a2fd60e2364ba87263f962ed7d4329c916620a1.json => query-4c7231f24fd0bcc99004c5bd4065697cd321b397422a7c689b85216bbb1fd525.json} (61%) create mode 100644 backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json rename backend/.sqlx/{query-2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91.json => query-52379713a1f7312127bcd13c9a8027a85270c25c5a0f0d4d7670bd602bd3cebf.json} (65%) delete mode 100644 backend/.sqlx/query-54756c6c39888feb2206b056df1c84c3bb44adc490309954359845c06b6e607c.json create mode 100644 backend/.sqlx/query-54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2.json create mode 100644 backend/.sqlx/query-56031289603fbf9c60ff2c04750fa0e94550eb617612c2bba81b9ce150d355b5.json delete mode 100644 backend/.sqlx/query-58dc872520beaa914fef8b7f30e578261fb9ebd92a81e1f2c8edaf93cece0819.json create mode 100644 backend/.sqlx/query-5c09c2ffb28f6eee3d7e48bd6373c0bcddc0943346f02315b962db3b13590d30.json rename backend/.sqlx/{query-83d6e371ca84903e9f487afc065353a9f7be86ff752612909587ec3cb770cb75.json => query-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json} (71%) rename backend/.sqlx/{query-023cdbc77ea9e2c17a1aa92a5b9001f29e58e81b3f782887db6e0a627dd8ad75.json => query-6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7.json} (62%) create mode 100644 backend/.sqlx/query-722f9da2b3ad1e1129928c52498b994db0dc1728945f90fd23b707ee355d0472.json rename backend/.sqlx/{query-bfff3d8df18db198d6ebba8a049b00147fc8bcd42f3df37ef81b9ded80974bd0.json => query-8065ed67770101e30eea456c1c682e1900d97721d931cea80a4fe240901b3604.json} (57%) delete mode 100644 backend/.sqlx/query-8515537f73c132e62c4dafad1e8d8e56f0c70dccd0edbc9667f550b31ab54c18.json create mode 100644 backend/.sqlx/query-88b6a76134a822d4b706c361a7c71f5a0ab04cc4ac9f236ffd056d3acdb79711.json delete mode 100644 backend/.sqlx/query-8aebd7f7fd1374f1c3d5389e953ebf080df3f76ac3e6e6373a89c8d46388125d.json rename backend/.sqlx/{query-c7d595d2a12228c49359440ca3a9622f1de5f5ee4bbe5d2b23f6fdb6379cebf3.json => query-93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9.json} (70%) rename backend/.sqlx/{query-d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6.json => query-94fd0a57cfc9341b2e9deae60506c6c06aa6934b87200da14231f12f65149cd3.json} (62%) rename backend/.sqlx/{query-2fd22c4ffa2d222bb116260994a748e0639c2f73cbc1d8be66420c70b14c96e1.json => query-95e77019bca83ce43b629e7aac429b09a60d732099a2de9e001d2b40a8e919a9.json} (57%) create mode 100644 backend/.sqlx/query-983c21be4341a7ff9eb647041aa3642a89b16701e71d624c6adacb652e231a1a.json create mode 100644 backend/.sqlx/query-98d929e1c12a41933f5cfbd79175c57375fdb528a30de49adbe5305fc237b2c5.json create mode 100644 backend/.sqlx/query-9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73.json rename backend/.sqlx/{query-ecab1af12a7afa685c056b9d0e526275203fc8ecddf83ca6d05c9fb77e46e7ee.json => query-a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e.json} (64%) delete mode 100644 backend/.sqlx/query-a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437.json rename backend/.sqlx/{query-b615d73ddb43e9d655b86a0cf98f892bf40e629ee11ee4845199481755f2789d.json => query-bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb.json} (82%) rename backend/.sqlx/{query-5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd.json => query-c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da.json} (86%) rename backend/.sqlx/{query-55cf43cb9219b43f8e9f94b23b62846cd0b1ef5f64d20b0d975d0058730f427b.json => query-ca8997323e27f99bfc5ad8c4a54224d43eaab99b9f1b7d55eff25b0225bb1504.json} (66%) create mode 100644 backend/.sqlx/query-d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90.json delete mode 100644 backend/.sqlx/query-d32448f6b329cf98dad42b218a630c0cf40a99edb4ae9fe3e9be485ab1077b3a.json rename backend/.sqlx/{query-97e364c703bdcdfb5345810659cbe0477a28b8199ef0b297f9a22c88a43b6b5c.json => query-db2d2f67c785f790a1a2bd7181a69945b6baeb1f1e9e36c9949b9d5fe1f78431.json} (65%) rename backend/.sqlx/{query-2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8.json => query-dd8c63ac04e33e2863ff3712fc6a5209e1ff2c235df1e39ddc3dd21f60f66ef4.json} (57%) delete mode 100644 backend/.sqlx/query-e33be0991702ae3a295db7defc6d19d914307a95d72bb0fb447e5b367d52f6a0.json create mode 100644 backend/.sqlx/query-e4b5ea8c2a5644471c103463e79030140350134ed4f42478daba17655802f238.json delete mode 100644 backend/.sqlx/query-eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06.json rename backend/.sqlx/{query-3b746f73abbaea3570b9c79af21d4d0f60232098d69b71c21fd3da985f7a5905.json => query-ebc2eed287f93e184ed683feb20432caa6e6682620c90f38b29dd32b9a8fe633.json} (72%) create mode 100644 backend/migrations/20260316000000_token_hash.down.sql create mode 100644 backend/migrations/20260316000000_token_hash.up.sql create mode 100644 backend/migrations/20260316000001_token_hash_pk_swap.down.sql create mode 100644 backend/migrations/20260316000001_token_hash_pk_swap.up.sql create mode 100644 backend/migrations/20260316000002_native_trigger_token_hash.down.sql create mode 100644 backend/migrations/20260316000002_native_trigger_token_hash.up.sql create mode 100644 backend/migrations/20260316000003_rename_token_expiry_notification_column.down.sql create mode 100644 backend/migrations/20260316000003_rename_token_expiry_notification_column.up.sql create mode 100644 backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.down.sql create mode 100644 backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.up.sql create mode 100644 backend/windmill-api-integration-tests/tests/token_hash.rs diff --git a/backend/.sqlx/query-3317484a9c09c07c2c9db9debaecc4a4d518093ab48e79365dbb808068e0b8ff.json b/backend/.sqlx/query-0f5a31f328e59befb7dd3c3cb44439a0405d479e02ac79c2f4ec9a97636bd80d.json similarity index 54% rename from backend/.sqlx/query-3317484a9c09c07c2c9db9debaecc4a4d518093ab48e79365dbb808068e0b8ff.json rename to backend/.sqlx/query-0f5a31f328e59befb7dd3c3cb44439a0405d479e02ac79c2f4ec9a97636bd80d.json index edabfc0be8..734f76ba0a 100644 --- a/backend/.sqlx/query-3317484a9c09c07c2c9db9debaecc4a4d518093ab48e79365dbb808068e0b8ff.json +++ b/backend/.sqlx/query-0f5a31f328e59befb7dd3c3cb44439a0405d479e02ac79c2f4ec9a97636bd80d.json @@ -1,17 +1,16 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM variable WHERE path = $1 AND workspace_id = $2 RETURNING path", + "query": "SELECT token_hash FROM token WHERE token_hash = $1", "describe": { "columns": [ { "ordinal": 0, - "name": "path", + "name": "token_hash", "type_info": "Varchar" } ], "parameters": { "Left": [ - "Text", "Text" ] }, @@ -19,5 +18,5 @@ false ] }, - "hash": "3317484a9c09c07c2c9db9debaecc4a4d518093ab48e79365dbb808068e0b8ff" + "hash": "0f5a31f328e59befb7dd3c3cb44439a0405d479e02ac79c2f4ec9a97636bd80d" } diff --git a/backend/.sqlx/query-bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f.json b/backend/.sqlx/query-104fc7e5433abd7247323c5ef76b85f937776a6b47cd99c648bb4d819d3cfe57.json similarity index 75% rename from backend/.sqlx/query-bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f.json rename to backend/.sqlx/query-104fc7e5433abd7247323c5ef76b85f937776a6b47cd99c648bb4d819d3cfe57.json index 9085383617..a59afbf3ef 100644 --- a/backend/.sqlx/query-bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f.json +++ b/backend/.sqlx/query-104fc7e5433abd7247323c5ef76b85f937776a6b47cd99c648bb4d819d3cfe57.json @@ -1,12 +1,12 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM token WHERE expiration <= now()\n RETURNING substring(token for 10) as token_prefix, label, email, workspace_id", + "query": "DELETE FROM token WHERE expiration <= now()\n RETURNING token_prefix, label, email, workspace_id", "describe": { "columns": [ { "ordinal": 0, "name": "token_prefix", - "type_info": "Text" + "type_info": "Varchar" }, { "ordinal": 1, @@ -28,11 +28,11 @@ "Left": [] }, "nullable": [ - null, + false, true, true, true ] }, - "hash": "bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f" + "hash": "104fc7e5433abd7247323c5ef76b85f937776a6b47cd99c648bb4d819d3cfe57" } diff --git a/backend/.sqlx/query-15ef5759a2ccd7b7f9fd3f2ce0d54d01fe0a2c7e9692ac4ce29a86eb509e1a1d.json b/backend/.sqlx/query-15ef5759a2ccd7b7f9fd3f2ce0d54d01fe0a2c7e9692ac4ce29a86eb509e1a1d.json deleted file mode 100644 index 5552400f28..0000000000 --- a/backend/.sqlx/query-15ef5759a2ccd7b7f9fd3f2ce0d54d01fe0a2c7e9692ac4ce29a86eb509e1a1d.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token\n (token, label, super_admin, email)\n VALUES ($1, $2, $3, $4)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Bool", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "15ef5759a2ccd7b7f9fd3f2ce0d54d01fe0a2c7e9692ac4ce29a86eb509e1a1d" -} diff --git a/backend/.sqlx/query-1a2470da1015634d15952819f482749ef04e1a8c944c0fb7696e387d10370217.json b/backend/.sqlx/query-1a2470da1015634d15952819f482749ef04e1a8c944c0fb7696e387d10370217.json new file mode 100644 index 0000000000..85eaed8eda --- /dev/null +++ b/backend/.sqlx/query-1a2470da1015634d15952819f482749ef04e1a8c944c0fb7696e387d10370217.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin)\n VALUES ($1, $2, $3, 'test@windmill.dev', 'webhook-test', false)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "1a2470da1015634d15952819f482749ef04e1a8c944c0fb7696e387d10370217" +} diff --git a/backend/.sqlx/query-ee537def1ead8bee48bb9f5c1f57d42e7add6011c34d91761ba23e2c74c4032c.json b/backend/.sqlx/query-1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce.json similarity index 80% rename from backend/.sqlx/query-ee537def1ead8bee48bb9f5c1f57d42e7add6011c34d91761ba23e2c74c4032c.json rename to backend/.sqlx/query-1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce.json index 536175599d..492fffe8be 100644 --- a/backend/.sqlx/query-ee537def1ead8bee48bb9f5c1f57d42e7add6011c34d91761ba23e2c74c4032c.json +++ b/backend/.sqlx/query-1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_prefix,\n service_config,\n error,\n created_at,\n updated_at\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND script_path = $3\n AND is_flow = $4\n LIMIT 1\n ", + "query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND script_path = $3\n AND is_flow = $4\n LIMIT 1\n ", "describe": { "columns": [ { @@ -40,7 +40,7 @@ }, { "ordinal": 5, - "name": "webhook_token_prefix", + "name": "webhook_token_hash", "type_info": "Varchar" }, { @@ -95,5 +95,5 @@ false ] }, - "hash": "ee537def1ead8bee48bb9f5c1f57d42e7add6011c34d91761ba23e2c74c4032c" + "hash": "1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce" } diff --git a/backend/.sqlx/query-8be2919c3511575c89b882b112b987fd5724c299cb285f819a2561260404e513.json b/backend/.sqlx/query-1bf4a93cb85c6eed313a2f393da9408dd2aa4e47ef7a38a0d3ccca944a09f5bb.json similarity index 68% rename from backend/.sqlx/query-8be2919c3511575c89b882b112b987fd5724c299cb285f819a2561260404e513.json rename to backend/.sqlx/query-1bf4a93cb85c6eed313a2f393da9408dd2aa4e47ef7a38a0d3ccca944a09f5bb.json index 191b010aec..ae055e1b5f 100644 --- a/backend/.sqlx/query-8be2919c3511575c89b882b112b987fd5724c299cb285f819a2561260404e513.json +++ b/backend/.sqlx/query-1bf4a93cb85c6eed313a2f393da9408dd2aa4e47ef7a38a0d3ccca944a09f5bb.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, last_used_at, scopes FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL)\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", + "query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes 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": [ { @@ -11,7 +11,7 @@ { "ordinal": 1, "name": "token_prefix", - "type_info": "Text" + "type_info": "Varchar" }, { "ordinal": 2, @@ -43,12 +43,12 @@ }, "nullable": [ true, - null, + false, true, false, false, true ] }, - "hash": "8be2919c3511575c89b882b112b987fd5724c299cb285f819a2561260404e513" + "hash": "1bf4a93cb85c6eed313a2f393da9408dd2aa4e47ef7a38a0d3ccca944a09f5bb" } diff --git a/backend/.sqlx/query-6a254de9005594dc75a59a545546417c8a5aa7635be1dc0b37dc29d0f9e7c163.json b/backend/.sqlx/query-207106aa8267fe756989f3ee1eadb7e169d07463f67f1da79c8bc23c1079c185.json similarity index 61% rename from backend/.sqlx/query-6a254de9005594dc75a59a545546417c8a5aa7635be1dc0b37dc29d0f9e7c163.json rename to backend/.sqlx/query-207106aa8267fe756989f3ee1eadb7e169d07463f67f1da79c8bc23c1079c185.json index 3e7972dbd7..e1cdb6416d 100644 --- a/backend/.sqlx/query-6a254de9005594dc75a59a545546417c8a5aa7635be1dc0b37dc29d0f9e7c163.json +++ b/backend/.sqlx/query-207106aa8267fe756989f3ee1eadb7e169d07463f67f1da79c8bc23c1079c185.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT label,\n concat(substring(token for 10)) AS token_prefix,\n expiration,\n created_at,\n last_used_at,\n scopes,\n email\n FROM token\n WHERE workspace_id = $1\n AND (\n scopes @> ARRAY['jobs:run:flows:' || $2]::text[]\n OR scopes @> ARRAY['run:flow/' || $2]::text[]\n )\n ", + "query": "\n SELECT label,\n token_prefix,\n expiration,\n created_at,\n last_used_at,\n scopes,\n email\n FROM token\n WHERE workspace_id = $1\n AND (\n scopes @> ARRAY['jobs:run:scripts:' || $2]::text[]\n OR scopes @> ARRAY['run:script/' || $2]::text[]\n )\n ", "describe": { "columns": [ { @@ -11,7 +11,7 @@ { "ordinal": 1, "name": "token_prefix", - "type_info": "Text" + "type_info": "Varchar" }, { "ordinal": 2, @@ -47,7 +47,7 @@ }, "nullable": [ true, - null, + false, true, false, false, @@ -55,5 +55,5 @@ true ] }, - "hash": "6a254de9005594dc75a59a545546417c8a5aa7635be1dc0b37dc29d0f9e7c163" + "hash": "207106aa8267fe756989f3ee1eadb7e169d07463f67f1da79c8bc23c1079c185" } diff --git a/backend/.sqlx/query-1bdf186d3b99bbd913cbf95150105470cd5f1d4ddbb147cb8ce46f9d1da5dfaf.json b/backend/.sqlx/query-215163b5a2791c51f9b28681c1ca1a47475dcf1a388c613a9e0154aef6582a23.json similarity index 55% rename from backend/.sqlx/query-1bdf186d3b99bbd913cbf95150105470cd5f1d4ddbb147cb8ce46f9d1da5dfaf.json rename to backend/.sqlx/query-215163b5a2791c51f9b28681c1ca1a47475dcf1a388c613a9e0154aef6582a23.json index b58e3bf8d8..54a7a25b68 100644 --- a/backend/.sqlx/query-1bdf186d3b99bbd913cbf95150105470cd5f1d4ddbb147cb8ce46f9d1da5dfaf.json +++ b/backend/.sqlx/query-215163b5a2791c51f9b28681c1ca1a47475dcf1a388c613a9e0154aef6582a23.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "WITH email_lookup AS (\n SELECT email FROM token WHERE token = $1\n )\n DELETE FROM token\n WHERE email = (SELECT email FROM email_lookup) AND label = 'session'\n RETURNING email", + "query": "WITH email_lookup AS (\n SELECT email FROM token WHERE token_hash = $1\n )\n DELETE FROM token\n WHERE email = (SELECT email FROM email_lookup) AND label = 'session'\n RETURNING email", "describe": { "columns": [ { @@ -18,5 +18,5 @@ true ] }, - "hash": "1bdf186d3b99bbd913cbf95150105470cd5f1d4ddbb147cb8ce46f9d1da5dfaf" + "hash": "215163b5a2791c51f9b28681c1ca1a47475dcf1a388c613a9e0154aef6582a23" } diff --git a/backend/.sqlx/query-223fbd972728d5b3ec5b1708e3f2e1f4901b0382fca50704c9544cdec5f9352c.json b/backend/.sqlx/query-223fbd972728d5b3ec5b1708e3f2e1f4901b0382fca50704c9544cdec5f9352c.json new file mode 100644 index 0000000000..8595b12ec6 --- /dev/null +++ b/backend/.sqlx/query-223fbd972728d5b3ec5b1708e3f2e1f4901b0382fca50704c9544cdec5f9352c.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token (token_hash, token_prefix, token, email, label, expiration, scopes, workspace_id)\n SELECT $1::varchar, $2::varchar, $3::varchar, $4::varchar, $5::varchar, now() + ($6 || ' seconds')::interval, $7::text[], $8::varchar\n WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $8 AND deleted = true)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Text", + "TextArray", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "223fbd972728d5b3ec5b1708e3f2e1f4901b0382fca50704c9544cdec5f9352c" +} diff --git a/backend/.sqlx/query-27cafd840e5f2c85d1c1e02d84a1b372e9d40dee29a10fb8fec89492fc501556.json b/backend/.sqlx/query-27cafd840e5f2c85d1c1e02d84a1b372e9d40dee29a10fb8fec89492fc501556.json new file mode 100644 index 0000000000..f2acda445d --- /dev/null +++ b/backend/.sqlx/query-27cafd840e5f2c85d1c1e02d84a1b372e9d40dee29a10fb8fec89492fc501556.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT encode(sha256('SECRET_TOKEN'::bytea), 'hex') AS hash", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "hash", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "27cafd840e5f2c85d1c1e02d84a1b372e9d40dee29a10fb8fec89492fc501556" +} diff --git a/backend/.sqlx/query-406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026.json b/backend/.sqlx/query-406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026.json new file mode 100644 index 0000000000..2b5b68dfae --- /dev/null +++ b/backend/.sqlx/query-406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT label, email, scopes, workspace_id, super_admin, owner, expiration FROM token WHERE token_hash = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "label", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 3, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "super_admin", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "owner", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "expiration", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + true, + true, + true, + false, + true, + true + ] + }, + "hash": "406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026" +} diff --git a/backend/.sqlx/query-27ada97cb533c8595f1d73987c7823d8e54c96889e06895c57cafae9ca27bf8b.json b/backend/.sqlx/query-40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e.json similarity index 63% rename from backend/.sqlx/query-27ada97cb533c8595f1d73987c7823d8e54c96889e06895c57cafae9ca27bf8b.json rename to backend/.sqlx/query-40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e.json index 1199e9441f..706fa0c9ea 100644 --- a/backend/.sqlx/query-27ada97cb533c8595f1d73987c7823d8e54c96889e06895c57cafae9ca27bf8b.json +++ b/backend/.sqlx/query-40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE native_trigger\n SET script_path = $1, is_flow = $2, webhook_token_prefix = $3, service_config = $4, error = NULL, updated_at = NOW()\n WHERE\n workspace_id = $5\n AND service_name = $6\n AND external_id = $7\n ", + "query": "\n UPDATE native_trigger\n SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, error = NULL, updated_at = NOW()\n WHERE\n workspace_id = $5\n AND service_name = $6\n AND external_id = $7\n ", "describe": { "columns": [], "parameters": { @@ -26,5 +26,5 @@ }, "nullable": [] }, - "hash": "27ada97cb533c8595f1d73987c7823d8e54c96889e06895c57cafae9ca27bf8b" + "hash": "40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e" } diff --git a/backend/.sqlx/query-66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a.json b/backend/.sqlx/query-4b76c4a387786bc5bb69e4c684c34b936c3ffff44ae58f0709d05ba3ff534f79.json similarity index 54% rename from backend/.sqlx/query-66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a.json rename to backend/.sqlx/query-4b76c4a387786bc5bb69e4c684c34b936c3ffff44ae58f0709d05ba3ff534f79.json index 3d63bbcfbf..8fc87ce587 100644 --- a/backend/.sqlx/query-66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a.json +++ b/backend/.sqlx/query-4b76c4a387786bc5bb69e4c684c34b936c3ffff44ae58f0709d05ba3ff534f79.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM token WHERE token = $1", + "query": "DELETE FROM token WHERE token_hash = $1", "describe": { "columns": [], "parameters": { @@ -10,5 +10,5 @@ }, "nullable": [] }, - "hash": "66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a" + "hash": "4b76c4a387786bc5bb69e4c684c34b936c3ffff44ae58f0709d05ba3ff534f79" } diff --git a/backend/.sqlx/query-4bf2f3c6771ab4a15b94ba713ebaab2b35961f750600500e3736edcff1c191fe.json b/backend/.sqlx/query-4bf2f3c6771ab4a15b94ba713ebaab2b35961f750600500e3736edcff1c191fe.json new file mode 100644 index 0000000000..10c513f276 --- /dev/null +++ b/backend/.sqlx/query-4bf2f3c6771ab4a15b94ba713ebaab2b35961f750600500e3736edcff1c191fe.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT token_hash FROM token WHERE email = 'test@windmill.dev' AND label = 'test token'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token_hash", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "4bf2f3c6771ab4a15b94ba713ebaab2b35961f750600500e3736edcff1c191fe" +} diff --git a/backend/.sqlx/query-29673d489fbf45fc249da04c1a2fd60e2364ba87263f962ed7d4329c916620a1.json b/backend/.sqlx/query-4c7231f24fd0bcc99004c5bd4065697cd321b397422a7c689b85216bbb1fd525.json similarity index 61% rename from backend/.sqlx/query-29673d489fbf45fc249da04c1a2fd60e2364ba87263f962ed7d4329c916620a1.json rename to backend/.sqlx/query-4c7231f24fd0bcc99004c5bd4065697cd321b397422a7c689b85216bbb1fd525.json index 251fce2637..5cf5699f2a 100644 --- a/backend/.sqlx/query-29673d489fbf45fc249da04c1a2fd60e2364ba87263f962ed7d4329c916620a1.json +++ b/backend/.sqlx/query-4c7231f24fd0bcc99004c5bd4065697cd321b397422a7c689b85216bbb1fd525.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT label,\n concat(substring(token for 10)) AS token_prefix,\n expiration,\n created_at,\n last_used_at,\n scopes,\n email\n FROM token\n WHERE workspace_id = $1\n AND (\n scopes @> ARRAY['jobs:run:scripts:' || $2]::text[]\n OR scopes @> ARRAY['run:script/' || $2]::text[]\n )\n ", + "query": "\n SELECT label,\n token_prefix,\n expiration,\n created_at,\n last_used_at,\n scopes,\n email\n FROM token\n WHERE workspace_id = $1\n AND (\n scopes @> ARRAY['jobs:run:flows:' || $2]::text[]\n OR scopes @> ARRAY['run:flow/' || $2]::text[]\n )\n ", "describe": { "columns": [ { @@ -11,7 +11,7 @@ { "ordinal": 1, "name": "token_prefix", - "type_info": "Text" + "type_info": "Varchar" }, { "ordinal": 2, @@ -47,7 +47,7 @@ }, "nullable": [ true, - null, + false, true, false, false, @@ -55,5 +55,5 @@ true ] }, - "hash": "29673d489fbf45fc249da04c1a2fd60e2364ba87263f962ed7d4329c916620a1" + "hash": "4c7231f24fd0bcc99004c5bd4065697cd321b397422a7c689b85216bbb1fd525" } diff --git a/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json b/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json new file mode 100644 index 0000000000..27d46b27ed --- /dev/null +++ b/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token (token_hash, token_prefix, email, label, super_admin, owner, workspace_id)\n VALUES ($1, $2, 'charlie@windmill.dev', 'Charlie new token', false, 'u/charlie', 'test-workspace')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90" +} diff --git a/backend/.sqlx/query-2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91.json b/backend/.sqlx/query-52379713a1f7312127bcd13c9a8027a85270c25c5a0f0d4d7670bd602bd3cebf.json similarity index 65% rename from backend/.sqlx/query-2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91.json rename to backend/.sqlx/query-52379713a1f7312127bcd13c9a8027a85270c25c5a0f0d4d7670bd602bd3cebf.json index da0ce60709..50a30bd742 100644 --- a/backend/.sqlx/query-2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91.json +++ b/backend/.sqlx/query-52379713a1f7312127bcd13c9a8027a85270c25c5a0f0d4d7670bd602bd3cebf.json @@ -1,11 +1,11 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM token WHERE workspace_id = $1 AND label IS DISTINCT FROM 'session' RETURNING token", + "query": "DELETE FROM token WHERE workspace_id = $1 AND label IS DISTINCT FROM 'session' RETURNING token_prefix", "describe": { "columns": [ { "ordinal": 0, - "name": "token", + "name": "token_prefix", "type_info": "Varchar" } ], @@ -18,5 +18,5 @@ false ] }, - "hash": "2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91" + "hash": "52379713a1f7312127bcd13c9a8027a85270c25c5a0f0d4d7670bd602bd3cebf" } diff --git a/backend/.sqlx/query-54756c6c39888feb2206b056df1c84c3bb44adc490309954359845c06b6e607c.json b/backend/.sqlx/query-54756c6c39888feb2206b056df1c84c3bb44adc490309954359845c06b6e607c.json deleted file mode 100644 index 39355ffc0c..0000000000 --- a/backend/.sqlx/query-54756c6c39888feb2206b056df1c84c3bb44adc490309954359845c06b6e607c.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token\n (token, email, label, expiration, super_admin)\n VALUES ($1, $2, $3, $4, $5)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Timestamptz", - "Bool" - ] - }, - "nullable": [] - }, - "hash": "54756c6c39888feb2206b056df1c84c3bb44adc490309954359845c06b6e607c" -} diff --git a/backend/.sqlx/query-54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2.json b/backend/.sqlx/query-54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2.json new file mode 100644 index 0000000000..de1b71a1e6 --- /dev/null +++ b/backend/.sqlx/query-54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin)\n VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2" +} diff --git a/backend/.sqlx/query-56031289603fbf9c60ff2c04750fa0e94550eb617612c2bba81b9ce150d355b5.json b/backend/.sqlx/query-56031289603fbf9c60ff2c04750fa0e94550eb617612c2bba81b9ce150d355b5.json new file mode 100644 index 0000000000..cc515a4e8f --- /dev/null +++ b/backend/.sqlx/query-56031289603fbf9c60ff2c04750fa0e94550eb617612c2bba81b9ce150d355b5.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT token FROM token WHERE token_hash = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "56031289603fbf9c60ff2c04750fa0e94550eb617612c2bba81b9ce150d355b5" +} diff --git a/backend/.sqlx/query-58dc872520beaa914fef8b7f30e578261fb9ebd92a81e1f2c8edaf93cece0819.json b/backend/.sqlx/query-58dc872520beaa914fef8b7f30e578261fb9ebd92a81e1f2c8edaf93cece0819.json deleted file mode 100644 index f1346579a2..0000000000 --- a/backend/.sqlx/query-58dc872520beaa914fef8b7f30e578261fb9ebd92a81e1f2c8edaf93cece0819.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM token\n WHERE token LIKE concat($1::text, '%')\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [] - }, - "hash": "58dc872520beaa914fef8b7f30e578261fb9ebd92a81e1f2c8edaf93cece0819" -} diff --git a/backend/.sqlx/query-5c09c2ffb28f6eee3d7e48bd6373c0bcddc0943346f02315b962db3b13590d30.json b/backend/.sqlx/query-5c09c2ffb28f6eee3d7e48bd6373c0bcddc0943346f02315b962db3b13590d30.json new file mode 100644 index 0000000000..25bbd4f835 --- /dev/null +++ b/backend/.sqlx/query-5c09c2ffb28f6eee3d7e48bd6373c0bcddc0943346f02315b962db3b13590d30.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM token WHERE token_hash = $1) AS exists", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "5c09c2ffb28f6eee3d7e48bd6373c0bcddc0943346f02315b962db3b13590d30" +} diff --git a/backend/.sqlx/query-83d6e371ca84903e9f487afc065353a9f7be86ff752612909587ec3cb770cb75.json b/backend/.sqlx/query-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json similarity index 71% rename from backend/.sqlx/query-83d6e371ca84903e9f487afc065353a9f7be86ff752612909587ec3cb770cb75.json rename to backend/.sqlx/query-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json index d6240c6b3a..bf734e5861 100644 --- a/backend/.sqlx/query-83d6e371ca84903e9f487afc065353a9f7be86ff752612909587ec3cb770cb75.json +++ b/backend/.sqlx/query-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT external_id, webhook_token_prefix FROM native_trigger WHERE workspace_id = $1 AND service_name = $2", + "query": "SELECT external_id, webhook_token_hash FROM native_trigger WHERE workspace_id = $1 AND service_name = $2", "describe": { "columns": [ { @@ -10,7 +10,7 @@ }, { "ordinal": 1, - "name": "webhook_token_prefix", + "name": "webhook_token_hash", "type_info": "Varchar" } ], @@ -35,5 +35,5 @@ false ] }, - "hash": "83d6e371ca84903e9f487afc065353a9f7be86ff752612909587ec3cb770cb75" + "hash": "6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5" } diff --git a/backend/.sqlx/query-023cdbc77ea9e2c17a1aa92a5b9001f29e58e81b3f782887db6e0a627dd8ad75.json b/backend/.sqlx/query-6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7.json similarity index 62% rename from backend/.sqlx/query-023cdbc77ea9e2c17a1aa92a5b9001f29e58e81b3f782887db6e0a627dd8ad75.json rename to backend/.sqlx/query-6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7.json index d3f1c39c7a..69af249a3f 100644 --- a/backend/.sqlx/query-023cdbc77ea9e2c17a1aa92a5b9001f29e58e81b3f782887db6e0a627dd8ad75.json +++ b/backend/.sqlx/query-6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO native_trigger (\n external_id,\n workspace_id,\n service_name,\n script_path,\n is_flow,\n webhook_token_prefix,\n service_config\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7\n )\n ON CONFLICT (external_id, workspace_id, service_name)\n DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_prefix = $6, service_config = $7, error = NULL, updated_at = NOW()\n ", + "query": "\n INSERT INTO native_trigger (\n external_id,\n workspace_id,\n service_name,\n script_path,\n is_flow,\n webhook_token_hash,\n service_config\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7\n )\n ON CONFLICT (external_id, workspace_id, service_name)\n DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, error = NULL, updated_at = NOW()\n ", "describe": { "columns": [], "parameters": { @@ -26,5 +26,5 @@ }, "nullable": [] }, - "hash": "023cdbc77ea9e2c17a1aa92a5b9001f29e58e81b3f782887db6e0a627dd8ad75" + "hash": "6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7" } diff --git a/backend/.sqlx/query-722f9da2b3ad1e1129928c52498b994db0dc1728945f90fd23b707ee355d0472.json b/backend/.sqlx/query-722f9da2b3ad1e1129928c52498b994db0dc1728945f90fd23b707ee355d0472.json new file mode 100644 index 0000000000..b3f0d62ca8 --- /dev/null +++ b/backend/.sqlx/query-722f9da2b3ad1e1129928c52498b994db0dc1728945f90fd23b707ee355d0472.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM token WHERE token_hash = $1 RETURNING email", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "722f9da2b3ad1e1129928c52498b994db0dc1728945f90fd23b707ee355d0472" +} diff --git a/backend/.sqlx/query-bfff3d8df18db198d6ebba8a049b00147fc8bcd42f3df37ef81b9ded80974bd0.json b/backend/.sqlx/query-8065ed67770101e30eea456c1c682e1900d97721d931cea80a4fe240901b3604.json similarity index 57% rename from backend/.sqlx/query-bfff3d8df18db198d6ebba8a049b00147fc8bcd42f3df37ef81b9ded80974bd0.json rename to backend/.sqlx/query-8065ed67770101e30eea456c1c682e1900d97721d931cea80a4fe240901b3604.json index 4843d959c1..fef6764ba9 100644 --- a/backend/.sqlx/query-bfff3d8df18db198d6ebba8a049b00147fc8bcd42f3df37ef81b9ded80974bd0.json +++ b/backend/.sqlx/query-8065ed67770101e30eea456c1c682e1900d97721d931cea80a4fe240901b3604.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT true FROM token WHERE token = $1 and expiration IS NOT NULL and expiration > now() + $2::int * '1 sec'::interval", + "query": "SELECT true FROM token WHERE token_hash = $1 and expiration IS NOT NULL and expiration > now() + $2::int * '1 sec'::interval", "describe": { "columns": [ { @@ -19,5 +19,5 @@ null ] }, - "hash": "bfff3d8df18db198d6ebba8a049b00147fc8bcd42f3df37ef81b9ded80974bd0" + "hash": "8065ed67770101e30eea456c1c682e1900d97721d931cea80a4fe240901b3604" } diff --git a/backend/.sqlx/query-8515537f73c132e62c4dafad1e8d8e56f0c70dccd0edbc9667f550b31ab54c18.json b/backend/.sqlx/query-8515537f73c132e62c4dafad1e8d8e56f0c70dccd0edbc9667f550b31ab54c18.json deleted file mode 100644 index b03708c813..0000000000 --- a/backend/.sqlx/query-8515537f73c132e62c4dafad1e8d8e56f0c70dccd0edbc9667f550b31ab54c18.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token (token, email, label, super_admin, owner, workspace_id)\n VALUES ('CHARLIE_TOKEN_NEW', 'charlie@windmill.dev', 'Charlie new token', false, 'u/charlie', 'test-workspace')", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "8515537f73c132e62c4dafad1e8d8e56f0c70dccd0edbc9667f550b31ab54c18" -} diff --git a/backend/.sqlx/query-88b6a76134a822d4b706c361a7c71f5a0ab04cc4ac9f236ffd056d3acdb79711.json b/backend/.sqlx/query-88b6a76134a822d4b706c361a7c71f5a0ab04cc4ac9f236ffd056d3acdb79711.json new file mode 100644 index 0000000000..f96090bad5 --- /dev/null +++ b/backend/.sqlx/query-88b6a76134a822d4b706c361a7c71f5a0ab04cc4ac9f236ffd056d3acdb79711.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes, workspace_id, owner, expiration)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Bool", + "TextArray", + "Varchar", + "Varchar", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "88b6a76134a822d4b706c361a7c71f5a0ab04cc4ac9f236ffd056d3acdb79711" +} diff --git a/backend/.sqlx/query-8aebd7f7fd1374f1c3d5389e953ebf080df3f76ac3e6e6373a89c8d46388125d.json b/backend/.sqlx/query-8aebd7f7fd1374f1c3d5389e953ebf080df3f76ac3e6e6373a89c8d46388125d.json deleted file mode 100644 index 90a2f78d2c..0000000000 --- a/backend/.sqlx/query-8aebd7f7fd1374f1c3d5389e953ebf080df3f76ac3e6e6373a89c8d46388125d.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token\n (token, email, label, expiration, super_admin)\n VALUES ($1, $2, $3, now() + ($4 || ' seconds')::interval, $5)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Text", - "Bool" - ] - }, - "nullable": [] - }, - "hash": "8aebd7f7fd1374f1c3d5389e953ebf080df3f76ac3e6e6373a89c8d46388125d" -} diff --git a/backend/.sqlx/query-c7d595d2a12228c49359440ca3a9622f1de5f5ee4bbe5d2b23f6fdb6379cebf3.json b/backend/.sqlx/query-93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9.json similarity index 70% rename from backend/.sqlx/query-c7d595d2a12228c49359440ca3a9622f1de5f5ee4bbe5d2b23f6fdb6379cebf3.json rename to backend/.sqlx/query-93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9.json index a3d8c2502f..c9faa982ba 100644 --- a/backend/.sqlx/query-c7d595d2a12228c49359440ca3a9622f1de5f5ee4bbe5d2b23f6fdb6379cebf3.json +++ b/backend/.sqlx/query-93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE token SET last_used_at = now() WHERE\n token = $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", + "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", "describe": { "columns": [ { @@ -43,5 +43,5 @@ true ] }, - "hash": "c7d595d2a12228c49359440ca3a9622f1de5f5ee4bbe5d2b23f6fdb6379cebf3" + "hash": "93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9" } diff --git a/backend/.sqlx/query-d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6.json b/backend/.sqlx/query-94fd0a57cfc9341b2e9deae60506c6c06aa6934b87200da14231f12f65149cd3.json similarity index 62% rename from backend/.sqlx/query-d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6.json rename to backend/.sqlx/query-94fd0a57cfc9341b2e9deae60506c6c06aa6934b87200da14231f12f65149cd3.json index 015aa7b05a..d54260b4f7 100644 --- a/backend/.sqlx/query-d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6.json +++ b/backend/.sqlx/query-94fd0a57cfc9341b2e9deae60506c6c06aa6934b87200da14231f12f65149cd3.json @@ -1,12 +1,12 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM token_expiry_notification n\n USING token t\n WHERE n.token = t.token\n AND n.expiration > now()\n AND n.expiration <= now() + interval '7 days'\n RETURNING substring(t.token for 10) as token_prefix, t.label, t.email, t.workspace_id", + "query": "DELETE FROM token_expiry_notification n\n USING token t\n WHERE n.token_hash = t.token_hash\n AND n.expiration > now()\n AND n.expiration <= now() + interval '7 days'\n RETURNING t.token_prefix, t.label, t.email, t.workspace_id", "describe": { "columns": [ { "ordinal": 0, "name": "token_prefix", - "type_info": "Text" + "type_info": "Varchar" }, { "ordinal": 1, @@ -28,11 +28,11 @@ "Left": [] }, "nullable": [ - null, + false, true, true, true ] }, - "hash": "d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6" + "hash": "94fd0a57cfc9341b2e9deae60506c6c06aa6934b87200da14231f12f65149cd3" } diff --git a/backend/.sqlx/query-2fd22c4ffa2d222bb116260994a748e0639c2f73cbc1d8be66420c70b14c96e1.json b/backend/.sqlx/query-95e77019bca83ce43b629e7aac429b09a60d732099a2de9e001d2b40a8e919a9.json similarity index 57% rename from backend/.sqlx/query-2fd22c4ffa2d222bb116260994a748e0639c2f73cbc1d8be66420c70b14c96e1.json rename to backend/.sqlx/query-95e77019bca83ce43b629e7aac429b09a60d732099a2de9e001d2b40a8e919a9.json index afd0f503bf..a8fbb473d8 100644 --- a/backend/.sqlx/query-2fd22c4ffa2d222bb116260994a748e0639c2f73cbc1d8be66420c70b14c96e1.json +++ b/backend/.sqlx/query-95e77019bca83ce43b629e7aac429b09a60d732099a2de9e001d2b40a8e919a9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE native_trigger\n SET service_config = $1, updated_at = NOW()\n WHERE\n workspace_id = $2\n AND service_name = $3\n AND external_id = $4\n ", + "query": "\n UPDATE native_trigger\n SET service_config = $1,\n webhook_token_hash = COALESCE($5, webhook_token_hash),\n updated_at = NOW()\n WHERE\n workspace_id = $2\n AND service_name = $3\n AND external_id = $4\n ", "describe": { "columns": [], "parameters": { @@ -18,10 +18,11 @@ } } }, - "Text" + "Text", + "Varchar" ] }, "nullable": [] }, - "hash": "2fd22c4ffa2d222bb116260994a748e0639c2f73cbc1d8be66420c70b14c96e1" + "hash": "95e77019bca83ce43b629e7aac429b09a60d732099a2de9e001d2b40a8e919a9" } diff --git a/backend/.sqlx/query-983c21be4341a7ff9eb647041aa3642a89b16701e71d624c6adacb652e231a1a.json b/backend/.sqlx/query-983c21be4341a7ff9eb647041aa3642a89b16701e71d624c6adacb652e231a1a.json new file mode 100644 index 0000000000..959438f582 --- /dev/null +++ b/backend/.sqlx/query-983c21be4341a7ff9eb647041aa3642a89b16701e71d624c6adacb652e231a1a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email FROM token WHERE token_hash = $1 AND (expiration > NOW() OR expiration IS NULL)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "983c21be4341a7ff9eb647041aa3642a89b16701e71d624c6adacb652e231a1a" +} diff --git a/backend/.sqlx/query-98d929e1c12a41933f5cfbd79175c57375fdb528a30de49adbe5305fc237b2c5.json b/backend/.sqlx/query-98d929e1c12a41933f5cfbd79175c57375fdb528a30de49adbe5305fc237b2c5.json new file mode 100644 index 0000000000..69cd713502 --- /dev/null +++ b/backend/.sqlx/query-98d929e1c12a41933f5cfbd79175c57375fdb528a30de49adbe5305fc237b2c5.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin)\n VALUES ($1, $2, $3, $4, $5, $6, $7)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Timestamptz", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "98d929e1c12a41933f5cfbd79175c57375fdb528a30de49adbe5305fc237b2c5" +} diff --git a/backend/.sqlx/query-9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73.json b/backend/.sqlx/query-9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73.json new file mode 100644 index 0000000000..163dc2285b --- /dev/null +++ b/backend/.sqlx/query-9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9\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" + ] + }, + "nullable": [] + }, + "hash": "9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73" +} diff --git a/backend/.sqlx/query-ecab1af12a7afa685c056b9d0e526275203fc8ecddf83ca6d05c9fb77e46e7ee.json b/backend/.sqlx/query-a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e.json similarity index 64% rename from backend/.sqlx/query-ecab1af12a7afa685c056b9d0e526275203fc8ecddf83ca6d05c9fb77e46e7ee.json rename to backend/.sqlx/query-a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e.json index 16ccdd10e9..660c855622 100644 --- a/backend/.sqlx/query-ecab1af12a7afa685c056b9d0e526275203fc8ecddf83ca6d05c9fb77e46e7ee.json +++ b/backend/.sqlx/query-a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_prefix,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n )\n LIMIT $3\n OFFSET $4\n ", + "query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n )\n LIMIT $3\n OFFSET $4\n ", "describe": { "columns": [ { @@ -40,7 +40,7 @@ }, { "ordinal": 5, - "name": "webhook_token_prefix", + "name": "webhook_token_hash", "type_info": "Varchar" }, { @@ -97,5 +97,5 @@ false ] }, - "hash": "ecab1af12a7afa685c056b9d0e526275203fc8ecddf83ca6d05c9fb77e46e7ee" + "hash": "a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e" } diff --git a/backend/.sqlx/query-a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437.json b/backend/.sqlx/query-a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437.json deleted file mode 100644 index af35d619fa..0000000000 --- a/backend/.sqlx/query-a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token_expiry_notification (token, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Timestamptz" - ] - }, - "nullable": [] - }, - "hash": "a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437" -} diff --git a/backend/.sqlx/query-b615d73ddb43e9d655b86a0cf98f892bf40e629ee11ee4845199481755f2789d.json b/backend/.sqlx/query-bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb.json similarity index 82% rename from backend/.sqlx/query-b615d73ddb43e9d655b86a0cf98f892bf40e629ee11ee4845199481755f2789d.json rename to backend/.sqlx/query-bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb.json index e6ef13f492..a90b2b8398 100644 --- a/backend/.sqlx/query-b615d73ddb43e9d655b86a0cf98f892bf40e629ee11ee4845199481755f2789d.json +++ b/backend/.sqlx/query-bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_prefix,\n service_config,\n error,\n created_at,\n updated_at\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ", + "query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ", "describe": { "columns": [ { @@ -40,7 +40,7 @@ }, { "ordinal": 5, - "name": "webhook_token_prefix", + "name": "webhook_token_hash", "type_info": "Varchar" }, { @@ -94,5 +94,5 @@ false ] }, - "hash": "b615d73ddb43e9d655b86a0cf98f892bf40e629ee11ee4845199481755f2789d" + "hash": "bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb" } diff --git a/backend/.sqlx/query-5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd.json b/backend/.sqlx/query-c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da.json similarity index 86% rename from backend/.sqlx/query-5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd.json rename to backend/.sqlx/query-c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da.json index 63478d0e66..ee946012b2 100644 --- a/backend/.sqlx/query-5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd.json +++ b/backend/.sqlx/query-c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE mcp_oauth_refresh_token\n SET used_at = now()\n WHERE refresh_token = $1\n AND client_id = $2\n AND used_at IS NULL\n AND NOT revoked\n AND expires_at > now()\n RETURNING id, refresh_token, access_token, client_id, user_email, workspace_id,\n scopes, token_family, created_at, expires_at, used_at, revoked", + "query": "UPDATE mcp_oauth_refresh_token\n SET used_at = now()\n WHERE refresh_token = $1\n AND client_id = $2\n AND used_at IS NULL\n AND NOT revoked\n AND expires_at > now()\n RETURNING id, refresh_token, access_token_hash, client_id, user_email, workspace_id,\n scopes, token_family, created_at, expires_at, used_at, revoked", "describe": { "columns": [ { @@ -15,7 +15,7 @@ }, { "ordinal": 2, - "name": "access_token", + "name": "access_token_hash", "type_info": "Varchar" }, { @@ -85,5 +85,5 @@ false ] }, - "hash": "5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd" + "hash": "c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da" } diff --git a/backend/.sqlx/query-55cf43cb9219b43f8e9f94b23b62846cd0b1ef5f64d20b0d975d0058730f427b.json b/backend/.sqlx/query-ca8997323e27f99bfc5ad8c4a54224d43eaab99b9f1b7d55eff25b0225bb1504.json similarity index 66% rename from backend/.sqlx/query-55cf43cb9219b43f8e9f94b23b62846cd0b1ef5f64d20b0d975d0058730f427b.json rename to backend/.sqlx/query-ca8997323e27f99bfc5ad8c4a54224d43eaab99b9f1b7d55eff25b0225bb1504.json index e68f25d6ab..86175b0dc4 100644 --- a/backend/.sqlx/query-55cf43cb9219b43f8e9f94b23b62846cd0b1ef5f64d20b0d975d0058730f427b.json +++ b/backend/.sqlx/query-ca8997323e27f99bfc5ad8c4a54224d43eaab99b9f1b7d55eff25b0225bb1504.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT job FROM token WHERE token = $1", + "query": "SELECT job FROM token WHERE token_hash = $1", "describe": { "columns": [ { @@ -18,5 +18,5 @@ true ] }, - "hash": "55cf43cb9219b43f8e9f94b23b62846cd0b1ef5f64d20b0d975d0058730f427b" + "hash": "ca8997323e27f99bfc5ad8c4a54224d43eaab99b9f1b7d55eff25b0225bb1504" } diff --git a/backend/.sqlx/query-d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90.json b/backend/.sqlx/query-d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90.json new file mode 100644 index 0000000000..e778c17bf6 --- /dev/null +++ b/backend/.sqlx/query-d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token\n (token_hash, token_prefix, token, label, super_admin, email)\n VALUES ($1, $2, $3, $4, $5, $6)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Bool", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90" +} diff --git a/backend/.sqlx/query-d32448f6b329cf98dad42b218a630c0cf40a99edb4ae9fe3e9be485ab1077b3a.json b/backend/.sqlx/query-d32448f6b329cf98dad42b218a630c0cf40a99edb4ae9fe3e9be485ab1077b3a.json deleted file mode 100644 index 73ffec7e48..0000000000 --- a/backend/.sqlx/query-d32448f6b329cf98dad42b218a630c0cf40a99edb4ae9fe3e9be485ab1077b3a.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token (token, email, label, expiration, scopes, workspace_id)\n SELECT $1::varchar, $2::varchar, $3::varchar, now() + ($4 || ' seconds')::interval, $5::text[], $6::varchar\n WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $6 AND deleted = true)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Text", - "TextArray", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "d32448f6b329cf98dad42b218a630c0cf40a99edb4ae9fe3e9be485ab1077b3a" -} diff --git a/backend/.sqlx/query-97e364c703bdcdfb5345810659cbe0477a28b8199ef0b297f9a22c88a43b6b5c.json b/backend/.sqlx/query-db2d2f67c785f790a1a2bd7181a69945b6baeb1f1e9e36c9949b9d5fe1f78431.json similarity index 65% rename from backend/.sqlx/query-97e364c703bdcdfb5345810659cbe0477a28b8199ef0b297f9a22c88a43b6b5c.json rename to backend/.sqlx/query-db2d2f67c785f790a1a2bd7181a69945b6baeb1f1e9e36c9949b9d5fe1f78431.json index 34ff650daf..eadb48cbb5 100644 --- a/backend/.sqlx/query-97e364c703bdcdfb5345810659cbe0477a28b8199ef0b297f9a22c88a43b6b5c.json +++ b/backend/.sqlx/query-db2d2f67c785f790a1a2bd7181a69945b6baeb1f1e9e36c9949b9d5fe1f78431.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM token WHERE token = $1 RETURNING email", + "query": "SELECT email FROM token WHERE token = $1", "describe": { "columns": [ { @@ -18,5 +18,5 @@ true ] }, - "hash": "97e364c703bdcdfb5345810659cbe0477a28b8199ef0b297f9a22c88a43b6b5c" + "hash": "db2d2f67c785f790a1a2bd7181a69945b6baeb1f1e9e36c9949b9d5fe1f78431" } diff --git a/backend/.sqlx/query-2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8.json b/backend/.sqlx/query-dd8c63ac04e33e2863ff3712fc6a5209e1ff2c235df1e39ddc3dd21f60f66ef4.json similarity index 57% rename from backend/.sqlx/query-2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8.json rename to backend/.sqlx/query-dd8c63ac04e33e2863ff3712fc6a5209e1ff2c235df1e39ddc3dd21f60f66ef4.json index 32e0372293..37055880ae 100644 --- a/backend/.sqlx/query-2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8.json +++ b/backend/.sqlx/query-dd8c63ac04e33e2863ff3712fc6a5209e1ff2c235df1e39ddc3dd21f60f66ef4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO mcp_oauth_refresh_token\n (refresh_token, access_token, client_id, user_email, workspace_id, scopes, token_family, expires_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, now() + ($8 || ' seconds')::interval)", + "query": "INSERT INTO mcp_oauth_refresh_token\n (refresh_token, access_token_hash, client_id, user_email, workspace_id, scopes, token_family, expires_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, now() + ($8 || ' seconds')::interval)", "describe": { "columns": [], "parameters": { @@ -17,5 +17,5 @@ }, "nullable": [] }, - "hash": "2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8" + "hash": "dd8c63ac04e33e2863ff3712fc6a5209e1ff2c235df1e39ddc3dd21f60f66ef4" } diff --git a/backend/.sqlx/query-e33be0991702ae3a295db7defc6d19d914307a95d72bb0fb447e5b367d52f6a0.json b/backend/.sqlx/query-e33be0991702ae3a295db7defc6d19d914307a95d72bb0fb447e5b367d52f6a0.json deleted file mode 100644 index 14757923d1..0000000000 --- a/backend/.sqlx/query-e33be0991702ae3a295db7defc6d19d914307a95d72bb0fb447e5b367d52f6a0.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token\n (token, email, label, expiration, super_admin, scopes, workspace_id)\n SELECT $1, $2, $3, $4, $5, $6, $7\n WHERE $7::varchar IS NULL OR NOT EXISTS(\n SELECT 1 FROM workspace WHERE id = $7 AND deleted = true\n )", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Timestamptz", - "Bool", - "TextArray", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "e33be0991702ae3a295db7defc6d19d914307a95d72bb0fb447e5b367d52f6a0" -} diff --git a/backend/.sqlx/query-e4b5ea8c2a5644471c103463e79030140350134ed4f42478daba17655802f238.json b/backend/.sqlx/query-e4b5ea8c2a5644471c103463e79030140350134ed4f42478daba17655802f238.json new file mode 100644 index 0000000000..f4e8f0106c --- /dev/null +++ b/backend/.sqlx/query-e4b5ea8c2a5644471c103463e79030140350134ed4f42478daba17655802f238.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token_expiry_notification (token_hash, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "e4b5ea8c2a5644471c103463e79030140350134ed4f42478daba17655802f238" +} diff --git a/backend/.sqlx/query-eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06.json b/backend/.sqlx/query-eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06.json deleted file mode 100644 index c96961eac4..0000000000 --- a/backend/.sqlx/query-eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT token as \"token!\"\n FROM token\n WHERE token LIKE concat($1::text, '%')\n LIMIT 1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "token!", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06" -} diff --git a/backend/.sqlx/query-3b746f73abbaea3570b9c79af21d4d0f60232098d69b71c21fd3da985f7a5905.json b/backend/.sqlx/query-ebc2eed287f93e184ed683feb20432caa6e6682620c90f38b29dd32b9a8fe633.json similarity index 72% rename from backend/.sqlx/query-3b746f73abbaea3570b9c79af21d4d0f60232098d69b71c21fd3da985f7a5905.json rename to backend/.sqlx/query-ebc2eed287f93e184ed683feb20432caa6e6682620c90f38b29dd32b9a8fe633.json index 3f0749f7bf..657c660228 100644 --- a/backend/.sqlx/query-3b746f73abbaea3570b9c79af21d4d0f60232098d69b71c21fd3da985f7a5905.json +++ b/backend/.sqlx/query-ebc2eed287f93e184ed683feb20432caa6e6682620c90f38b29dd32b9a8fe633.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, last_used_at, scopes FROM token WHERE email = $1\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", + "query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes FROM token WHERE email = $1\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", "describe": { "columns": [ { @@ -11,7 +11,7 @@ { "ordinal": 1, "name": "token_prefix", - "type_info": "Text" + "type_info": "Varchar" }, { "ordinal": 2, @@ -43,12 +43,12 @@ }, "nullable": [ true, - null, + false, true, false, false, true ] }, - "hash": "3b746f73abbaea3570b9c79af21d4d0f60232098d69b71c21fd3da985f7a5905" + "hash": "ebc2eed287f93e184ed683feb20432caa6e6682620c90f38b29dd32b9a8fe633" } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index fd93c7df88..4615ab8810 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -04689be519d724e437b64c2ec86d47b3270c9be8 +2d0823a471014e2bc2d898c63518323946b7474f diff --git a/backend/migrations/20260316000000_token_hash.down.sql b/backend/migrations/20260316000000_token_hash.down.sql new file mode 100644 index 0000000000..02725da65d --- /dev/null +++ b/backend/migrations/20260316000000_token_hash.down.sql @@ -0,0 +1,8 @@ +-- Reverse of step 1: drop indexes and columns + +DROP INDEX IF EXISTS idx_token_plaintext; +DROP INDEX IF EXISTS idx_token_prefix; +DROP INDEX IF EXISTS token_hash_unique; + +ALTER TABLE token DROP COLUMN token_hash; +ALTER TABLE token DROP COLUMN token_prefix; diff --git a/backend/migrations/20260316000000_token_hash.up.sql b/backend/migrations/20260316000000_token_hash.up.sql new file mode 100644 index 0000000000..c0c3bb7375 --- /dev/null +++ b/backend/migrations/20260316000000_token_hash.up.sql @@ -0,0 +1,31 @@ +-- Step 1: Add columns, backfill, build indexes. +-- This migration does the heavy work but avoids ACCESS EXCLUSIVE during index build +-- by creating the unique index first, then using it for the PK swap in the next migration. + +-- Add new columns (instant metadata change) +ALTER TABLE token ADD COLUMN token_hash VARCHAR(64); +ALTER TABLE token ADD COLUMN token_prefix VARCHAR(10); + +-- Backfill existing tokens using built-in sha256() (no extension needed). +-- Takes ROW EXCLUSIVE lock — concurrent reads and non-token writes proceed normally. +UPDATE token +SET token_hash = encode(sha256(token::bytea), 'hex'), + token_prefix = substring(token for 10) +WHERE token_hash IS NULL; + +-- Mark NOT NULL (instant on PG 12+ when all rows already satisfy the constraint) +ALTER TABLE token ALTER COLUMN token_hash SET NOT NULL; +ALTER TABLE token ALTER COLUMN token_prefix SET NOT NULL; + +-- Build the unique index that the next migration will promote to PK. +-- Takes SHARE lock (reads OK, writes wait) but only for the duration of the build, +-- which is fast since token tables are typically small. +CREATE UNIQUE INDEX token_hash_unique ON token (token_hash); + +-- Index on prefix for deletion/listing +CREATE INDEX idx_token_prefix ON token (token_prefix); + +-- Keep old workers fast during rolling upgrades: they query WHERE token = $1 +-- after the PK swap drops the old primary key index on token. +-- Can be dropped once all workers are past MIN_VERSION_SUPPORTS_TOKEN_HASH. +CREATE INDEX idx_token_plaintext ON token (token) WHERE token IS NOT NULL; diff --git a/backend/migrations/20260316000001_token_hash_pk_swap.down.sql b/backend/migrations/20260316000001_token_hash_pk_swap.down.sql new file mode 100644 index 0000000000..ab938e3422 --- /dev/null +++ b/backend/migrations/20260316000001_token_hash_pk_swap.down.sql @@ -0,0 +1,26 @@ +-- Reverse step 2: restore old PK and trigger + +-- Restore the original trigger +CREATE OR REPLACE FUNCTION notify_token_invalidation() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.label = 'session' AND OLD.email IS NOT NULL THEN + INSERT INTO notify_event (channel, payload) + VALUES ('notify_token_invalidation', OLD.token); + END IF; + RETURN OLD; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Delete tokens created after migration that have no plaintext (cannot be restored) +DELETE FROM token WHERE token IS NULL; + +-- Make token NOT NULL again +ALTER TABLE token ALTER COLUMN token SET NOT NULL; + +-- Swap PK back: drop token_hash PK, restore token PK +ALTER TABLE token DROP CONSTRAINT token_pkey; +ALTER TABLE token ADD PRIMARY KEY (token); + +-- Re-create the unique index on token_hash (was consumed by ADD CONSTRAINT ... USING INDEX) +CREATE UNIQUE INDEX token_hash_unique ON token (token_hash); diff --git a/backend/migrations/20260316000001_token_hash_pk_swap.up.sql b/backend/migrations/20260316000001_token_hash_pk_swap.up.sql new file mode 100644 index 0000000000..38e93fb3ba --- /dev/null +++ b/backend/migrations/20260316000001_token_hash_pk_swap.up.sql @@ -0,0 +1,22 @@ +-- Step 2: Swap PK and update trigger. +-- All operations here are instant metadata changes (no data/index rebuild). +-- The ACCESS EXCLUSIVE lock is held for only milliseconds. + +-- Swap primary key: drop old, promote existing unique index (instant) +ALTER TABLE token DROP CONSTRAINT token_pkey; +ALTER TABLE token ADD CONSTRAINT token_pkey PRIMARY KEY USING INDEX token_hash_unique; + +-- Make old token column nullable (no longer written for new tokens) +ALTER TABLE token ALTER COLUMN token DROP NOT NULL; + +-- Update the cache invalidation trigger to send prefix instead of plaintext +CREATE OR REPLACE FUNCTION notify_token_invalidation() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.label = 'session' AND OLD.email IS NOT NULL THEN + INSERT INTO notify_event (channel, payload) + VALUES ('notify_token_invalidation', OLD.token_prefix); + END IF; + RETURN OLD; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/backend/migrations/20260316000002_native_trigger_token_hash.down.sql b/backend/migrations/20260316000002_native_trigger_token_hash.down.sql new file mode 100644 index 0000000000..c45ef7fd01 --- /dev/null +++ b/backend/migrations/20260316000002_native_trigger_token_hash.down.sql @@ -0,0 +1,9 @@ +ALTER TABLE native_trigger ADD COLUMN webhook_token_prefix VARCHAR(10) NOT NULL DEFAULT ''; + +-- Backfill prefix from the token table +UPDATE native_trigger nt +SET webhook_token_prefix = t.token_prefix +FROM token t +WHERE t.token_hash = nt.webhook_token_hash; + +ALTER TABLE native_trigger DROP COLUMN IF EXISTS webhook_token_hash; diff --git a/backend/migrations/20260316000002_native_trigger_token_hash.up.sql b/backend/migrations/20260316000002_native_trigger_token_hash.up.sql new file mode 100644 index 0000000000..fd643ebaa1 --- /dev/null +++ b/backend/migrations/20260316000002_native_trigger_token_hash.up.sql @@ -0,0 +1,22 @@ +-- Add webhook_token_hash to native_trigger for safe token lookups/deletes, +-- and drop webhook_token_prefix which is no longer needed. + +ALTER TABLE native_trigger ADD COLUMN webhook_token_hash VARCHAR(64); + +-- Backfill from the token table +UPDATE native_trigger nt +SET webhook_token_hash = t.token_hash +FROM token t +WHERE t.token_prefix = nt.webhook_token_prefix; + +-- Mark orphaned triggers (whose tokens no longer exist) with an error +-- instead of deleting them, so they remain visible in the UI. +-- Use a placeholder hash (sha256 of empty string) that won't match any real token. +UPDATE native_trigger +SET webhook_token_hash = encode(sha256(''::bytea), 'hex'), + error = 'Webhook token not found during migration — re-create this trigger to fix' +WHERE webhook_token_hash IS NULL; + +ALTER TABLE native_trigger ALTER COLUMN webhook_token_hash SET NOT NULL; + +ALTER TABLE native_trigger DROP COLUMN webhook_token_prefix; diff --git a/backend/migrations/20260316000003_rename_token_expiry_notification_column.down.sql b/backend/migrations/20260316000003_rename_token_expiry_notification_column.down.sql new file mode 100644 index 0000000000..1b4895e88f --- /dev/null +++ b/backend/migrations/20260316000003_rename_token_expiry_notification_column.down.sql @@ -0,0 +1,5 @@ +-- Hashed values cannot be reversed; truncate to avoid silent join mismatches +-- with the old code that compared plaintext token_expiry_notification.token against token.token. +TRUNCATE token_expiry_notification; + +ALTER TABLE token_expiry_notification RENAME COLUMN token_hash TO token; diff --git a/backend/migrations/20260316000003_rename_token_expiry_notification_column.up.sql b/backend/migrations/20260316000003_rename_token_expiry_notification_column.up.sql new file mode 100644 index 0000000000..fd4186e725 --- /dev/null +++ b/backend/migrations/20260316000003_rename_token_expiry_notification_column.up.sql @@ -0,0 +1,5 @@ +-- Convert existing plaintext token values to SHA-256 hashes, then rename the column. +UPDATE token_expiry_notification +SET token = encode(sha256(token::bytea), 'hex'); + +ALTER TABLE token_expiry_notification RENAME COLUMN token TO token_hash; diff --git a/backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.down.sql b/backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.down.sql new file mode 100644 index 0000000000..9e3c94eea1 --- /dev/null +++ b/backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.down.sql @@ -0,0 +1,4 @@ +-- Hashing is irreversible so the values will be stale hashes, but the old +-- code's DELETE FROM token WHERE token = $1 is non-fatal — refresh tokens +-- keep working, only old access token cleanup silently fails. +ALTER TABLE mcp_oauth_refresh_token RENAME COLUMN access_token_hash TO access_token; diff --git a/backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.up.sql b/backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.up.sql new file mode 100644 index 0000000000..e845eb6527 --- /dev/null +++ b/backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.up.sql @@ -0,0 +1,6 @@ +-- Hash existing plaintext access_token values in mcp_oauth_refresh_token. +-- Only hash rows that are not already 64-char hex strings (safety guard for re-runs). +UPDATE mcp_oauth_refresh_token +SET access_token = encode(sha256(access_token::bytea), 'hex'); + +ALTER TABLE mcp_oauth_refresh_token RENAME COLUMN access_token TO access_token_hash; diff --git a/backend/parsers/windmill-parser-py-imports/tests/fixtures/base.sql b/backend/parsers/windmill-parser-py-imports/tests/fixtures/base.sql index 4b3df78534..30629d969d 100644 --- a/backend/parsers/windmill-parser-py-imports/tests/fixtures/base.sql +++ b/backend/parsers/windmill-parser-py-imports/tests/fixtures/base.sql @@ -803,7 +803,9 @@ ALTER TABLE public.script OWNER TO postgres; -- CREATE TABLE public.token ( - token character varying(50) NOT NULL, + token_hash character varying(64) NOT NULL, + token_prefix character varying(10) NOT NULL, + token character varying(50), label character varying(50), expiration timestamp with time zone, workspace_id character varying(50), @@ -1209,7 +1211,7 @@ ALTER TABLE ONLY public.script -- ALTER TABLE ONLY public.token - ADD CONSTRAINT token_pkey PRIMARY KEY (token); + ADD CONSTRAINT token_pkey PRIMARY KEY (token_hash); -- @@ -2534,7 +2536,7 @@ INSERT INTO public.usr(workspace_id, email, username, is_admin, role) VALUES INSERT INTO public.workspace_key(workspace_id, kind, key) VALUES ('test-workspace', 'cloud', 'test-key'); -insert INTO public.token(token, email, label, super_admin) VALUES ('SECRET_TOKEN', 'test@windmill.dev', 'test token', true); +insert INTO public.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); INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES ( 'test-workspace', diff --git a/backend/src/main.rs b/backend/src/main.rs index aa9f16af89..24d9459937 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1686,7 +1686,7 @@ async fn process_notify_event( } "notify_token_invalidation" => { tracing::info!( - "Token invalidation detected for token: {}...", + "Token invalidation detected for prefix: {}...", payload.get(..8).unwrap_or(payload) ); windmill_api::auth::invalidate_token_from_cache(payload); diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 803afabf0d..429577f3ef 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -945,7 +945,7 @@ pub async fn delete_expired_items(db: &DB) -> () { let expired_tokens_r = sqlx::query_as!( TokenRow, "DELETE FROM token WHERE expiration <= now() - RETURNING substring(token for 10) as token_prefix, label, email, workspace_id", + RETURNING token_prefix, label, email, workspace_id", ) .fetch_all(db) .await; @@ -1164,15 +1164,17 @@ pub async fn delete_expired_items(db: &DB) -> () { } pub async fn check_expiring_tokens(db: &DB) { - // Find tokens expiring within 7 days that still have a pending notification row + // Find tokens expiring within 7 days that still have a pending notification row. + // The notification table stores token_hash (not plaintext) so the join works + // even after the hash migration makes token.token nullable. let expiring_tokens_r = sqlx::query_as!( TokenRow, "DELETE FROM token_expiry_notification n USING token t - WHERE n.token = t.token + WHERE n.token_hash = t.token_hash AND n.expiration > now() AND n.expiration <= now() + interval '7 days' - RETURNING substring(t.token for 10) as token_prefix, t.label, t.email, t.workspace_id", + RETURNING t.token_prefix, t.label, t.email, t.workspace_id", ) .fetch_all(db) .await; diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 2f86dcb322..5a272d5121 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -115,14 +115,14 @@ kafka_trigger: path(char), kafka_resource_path(char), topics(char), group_id(cha log_file: hostname(char), log_ts(ts), ok_lines(bigint), err_lines(bigint), mode(log_mode), worker_group(char), file_path(char), json_fmt(bool) magic_link: email(char), token(char), expiration(ts) mcp_oauth_client: mcp_server_url(text), client_id(text), client_secret(text), client_secret_expires_at(ts), token_endpoint(text), created_at(ts) -mcp_oauth_refresh_token: id(bigint), refresh_token(char), access_token(char), client_id(char), user_email(char), workspace_id(char), scopes(text[]), token_family(uuid), created_at(ts), expires_at(ts), used_at(ts), revoked(bool) +mcp_oauth_refresh_token: id(bigint), refresh_token(char), access_token_hash(char), client_id(char), user_email(char), workspace_id(char), scopes(text[]), token_family(uuid), created_at(ts), expires_at(ts), used_at(ts), revoked(bool) FK: (client_id) -> mcp_oauth_server_client(client_id) mcp_oauth_server_client: client_id(char), client_name(char), redirect_uris(text[]), created_at(ts) mcp_oauth_server_code: code(char), client_id(char), user_email(char), workspace_id(char), scopes(text[]), redirect_uri(text), code_challenge(char), code_challenge_method(char), created_at(ts), expires_at(ts) FK: (client_id) -> mcp_oauth_server_client(client_id) metrics: id(char), value(jsonb), created_at(ts) mqtt_trigger: mqtt_resource_path(char), subscribe_topics(jsonb[]), client_version(mqtt_client_version), v5_config(jsonb), v3_config(jsonb), client_id(char), path(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode) -native_trigger: external_id(char), workspace_id(char), service_name(native_trigger_service), script_path(char), is_flow(bool), webhook_token_prefix(char), service_config(jsonb), error(text), created_at(ts), updated_at(ts) +native_trigger: external_id(char), workspace_id(char), service_name(native_trigger_service), script_path(char), is_flow(bool), webhook_token_hash(char), service_config(jsonb), error(text), created_at(ts), updated_at(ts) FK: (workspace_id) -> workspace(id) nats_trigger: path(char), nats_resource_path(char), subjects(char), stream_name(char), consumer_name(char), use_jetstream(bool), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode) FK: (workspace_id) -> workspace(id) @@ -151,9 +151,9 @@ script: workspace_id(char), hash(bigint), path(char), parent_hashes(bigint[]), s skip_workspace_diff_tally: workspace_id(char), added_at(ts) sqs_trigger: path(char), queue_url(char), aws_resource_path(char), message_attributes(text[]), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error(text), server_id(char), last_server_ping(ts), aws_auth_resource_type(aws_auth_resource_type), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode) FK: (workspace_id) -> workspace(id) -token: token(char), label(char), expiration(ts), workspace_id(char), owner(char), email(char), super_admin(bool), created_at(ts), last_used_at(ts), scopes(text[]), job(uuid) +token: token_hash(char), token_prefix(char), token(char), label(char), expiration(ts), workspace_id(char), owner(char), email(char), super_admin(bool), created_at(ts), last_used_at(ts), scopes(text[]), job(uuid) FK: (workspace_id) -> workspace(id) -token_expiry_notification: token(char), expiration(ts) +token_expiry_notification: token_hash(char), expiration(ts) INDEX: idx_token_expiry_notification_expiration (expiration) tutorial_progress: email(char), progress(bit64), skipped_all(bool) unique_ext_jwt_token: jwt_hash(bigint), last_used_at(ts) diff --git a/backend/tests/fixtures/base.sql b/backend/tests/fixtures/base.sql index 7db9918fba..ce5df2640e 100644 --- a/backend/tests/fixtures/base.sql +++ b/backend/tests/fixtures/base.sql @@ -33,9 +33,11 @@ INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES ('test-workspace', 'test3@windmill.dev', 'test-user-3', false, 'User'); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN', 'test@windmill.dev', 'test token', true); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); +-- NOTE: plaintext `token` column is included for backward compat during transition. +-- Remove it once the `token` column is dropped from the schema. +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); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_2'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_3'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin; GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user; diff --git a/backend/tests/fixtures/end_user_email.sql b/backend/tests/fixtures/end_user_email.sql index 654ad93680..17d459f7de 100644 --- a/backend/tests/fixtures/end_user_email.sql +++ b/backend/tests/fixtures/end_user_email.sql @@ -24,15 +24,17 @@ VALUES ('other-ws@windmill.dev', 'hash', 'password', false, true, 'Other WS User INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES ('other-workspace', 'other-ws@windmill.dev', 'other-ws-user', true, 'Admin'); -INSERT INTO token(token, email, label, super_admin) -VALUES ('OTHER_WS_TOKEN', 'other-ws@windmill.dev', 'other ws token', false); +-- NOTE: plaintext `token` column is included for backward compat during transition. +-- Remove it once the `token` column is dropped from the schema. +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('OTHER_WS_TOKEN'::bytea), 'hex'), 'OTHER_WS_T', 'OTHER_WS_TOKEN', 'other-ws@windmill.dev', 'other ws token', false); -- User not in any workspace INSERT INTO password(email, password_hash, login_type, super_admin, verified, name) VALUES ('no-ws@windmill.dev', 'hash', 'password', false, true, 'No WS User'); -INSERT INTO token(token, email, label, super_admin) -VALUES ('NO_WS_TOKEN', 'no-ws@windmill.dev', 'no ws token', false); +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('NO_WS_TOKEN'::bytea), 'hex'), 'NO_WS_TOKE', 'NO_WS_TOKEN', 'no-ws@windmill.dev', 'no ws token', false); -- Script that returns WM_END_USER_EMAIL (public via extra_perms) INSERT INTO script (workspace_id, created_by, content, schema, summary, description, path, hash, language, lock, kind, extra_perms) diff --git a/backend/tests/fixtures/permissions_test.sql b/backend/tests/fixtures/permissions_test.sql index a9006f5233..66f406c08c 100644 --- a/backend/tests/fixtures/permissions_test.sql +++ b/backend/tests/fixtures/permissions_test.sql @@ -39,13 +39,13 @@ ON CONFLICT (email) DO NOTHING; -- Tokens associated with emails (workspace-scoped) -- The auth system will look up the user by email in the usr table -- Note: tokens must be at least 10 characters (TOKEN_PREFIX_LEN) -INSERT INTO token (token, email, label, super_admin, owner, workspace_id) +INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, owner, workspace_id) VALUES - ('ADMIN_TOKEN_TEST', 'admin@windmill.dev', 'Admin token', false, 'u/admin', 'test-workspace'), - ('ALICE_TOKEN_TEST', 'alice@windmill.dev', 'Alice token', false, 'u/alice', 'test-workspace'), - ('BOB_TOKEN_TEST12', 'bob@windmill.dev', 'Bob token', false, 'u/bob', 'test-workspace'), - ('CHARLIE_TOKEN_01', 'charlie@windmill.dev', 'Charlie token', false, 'u/charlie', 'test-workspace'), - ('OPERATOR_TOKEN_1', 'operator@windmill.dev', 'Operator token', false, 'u/operator', 'test-workspace'); + (encode(sha256('ADMIN_TOKEN_TEST'::bytea), 'hex'), 'ADMIN_TOKE', 'ADMIN_TOKEN_TEST', 'admin@windmill.dev', 'Admin token', false, 'u/admin', 'test-workspace'), + (encode(sha256('ALICE_TOKEN_TEST'::bytea), 'hex'), 'ALICE_TOKE', 'ALICE_TOKEN_TEST', 'alice@windmill.dev', 'Alice token', false, 'u/alice', 'test-workspace'), + (encode(sha256('BOB_TOKEN_TEST12'::bytea), 'hex'), 'BOB_TOKEN_', 'BOB_TOKEN_TEST12', 'bob@windmill.dev', 'Bob token', false, 'u/bob', 'test-workspace'), + (encode(sha256('CHARLIE_TOKEN_01'::bytea), 'hex'), 'CHARLIE_TO', 'CHARLIE_TOKEN_01', 'charlie@windmill.dev', 'Charlie token', false, 'u/charlie', 'test-workspace'), + (encode(sha256('OPERATOR_TOKEN_1'::bytea), 'hex'), 'OPERATOR_T', 'OPERATOR_TOKEN_1', 'operator@windmill.dev', 'Operator token', false, 'u/operator', 'test-workspace'); -- ============================================ -- GROUPS diff --git a/backend/tests/fixtures/preserve_on_behalf_of.sql b/backend/tests/fixtures/preserve_on_behalf_of.sql index 514c554960..7467843e80 100644 --- a/backend/tests/fixtures/preserve_on_behalf_of.sql +++ b/backend/tests/fixtures/preserve_on_behalf_of.sql @@ -65,14 +65,20 @@ INSERT INTO usr_to_group(workspace_id, group_, usr) VALUES ('test-workspace', 'wm_deployers', 'deployer-user') ON CONFLICT DO NOTHING; --- Tokens for all users -INSERT INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN', 'test@windmill.dev', 'test token', true) +-- Tokens for all users (token_hash = sha256 hex, token_prefix = first 10 chars) +-- NOTE: plaintext `token` column is included for backward compat during transition. +-- Remove it once the `token` column is dropped from the schema. +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) ON CONFLICT DO NOTHING; -INSERT INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false) +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('SECRET_TOKEN_2'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false) ON CONFLICT DO NOTHING; -INSERT INTO token(token, email, label, super_admin) VALUES ('DEPLOYER_TOKEN', 'deployer@windmill.dev', 'deployer token', false) +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('DEPLOYER_TOKEN'::bytea), 'hex'), 'DEPLOYER_T', 'DEPLOYER_TOKEN', 'deployer@windmill.dev', 'deployer token', false) ON CONFLICT DO NOTHING; -INSERT INTO token(token, email, label, super_admin) VALUES ('ORIGINAL_TOKEN', 'original@windmill.dev', 'original token', false) +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('ORIGINAL_TOKEN'::bytea), 'hex'), 'ORIGINAL_T', 'ORIGINAL_TOKEN', 'original@windmill.dev', 'original token', false) ON CONFLICT DO NOTHING; GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin; diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index af212dc315..88c3b0cf0b 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -26,7 +26,9 @@ use tokio::sync::RwLock; use windmill_common::DB; use windmill_common::{ - auth::{get_folders_for_user, get_groups_for_user, JWTAuthClaims, TOKEN_PREFIX_LEN}, + auth::{ + get_folders_for_user, get_groups_for_user, hash_token, safe_token_prefix, JWTAuthClaims, + }, error::{Error, JsonResult}, jwt, users::{COOKIE_NAME, SUPERADMIN_SECRET_EMAIL}, @@ -42,13 +44,14 @@ lazy_static::lazy_static! { /// Get email from a valid token, with caching. /// Used for WM_END_USER_EMAIL when user is authenticated but not a workspace member. async fn get_email_from_token(db: &DB, token: &str) -> Option { - if let Some(cached) = TOKEN_EMAIL_CACHE.get(token) { + let t_hash = hash_token(token); + if let Some(cached) = TOKEN_EMAIL_CACHE.get(&t_hash) { return cached; } let email = sqlx::query_scalar!( - "SELECT email FROM token WHERE token = $1 AND (expiration > NOW() OR expiration IS NULL)", - token + "SELECT email FROM token WHERE token_hash = $1 AND (expiration > NOW() OR expiration IS NULL)", + t_hash ) .fetch_optional(db) .await @@ -56,7 +59,7 @@ async fn get_email_from_token(db: &DB, token: &str) -> Option { .flatten() .flatten(); // email column is nullable, so we get Option> - TOKEN_EMAIL_CACHE.insert(token.to_string(), email.clone()); + TOKEN_EMAIL_CACHE.insert(t_hash, email.clone()); email } @@ -75,13 +78,15 @@ pub async fn get_end_user_email( } None } -// Global function to invalidate a specific token from cache -pub fn invalidate_token_from_cache(token: &str) { - // Remove all cache entries for this token (across all workspaces) - AUTH_CACHE.retain(|(_workspace_id, cached_token), _cached_value| cached_token != token); +// Global function to invalidate tokens from cache by prefix +pub fn invalidate_token_from_cache(token_prefix: &str) { + // Remove all cache entries whose raw token starts with this prefix (across all workspaces) + AUTH_CACHE.retain(|(_workspace_id, cached_token), _cached_value| { + !cached_token.starts_with(token_prefix) + }); tracing::info!( - "Invalidated token from auth cache: {}...", - &token[..token.len().min(8)] + "Invalidated token(s) from auth cache with prefix: {}...", + &token_prefix[..token_prefix.len().min(8)] ); } @@ -211,13 +216,14 @@ impl AuthCache { } } _ => { + let t_hash = hash_token(token); let user_o = sqlx::query!( "UPDATE token SET last_used_at = now() WHERE - token = $1 + 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", - token, + t_hash, w_id.as_ref(), ) .map(|x| (x.owner, x.email, x.super_admin, x.scopes, x.label)) @@ -275,7 +281,7 @@ impl AuthCache { scopes: None, username_override, token_prefix: Some( - token[0..TOKEN_PREFIX_LEN].to_string(), + safe_token_prefix(token), ), }) } else { @@ -299,7 +305,7 @@ impl AuthCache { scopes: None, username_override, token_prefix: Some( - token[0..TOKEN_PREFIX_LEN].to_string(), + safe_token_prefix(token), ), }) } @@ -315,7 +321,7 @@ impl AuthCache { folders, scopes: None, username_override, - token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), + token_prefix: Some(safe_token_prefix(token)), }) } } @@ -364,7 +370,7 @@ impl AuthCache { scopes, username_override, token_prefix: Some( - token[0..TOKEN_PREFIX_LEN].to_string(), + safe_token_prefix(token), ), }) } @@ -378,7 +384,7 @@ impl AuthCache { scopes, username_override, token_prefix: Some( - token[0..TOKEN_PREFIX_LEN].to_string(), + safe_token_prefix(token), ), }), None => None, @@ -393,7 +399,7 @@ impl AuthCache { folders: Vec::new(), scopes, username_override, - token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), + token_prefix: Some(safe_token_prefix(token)), }) } } @@ -427,7 +433,7 @@ impl AuthCache { folders: Vec::new(), scopes: None, username_override: None, - token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), + token_prefix: Some(safe_token_prefix(token)), }; Some(OptJobAuthed { authed, job_id: None }) } else { @@ -717,7 +723,7 @@ fn username_override_from_label(label: Option) -> Option { #[derive(FromRow, Serialize)] pub struct TruncatedTokenWithEmail { pub label: Option, - pub token_prefix: Option, + pub token_prefix: String, pub expiration: Option>, pub created_at: chrono::DateTime, pub last_used_at: chrono::DateTime, @@ -736,7 +742,7 @@ pub async fn list_tokens_internal( TruncatedTokenWithEmail, r#" SELECT label, - concat(substring(token for 10)) AS token_prefix, + token_prefix, expiration, created_at, last_used_at, @@ -759,7 +765,7 @@ pub async fn list_tokens_internal( TruncatedTokenWithEmail, r#" SELECT label, - concat(substring(token for 10)) AS token_prefix, + token_prefix, expiration, created_at, last_used_at, diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index 557ce7f706..aceef77e01 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -18,7 +18,10 @@ use http::request::Parts; use windmill_audit::audit_oss::AuditAuthorable; use windmill_common::{ - auth::{fetch_authed_from_permissioned_as, is_devops_email, is_super_admin_email}, + auth::{ + fetch_authed_from_permissioned_as, hash_token, is_devops_email, is_super_admin_email, + TOKEN_PREFIX_LEN, + }, db::{Authable, Authed, AuthedRef}, error::{self, Error, Result}, users::username_to_permissioned_as, @@ -511,9 +514,20 @@ pub async fn create_token_internal( ) -> Result { use tracing::Instrument; use windmill_audit::{audit_oss::audit_log, ActionKind}; - use windmill_common::{utils::rd_string, worker::CLOUD_HOSTED}; + use windmill_common::{ + min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH, utils::rd_string, worker::CLOUD_HOSTED, + }; let token = rd_string(32); + let t_hash = hash_token(&token); + let t_prefix = token.get(..TOKEN_PREFIX_LEN).unwrap_or(&token); + + // Write plaintext token column until all workers support hash-based lookup + let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await { + None + } else { + Some(&token) + }; let is_super_admin = sqlx::query_scalar!( "SELECT super_admin FROM password WHERE email = $1", @@ -536,12 +550,14 @@ pub async fn create_token_internal( } let rows = sqlx::query!( "INSERT INTO token - (token, email, label, expiration, super_admin, scopes, workspace_id) - SELECT $1, $2, $3, $4, $5, $6, $7 - WHERE $7::varchar IS NULL OR NOT EXISTS( - SELECT 1 FROM workspace WHERE id = $7 AND deleted = true + (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id) + SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9 + WHERE $9::varchar IS NULL OR NOT EXISTS( + SELECT 1 FROM workspace WHERE id = $9 AND deleted = true )", - token, + t_hash, + t_prefix, + plaintext as Option<&str>, authed.email, token_config.label, token_config.expiration, @@ -559,7 +575,7 @@ pub async fn create_token_internal( register_token_expiry_notification( &mut *tx, - &token, + &t_hash, token_config.label.as_deref(), token_config.expiration, ) @@ -571,7 +587,7 @@ pub async fn create_token_internal( "users.token.create", ActionKind::Create, &"global", - Some(&token[0..10]), + Some(t_prefix), None, ) .instrument(tracing::info_span!("token", email = &authed.email)) @@ -581,12 +597,14 @@ pub async fn create_token_internal( } /// Insert a pending expiry notification row for user tokens that have an expiration. +/// Stores the token_hash so the join in check_expiring_tokens works even when +/// the plaintext token column is NULL (after hash migration). /// When updating this filter, also update: /// - `is_user_token` in src/monitor.rs /// - `isUserToken` in frontend/src/lib/components/settings/TokensTable.svelte pub async fn register_token_expiry_notification( tx: &mut sqlx::PgConnection, - token: &str, + token_hash: &str, label: Option<&str>, expiration: Option>, ) { @@ -602,8 +620,8 @@ pub async fn register_token_expiry_notification( return; } if let Err(e) = sqlx::query!( - "INSERT INTO token_expiry_notification (token, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING", - token, + "INSERT INTO token_expiry_notification (token_hash, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING", + token_hash, expiration, ) .execute(&mut *tx) diff --git a/backend/windmill-api-integration-tests/tests/fixtures/base.sql b/backend/windmill-api-integration-tests/tests/fixtures/base.sql index 7db9918fba..ce5df2640e 100644 --- a/backend/windmill-api-integration-tests/tests/fixtures/base.sql +++ b/backend/windmill-api-integration-tests/tests/fixtures/base.sql @@ -33,9 +33,11 @@ INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES ('test-workspace', 'test3@windmill.dev', 'test-user-3', false, 'User'); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN', 'test@windmill.dev', 'test token', true); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); +-- NOTE: plaintext `token` column is included for backward compat during transition. +-- Remove it once the `token` column is dropped from the schema. +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); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_2'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_3'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin; GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user; diff --git a/backend/windmill-api-integration-tests/tests/fixtures/permissions_test.sql b/backend/windmill-api-integration-tests/tests/fixtures/permissions_test.sql index a9006f5233..66f406c08c 100644 --- a/backend/windmill-api-integration-tests/tests/fixtures/permissions_test.sql +++ b/backend/windmill-api-integration-tests/tests/fixtures/permissions_test.sql @@ -39,13 +39,13 @@ ON CONFLICT (email) DO NOTHING; -- Tokens associated with emails (workspace-scoped) -- The auth system will look up the user by email in the usr table -- Note: tokens must be at least 10 characters (TOKEN_PREFIX_LEN) -INSERT INTO token (token, email, label, super_admin, owner, workspace_id) +INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, owner, workspace_id) VALUES - ('ADMIN_TOKEN_TEST', 'admin@windmill.dev', 'Admin token', false, 'u/admin', 'test-workspace'), - ('ALICE_TOKEN_TEST', 'alice@windmill.dev', 'Alice token', false, 'u/alice', 'test-workspace'), - ('BOB_TOKEN_TEST12', 'bob@windmill.dev', 'Bob token', false, 'u/bob', 'test-workspace'), - ('CHARLIE_TOKEN_01', 'charlie@windmill.dev', 'Charlie token', false, 'u/charlie', 'test-workspace'), - ('OPERATOR_TOKEN_1', 'operator@windmill.dev', 'Operator token', false, 'u/operator', 'test-workspace'); + (encode(sha256('ADMIN_TOKEN_TEST'::bytea), 'hex'), 'ADMIN_TOKE', 'ADMIN_TOKEN_TEST', 'admin@windmill.dev', 'Admin token', false, 'u/admin', 'test-workspace'), + (encode(sha256('ALICE_TOKEN_TEST'::bytea), 'hex'), 'ALICE_TOKE', 'ALICE_TOKEN_TEST', 'alice@windmill.dev', 'Alice token', false, 'u/alice', 'test-workspace'), + (encode(sha256('BOB_TOKEN_TEST12'::bytea), 'hex'), 'BOB_TOKEN_', 'BOB_TOKEN_TEST12', 'bob@windmill.dev', 'Bob token', false, 'u/bob', 'test-workspace'), + (encode(sha256('CHARLIE_TOKEN_01'::bytea), 'hex'), 'CHARLIE_TO', 'CHARLIE_TOKEN_01', 'charlie@windmill.dev', 'Charlie token', false, 'u/charlie', 'test-workspace'), + (encode(sha256('OPERATOR_TOKEN_1'::bytea), 'hex'), 'OPERATOR_T', 'OPERATOR_TOKEN_1', 'operator@windmill.dev', 'Operator token', false, 'u/operator', 'test-workspace'); -- ============================================ -- GROUPS diff --git a/backend/windmill-api-integration-tests/tests/permissions.rs b/backend/windmill-api-integration-tests/tests/permissions.rs index 4342c61235..c333cf924b 100644 --- a/backend/windmill-api-integration-tests/tests/permissions.rs +++ b/backend/windmill-api-integration-tests/tests/permissions.rs @@ -517,9 +517,14 @@ async fn test_group_permission_inheritance(db: Pool) -> anyhow::Result // for (workspace_id, token) tuples. Since we can't easily clear it from tests, // we use a different token or wait for cache expiry. For this test, we create // a new token for Charlie. + let charlie_token = "CHARLIE_TOKEN_NEW"; + let charlie_token_hash = windmill_common::utils::calculate_hash(charlie_token); + let charlie_token_prefix = &charlie_token[..10.min(charlie_token.len())]; sqlx::query!( - "INSERT INTO token (token, email, label, super_admin, owner, workspace_id) - VALUES ('CHARLIE_TOKEN_NEW', 'charlie@windmill.dev', 'Charlie new token', false, 'u/charlie', 'test-workspace')" + "INSERT INTO token (token_hash, token_prefix, email, label, super_admin, owner, workspace_id) + VALUES ($1, $2, 'charlie@windmill.dev', 'Charlie new token', false, 'u/charlie', 'test-workspace')", + charlie_token_hash, + charlie_token_prefix, ) .execute(&db) .await?; @@ -563,28 +568,100 @@ async fn test_all_item_types_permissions(db: Pool) -> anyhow::Result<( let bob_client = create_client_for_user(port, "BOB_TOKEN_TEST12").await; // Test Scripts - uses /scripts/get/p/{path} - assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/scripts/get/p/u/alice/my_script")).await); - assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/scripts/get/p/u/alice/my_script")).await); + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/scripts/get/p/u/alice/my_script") + ) + .await + ); + assert!( + !can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/scripts/get/p/u/alice/my_script") + ) + .await + ); // Test Flows - uses /flows/get/{path} (no /p/) - assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/flows/get/u/alice/my_flow")).await); - assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/flows/get/u/alice/my_flow")).await); + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/flows/get/u/alice/my_flow") + ) + .await + ); + assert!( + !can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/flows/get/u/alice/my_flow") + ) + .await + ); // Test Resources - uses /resources/get/{path} (no /p/) - assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/resources/get/u/alice/my_resource")).await); - assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/resources/get/u/alice/my_resource")).await); + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/resources/get/u/alice/my_resource") + ) + .await + ); + assert!( + !can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/resources/get/u/alice/my_resource") + ) + .await + ); // Test Variables - uses /variables/get/{path} (no /p/) - assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/variables/get/u/alice/my_variable")).await); - assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/variables/get/u/alice/my_variable")).await); + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/variables/get/u/alice/my_variable") + ) + .await + ); + assert!( + !can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/variables/get/u/alice/my_variable") + ) + .await + ); // Test Schedules - uses /schedules/get/{path} (no /p/) - assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/schedules/get/u/alice/my_schedule")).await); - assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/schedules/get/u/alice/my_schedule")).await); + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/schedules/get/u/alice/my_schedule") + ) + .await + ); + assert!( + !can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/schedules/get/u/alice/my_schedule") + ) + .await + ); // Test Apps - uses /apps/get/p/{path} - assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/apps/get/p/u/alice/my_app")).await); - assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/apps/get/p/u/alice/my_app")).await); + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/apps/get/p/u/alice/my_app") + ) + .await + ); + assert!( + !can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/apps/get/p/u/alice/my_app") + ) + .await + ); Ok(()) } @@ -747,11 +824,9 @@ async fn test_operator_cannot_create_update(db: Pool) -> anyhow::Resul .await?; // Update app versions - sqlx::query!( - "UPDATE app SET versions = ARRAY[3001::bigint] WHERE id = 3001" - ) - .execute(&db) - .await?; + sqlx::query!("UPDATE app SET versions = ARRAY[3001::bigint] WHERE id = 3001") + .execute(&db) + .await?; let update_app = json!({ "path": "u/operator/existing_app", diff --git a/backend/windmill-api-integration-tests/tests/token_hash.rs b/backend/windmill-api-integration-tests/tests/token_hash.rs new file mode 100644 index 0000000000..aa7dd22286 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/token_hash.rs @@ -0,0 +1,399 @@ +//! Tests for the token hash migration. +//! +//! Verifies that: +//! - Rust hash_token() matches PostgreSQL's encode(sha256(...),'hex') +//! - Newly created tokens can authenticate immediately +//! - Token list/delete-by-prefix works with the new token_prefix column +//! - Logout invalidates tokens via hash-based deletion +//! - Backward compat: plaintext column is populated when old workers exist +//! - rotate_webhook_token produces valid tokens and defers old token deletion + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_common::auth::{hash_token, TOKEN_PREFIX_LEN}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn authed_with(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {}", token)) +} + +/// Test 1: Verify that Rust's hash_token() produces the same hash as PostgreSQL's +/// encode(sha256(token::bytea), 'hex'). This is the foundational invariant. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_hash_consistency(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + // Compute hash in Rust + let rust_hash = hash_token("SECRET_TOKEN"); + + // Compute hash in PostgreSQL + let pg_hash: String = + sqlx::query_scalar!("SELECT encode(sha256('SECRET_TOKEN'::bytea), 'hex') AS hash") + .fetch_one(&db) + .await? + .unwrap(); + + assert_eq!( + rust_hash, pg_hash, + "Rust hash_token() must match PostgreSQL sha256()" + ); + + // Also verify it matches what's stored in the fixture + let stored_hash: String = sqlx::query_scalar!( + "SELECT token_hash FROM token WHERE email = 'test@windmill.dev' AND label = 'test token'" + ) + .fetch_one(&db) + .await?; + + assert_eq!( + rust_hash, stored_hash, + "hash_token() must match the fixture's pre-computed hash" + ); + + Ok(()) +} + +/// Test 2: Create a token via API, then immediately use it to authenticate. +/// Verifies create_token_internal stores the hash correctly and auth lookups work. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_create_token_and_auth(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/users"); + + // Create a new token + let resp = authed(client().post(format!("{base}/tokens/create"))) + .json(&json!({"label": "test-hash-token"})) + .send() + .await?; + assert_eq!(resp.status(), 201); + let new_token = resp.text().await?; + assert!(!new_token.is_empty()); + + // Use the new token to call whoami + let resp = authed_with(client().get(format!("{base}/whoami")), &new_token) + .send() + .await?; + assert_eq!(resp.status(), 200, "newly created token must authenticate"); + let body = resp.json::().await?; + assert_eq!(body["email"], "test@windmill.dev"); + + // Verify the hash is stored correctly in DB + let expected_hash = hash_token(&new_token); + let db_hash: Option = sqlx::query_scalar!( + "SELECT token_hash FROM token WHERE token_hash = $1", + expected_hash + ) + .fetch_optional(&db) + .await?; + assert!(db_hash.is_some(), "token_hash must be stored in DB"); + + Ok(()) +} + +/// Test 3: Create a token, list tokens (verify prefix), delete by prefix, confirm invalid. +/// Covers the change from WHERE token LIKE to WHERE token_prefix = $1. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_token_list_and_delete_by_prefix(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/users"); + + // Create a token + let resp = authed(client().post(format!("{base}/tokens/create"))) + .json(&json!({"label": "prefix-test-token"})) + .send() + .await?; + assert_eq!(resp.status(), 201); + let new_token = resp.text().await?; + let prefix = &new_token[..TOKEN_PREFIX_LEN]; + + // List tokens and find our token by prefix + let resp = authed(client().get(format!("{base}/tokens/list"))) + .send() + .await?; + assert_eq!(resp.status(), 200); + let tokens = resp.json::>().await?; + let found = tokens + .iter() + .any(|t| t["token_prefix"].as_str() == Some(prefix)); + assert!(found, "token with prefix {prefix} must appear in list"); + + // Verify the new token works + let resp = authed_with(client().get(format!("{base}/whoami")), &new_token) + .send() + .await?; + assert_eq!(resp.status(), 200); + + // Delete by prefix + let resp = authed(client().delete(format!("{base}/tokens/delete/{prefix}"))) + .send() + .await?; + assert_eq!(resp.status(), 200, "delete token: {}", resp.text().await?); + + // Confirm the token is gone from the DB (auth cache may still serve 200 briefly) + let token_hash = hash_token(&new_token); + let deleted: bool = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM token WHERE token_hash = $1) AS exists", + token_hash + ) + .fetch_one(&db) + .await? + .unwrap_or(true); + assert!( + !deleted, + "token must be deleted from DB after delete-by-prefix" + ); + + Ok(()) +} + +/// Test 4: Logout invalidates a token via hash-based deletion. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_logout_invalidates_token(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/users"); + let auth_base = format!("http://localhost:{port}/api/auth"); + + // Create a fresh token (don't burn the fixture token) + let resp = authed(client().post(format!("{base}/tokens/create"))) + .json(&json!({"label": "logout-test-token"})) + .send() + .await?; + assert_eq!(resp.status(), 201); + let token = resp.text().await?; + + // Verify it works + let resp = authed_with(client().get(format!("{base}/whoami")), &token) + .send() + .await?; + assert_eq!(resp.status(), 200); + + // Logout with the token + let resp = authed_with(client().post(format!("{auth_base}/logout")), &token) + .send() + .await?; + assert!( + resp.status() == 200 || resp.status() == 303, + "logout: unexpected status {}", + resp.status() + ); + + // Confirm the token is gone from the DB (auth cache may still serve 200 briefly) + let token_hash = hash_token(&token); + let exists: bool = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM token WHERE token_hash = $1) AS exists", + token_hash + ) + .fetch_one(&db) + .await? + .unwrap_or(true); + assert!(!exists, "token must be deleted from DB after logout"); + + Ok(()) +} + +/// Test 5: Backward compatibility — plaintext column behavior based on MIN_VERSION. +/// When old workers exist (MIN_VERSION < 1.650.0), plaintext must be written so +/// old workers running WHERE token = $1 can still authenticate. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_plaintext_backward_compat(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + use windmill_common::min_version::{MIN_VERSION, MIN_VERSION_SUPPORTS_TOKEN_HASH}; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/users"); + + // --- Phase 1: Simulate old workers present (version < 1.650.0) --- + // Set MIN_VERSION to one minor below the token hash feature version + let mut old_version = MIN_VERSION_SUPPORTS_TOKEN_HASH.version().clone(); + old_version.minor -= 1; + *MIN_VERSION.write().await = old_version; + + let resp = authed(client().post(format!("{base}/tokens/create"))) + .json(&json!({"label": "old-worker-compat-token"})) + .send() + .await?; + assert_eq!(resp.status(), 201); + let old_compat_token = resp.text().await?; + let old_compat_hash = hash_token(&old_compat_token); + + // Plaintext should be stored (for old workers) + let plaintext: Option = sqlx::query_scalar!( + "SELECT token FROM token WHERE token_hash = $1", + old_compat_hash + ) + .fetch_one(&db) + .await?; + assert!( + plaintext.is_some(), + "plaintext must be stored when old workers exist" + ); + assert_eq!(plaintext.unwrap(), old_compat_token); + + // Old-style query (what old workers run) must find the token + let old_style_email: Option = sqlx::query_scalar!( + "SELECT email FROM token WHERE token = $1 AND (expiration > NOW() OR expiration IS NULL)", + &old_compat_token + ) + .fetch_optional(&db) + .await? + .flatten(); + assert_eq!( + old_style_email.as_deref(), + Some("test@windmill.dev"), + "old-style WHERE token = $1 must find the token" + ); + + // New-style query must also work + let new_style_email: Option = sqlx::query_scalar!( + "SELECT email FROM token WHERE token_hash = $1 AND (expiration > NOW() OR expiration IS NULL)", + old_compat_hash + ) + .fetch_optional(&db) + .await? + .flatten(); + assert_eq!( + new_style_email.as_deref(), + Some("test@windmill.dev"), + "new-style WHERE token_hash = $1 must also work" + ); + + // --- Phase 2: All workers upgraded (version >= 1.650.0) --- + *MIN_VERSION.write().await = MIN_VERSION_SUPPORTS_TOKEN_HASH.version().clone(); + + let resp = authed(client().post(format!("{base}/tokens/create"))) + .json(&json!({"label": "new-worker-token"})) + .send() + .await?; + assert_eq!(resp.status(), 201); + let new_token = resp.text().await?; + let new_hash = hash_token(&new_token); + + // Plaintext should NOT be stored + let plaintext: Option = + sqlx::query_scalar!("SELECT token FROM token WHERE token_hash = $1", new_hash) + .fetch_one(&db) + .await?; + assert!( + plaintext.is_none(), + "plaintext must be NULL when all workers support hash" + ); + + // Old-style query should NOT find this token + let old_style_result: Option = + sqlx::query_scalar!("SELECT email FROM token WHERE token = $1", &new_token) + .fetch_optional(&db) + .await? + .flatten(); + assert!( + old_style_result.is_none(), + "old-style query must not find token when plaintext is NULL" + ); + + // New-style query must still work + let resp = authed_with(client().get(format!("{base}/whoami")), &new_token) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "new token must authenticate via hash lookup" + ); + + Ok(()) +} + +/// Test 6: rotate_webhook_token creates a new token and keeps the old one alive. +/// Callers delete the old token after successfully updating the trigger. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_rotate_webhook_token(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + use windmill_native_triggers::{delete_token_by_hash, rotate_webhook_token}; + + // Insert a token directly with known values + let original_token = "test-webhook-token-original-1234"; + let original_hash = hash_token(original_token); + let original_prefix = &original_token[..TOKEN_PREFIX_LEN]; + + sqlx::query!( + "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin) + VALUES ($1, $2, $3, 'test@windmill.dev', 'webhook-test', false)", + original_hash, + original_prefix, + original_token, + ) + .execute(&db) + .await?; + + // Rotate the token + let rotated = rotate_webhook_token(&db, &original_hash) + .await? + .expect("rotate must return Some for existing token"); + + // New token should be different + assert_ne!(rotated.new_token, original_token); + assert_eq!(rotated.old_token_hash, original_hash); + + // New token's hash should exist in DB + let new_hash = hash_token(&rotated.new_token); + let exists: bool = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM token WHERE token_hash = $1) AS exists", + new_hash + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + assert!(exists, "new token hash must exist in DB after rotation"); + + // Old token should still exist (deletion deferred to caller) + let old_exists: bool = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM token WHERE token_hash = $1) AS exists", + original_hash + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + assert!( + old_exists, + "old token must still exist until caller deletes it" + ); + + // Caller deletes old token after successful trigger update + let deleted = delete_token_by_hash(&db, &rotated.old_token_hash).await?; + assert!(deleted, "old token must be deletable"); + + // Old token should now be gone + let old_gone: bool = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM token WHERE token_hash = $1) AS exists", + original_hash + ) + .fetch_one(&db) + .await? + .unwrap_or(true); + assert!(!old_gone, "old token must be gone after explicit deletion"); + + // Rotating a non-existent hash should return None + let result = rotate_webhook_token(&db, "nonexistent_hash").await?; + assert!( + result.is_none(), + "rotating a non-existent token must return None" + ); + + Ok(()) +} diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index f7409169a9..b911739eaa 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -42,7 +42,7 @@ use tracing::Instrument; use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; use windmill_common::audit::AuditAuthor; -use windmill_common::auth::TOKEN_PREFIX_LEN; +use windmill_common::auth::{safe_token_prefix, TOKEN_PREFIX_LEN}; use windmill_common::global_settings::AUTOMATE_USERNAME_CREATION_SETTING; use windmill_common::oauth2::InstanceEvent; use windmill_common::users::truncate_token; @@ -235,7 +235,7 @@ pub struct EditLoginType { #[derive(FromRow, Serialize)] pub struct TruncatedToken { pub label: Option, - pub token_prefix: Option, + pub token_prefix: String, pub expiration: Option>, pub created_at: chrono::DateTime, pub last_used_at: chrono::DateTime, @@ -527,23 +527,28 @@ async fn logout( } cookies.remove(cookie); let mut tx = db.begin().await?; + let t_hash = windmill_common::auth::hash_token(&token); + let t_prefix = token.get(..TOKEN_PREFIX_LEN).unwrap_or(&token); let email = if *INVALIDATE_ALL_SESSIONS_ON_LOGOUT { sqlx::query_scalar!( "WITH email_lookup AS ( - SELECT email FROM token WHERE token = $1 + SELECT email FROM token WHERE token_hash = $1 ) DELETE FROM token WHERE email = (SELECT email FROM email_lookup) AND label = 'session' RETURNING email", - token + t_hash ) .fetch_optional(&mut *tx) .await? } else { - sqlx::query_scalar!("DELETE FROM token WHERE token = $1 RETURNING email", token) - .fetch_optional(&mut *tx) - .await? + sqlx::query_scalar!( + "DELETE FROM token WHERE token_hash = $1 RETURNING email", + t_hash + ) + .fetch_optional(&mut *tx) + .await? }; if let Some(email) = email { @@ -559,7 +564,7 @@ async fn logout( email: email.clone(), username: email, username_override: None, - token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), + token_prefix: Some(t_prefix.to_string()), }, audit_message, ActionKind::Delete, @@ -1643,7 +1648,7 @@ async fn login( email: email.clone(), username: email.clone(), username_override: None, - token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), + token_prefix: Some(safe_token_prefix(&token)), }; audit_log( @@ -1689,7 +1694,8 @@ async fn refresh_token( let mut tx = db.begin().await?; if let Some(thresh_s) = query.if_expiring_in_less_than_s { - let not_expired = sqlx::query_scalar!("SELECT true FROM token WHERE token = $1 and expiration IS NOT NULL and expiration > now() + $2::int * '1 sec'::interval", &token, thresh_s) + let t_hash = windmill_common::auth::hash_token(&token); + let not_expired = sqlx::query_scalar!("SELECT true FROM token WHERE token_hash = $1 and expiration IS NOT NULL and expiration > now() + $2::int * '1 sec'::interval", &t_hash, thresh_s) .fetch_optional(&db) .await? .flatten() @@ -1740,7 +1746,16 @@ pub async fn create_session_token<'c>( tx: &mut sqlx::Transaction<'c, sqlx::Postgres>, cookies: Cookies, ) -> Result { + use windmill_common::min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH; + let token = rd_string(32); + let t_hash = windmill_common::auth::hash_token(&token); + let t_prefix = token.get(..TOKEN_PREFIX_LEN).unwrap_or(&token); + let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await { + None + } else { + Some(&token) + }; if *INVALIDATE_OLD_SESSIONS { sqlx::query!( @@ -1756,7 +1771,7 @@ pub async fn create_session_token<'c>( email: email.to_string(), username: email.to_string(), username_override: None, - token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), + token_prefix: Some(t_prefix.to_string()), }, "users.token.invalidate_old_sessions", ActionKind::Delete, @@ -1770,9 +1785,11 @@ pub async fn create_session_token<'c>( sqlx::query!( "INSERT INTO token - (token, email, label, expiration, super_admin) - VALUES ($1, $2, $3, now() + ($4 || ' seconds')::interval, $5)", - token, + (token_hash, token_prefix, token, email, label, expiration, super_admin) + VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7)", + t_hash, + t_prefix, + plaintext as Option<&str>, email, "session", &MAX_SESSION_VALIDITY_SECONDS.to_string(), @@ -1817,7 +1834,16 @@ async fn impersonate( authed: ApiAuthed, Json(new_token): Json, ) -> Result<(StatusCode, String)> { + use windmill_common::min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH; + let token = rd_string(32); + let t_hash = windmill_common::auth::hash_token(&token); + let t_prefix = token.get(..TOKEN_PREFIX_LEN).unwrap_or(&token); + let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await { + None + } else { + Some(&token) + }; require_super_admin(&db, &authed.email).await?; if new_token.impersonate_email.is_none() { @@ -1839,9 +1865,11 @@ async fn impersonate( sqlx::query!( "INSERT INTO token - (token, email, label, expiration, super_admin) - VALUES ($1, $2, $3, $4, $5)", - token, + (token_hash, token_prefix, token, email, label, expiration, super_admin) + VALUES ($1, $2, $3, $4, $5, $6, $7)", + t_hash, + t_prefix, + plaintext as Option<&str>, impersonated, new_token.label, new_token.expiration, @@ -1852,7 +1880,7 @@ async fn impersonate( windmill_api_auth::register_token_expiry_notification( &mut *tx, - &token, + &t_hash, new_token.label.as_deref(), new_token.expiration, ) @@ -1864,7 +1892,7 @@ async fn impersonate( "users.impersonate", ActionKind::Delete, &"global", - Some(&token[0..10]), + Some(t_prefix), Some([("impersonated", &format!("{impersonated}")[..])].into()), ) .instrument(tracing::info_span!("token", email = &impersonated)) @@ -1888,7 +1916,7 @@ async fn list_tokens( let rows = if query.exclude_ephemeral.unwrap_or(false) { sqlx::query_as!( TruncatedToken, - "SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, \ + "SELECT label, token_prefix, expiration, created_at, \ last_used_at, scopes FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL) ORDER BY created_at DESC LIMIT $2 OFFSET $3", email, @@ -1900,7 +1928,7 @@ async fn list_tokens( } else { sqlx::query_as!( TruncatedToken, - "SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, \ + "SELECT label, token_prefix, expiration, created_at, \ last_used_at, scopes FROM token WHERE email = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", email, @@ -1923,8 +1951,8 @@ async fn delete_token( let tokens_deleted: Vec = sqlx::query_scalar( "DELETE FROM token WHERE email = $1 - AND token LIKE concat($2::text, '%') - RETURNING concat(substring(token for 10), '*****')", + AND token_prefix = $2 + RETURNING concat(token_prefix, '*****')", ) .bind(&authed.email) .bind(&token_prefix) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index a85383e496..2b3146a6fb 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -3594,7 +3594,7 @@ pub(crate) async fn archive_workspace_impl( // Delete non-session tokens scoped to this workspace let deleted_tokens = sqlx::query_scalar!( - "DELETE FROM token WHERE workspace_id = $1 AND label IS DISTINCT FROM 'session' RETURNING token", + "DELETE FROM token WHERE workspace_id = $1 AND label IS DISTINCT FROM 'session' RETURNING token_prefix", w_id ) .fetch_all(&mut *tx) diff --git a/backend/windmill-api/src/mcp/oauth_server.rs b/backend/windmill-api/src/mcp/oauth_server.rs index 9a81e2efd8..ecfada2fcb 100644 --- a/backend/windmill-api/src/mcp/oauth_server.rs +++ b/backend/windmill-api/src/mcp/oauth_server.rs @@ -10,7 +10,9 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use sqlx::FromRow; use windmill_common::{ + auth::{hash_token, TOKEN_PREFIX_LEN}, error::{Error, Result}, + min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH, utils::rd_string, BASE_URL, DB, }; @@ -209,7 +211,7 @@ struct AuthorizationCode { struct RefreshTokenRow { id: i64, refresh_token: String, - access_token: String, + access_token_hash: String, client_id: String, user_email: String, workspace_id: String, @@ -383,16 +385,25 @@ async fn handle_authorization_code_grant( } let access_token = rd_string(32); + let access_token_hash = hash_token(&access_token); + let access_token_prefix = access_token.get(..TOKEN_PREFIX_LEN).unwrap_or(&access_token); + let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await { + None + } else { + Some(&access_token) + }; let refresh_token = rd_string(32); let token_family = sqlx::types::Uuid::new_v4(); let scopes = auth_code.scopes; // Create access token (rejects archived workspaces inline) let rows = sqlx::query!( - "INSERT INTO token (token, email, label, expiration, scopes, workspace_id) - SELECT $1::varchar, $2::varchar, $3::varchar, now() + ($4 || ' seconds')::interval, $5::text[], $6::varchar - WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $6 AND deleted = true)", - access_token, + "INSERT INTO token (token_hash, token_prefix, token, email, label, expiration, scopes, workspace_id) + SELECT $1::varchar, $2::varchar, $3::varchar, $4::varchar, $5::varchar, now() + ($6 || ' seconds')::interval, $7::text[], $8::varchar + WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $8 AND deleted = true)", + access_token_hash, + access_token_prefix, + plaintext as Option<&str>, auth_code.user_email, format!("mcp-oauth-{}", auth_code.client_id), MCP_OAUTH_TOKEN_EXPIRATION_SECS.to_string(), @@ -411,13 +422,13 @@ async fn handle_authorization_code_grant( )); } - // Create refresh token + // Create refresh token — store the hash of the access token so we can delete it later let refresh_token_result = sqlx::query!( "INSERT INTO mcp_oauth_refresh_token - (refresh_token, access_token, client_id, user_email, workspace_id, scopes, token_family, expires_at) + (refresh_token, access_token_hash, client_id, user_email, workspace_id, scopes, token_family, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7, now() + ($8 || ' seconds')::interval)", refresh_token, - access_token, + access_token_hash, auth_code.client_id, auth_code.user_email, auth_code.workspace_id, @@ -469,7 +480,7 @@ async fn handle_refresh_token_grant( AND used_at IS NULL AND NOT revoked AND expires_at > now() - RETURNING id, refresh_token, access_token, client_id, user_email, workspace_id, + RETURNING id, refresh_token, access_token_hash, client_id, user_email, workspace_id, scopes, token_family, created_at, expires_at, used_at, revoked", refresh_token_value, req.client_id @@ -504,10 +515,13 @@ async fn handle_refresh_token_grant( } }; - // Delete old access token - if let Err(e) = sqlx::query!("DELETE FROM token WHERE token = $1", token_row.access_token) - .execute(db) - .await + // Delete old access token using the stored hash + if let Err(e) = sqlx::query!( + "DELETE FROM token WHERE token_hash = $1", + token_row.access_token_hash + ) + .execute(db) + .await { tracing::error!("Failed to delete old access token: {}", e); // Non-fatal, continue with token creation @@ -515,15 +529,24 @@ async fn handle_refresh_token_grant( // Generate new tokens let new_access_token = rd_string(32); + let new_access_token_hash = hash_token(&new_access_token); + let new_access_token_prefix = new_access_token.get(..TOKEN_PREFIX_LEN).unwrap_or(&new_access_token); + let new_plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await { + None + } else { + Some(&new_access_token) + }; let new_refresh_token = rd_string(32); let scopes = token_row.scopes; // Create new access token (rejects archived workspaces inline) let rows = sqlx::query!( - "INSERT INTO token (token, email, label, expiration, scopes, workspace_id) - SELECT $1::varchar, $2::varchar, $3::varchar, now() + ($4 || ' seconds')::interval, $5::text[], $6::varchar - WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $6 AND deleted = true)", - new_access_token, + "INSERT INTO token (token_hash, token_prefix, token, email, label, expiration, scopes, workspace_id) + SELECT $1::varchar, $2::varchar, $3::varchar, $4::varchar, $5::varchar, now() + ($6 || ' seconds')::interval, $7::text[], $8::varchar + WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $8 AND deleted = true)", + new_access_token_hash, + new_access_token_prefix, + new_plaintext as Option<&str>, token_row.user_email, format!("mcp-oauth-{}", token_row.client_id), MCP_OAUTH_TOKEN_EXPIRATION_SECS.to_string(), @@ -542,13 +565,13 @@ async fn handle_refresh_token_grant( )); } - // Create new refresh token (same token family for tracking) + // Create new refresh token (same token family for tracking) — store hash of access token if let Err(e) = sqlx::query!( "INSERT INTO mcp_oauth_refresh_token - (refresh_token, access_token, client_id, user_email, workspace_id, scopes, token_family, expires_at) + (refresh_token, access_token_hash, client_id, user_email, workspace_id, scopes, token_family, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7, now() + ($8 || ' seconds')::interval)", new_refresh_token, - new_access_token, + new_access_token_hash, token_row.client_id, token_row.user_email, token_row.workspace_id, diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index 70e676b5b4..df1c83d6d5 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -834,7 +834,7 @@ pub(crate) async fn tarball_workspace( let trigger_str = &to_string_without_metadata( &trigger, false, - Some(vec!["webhook_token_prefix"]), + Some(vec!["webhook_token_hash"]), ) .unwrap(); archive diff --git a/backend/windmill-common/src/auth.rs b/backend/windmill-common/src/auth.rs index 7024029c52..23604b6e3a 100644 --- a/backend/windmill-common/src/auth.rs +++ b/backend/windmill-common/src/auth.rs @@ -18,6 +18,12 @@ use crate::{ DB, }; +/// Hash a raw token using SHA-256 (hex-encoded, 64 chars). +/// Used to store and look up tokens without keeping plaintext in the DB. +pub fn hash_token(token: &str) -> String { + crate::utils::calculate_hash(token) +} + #[derive(Debug)] pub struct IdToken { token: String, @@ -26,6 +32,15 @@ pub struct IdToken { pub const TOKEN_PREFIX_LEN: usize = 10; +/// Safely extract the token prefix (first TOKEN_PREFIX_LEN chars). +/// Returns the full token if it's shorter than TOKEN_PREFIX_LEN, preventing panics. +pub fn safe_token_prefix(token: &str) -> String { + token + .get(..TOKEN_PREFIX_LEN) + .unwrap_or(token) + .to_string() +} + lazy_static::lazy_static! { // Cache for script hash permissions - (ApiAuthed hash, script_hash) -> permission result pub static ref HASH_PERMS_CACHE: PermsCache = PermsCache::new(); diff --git a/backend/windmill-common/src/min_version.rs b/backend/windmill-common/src/min_version.rs index fd1769dd55..11abecff9f 100644 --- a/backend/windmill-common/src/min_version.rs +++ b/backend/windmill-common/src/min_version.rs @@ -5,6 +5,7 @@ use tokio::sync::RwLock; // ============ Feature Definitions ============ +pub const MIN_VERSION_SUPPORTS_TOKEN_HASH: VC = vc(1, 659, 0, "Token hash storage"); pub const MIN_VERSION_SUPPORTS_SYNC_JOBS_DEBOUNCING: VC = vc(1, 602, 0, "Sync jobs debouncing"); pub const MIN_VERSION_SUPPORTS_DEBOUNCING_V2: VC = vc(1, 597, 0, "Debouncing V2"); pub const MIN_VERSION_IS_AT_LEAST_1_595: VC = vc(1, 595, 0, "Flow status separate table"); diff --git a/backend/windmill-common/tests/fixtures/base.sql b/backend/windmill-common/tests/fixtures/base.sql index 7db9918fba..412fa1029f 100644 --- a/backend/windmill-common/tests/fixtures/base.sql +++ b/backend/windmill-common/tests/fixtures/base.sql @@ -33,9 +33,9 @@ INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES ('test-workspace', 'test3@windmill.dev', 'test-user-3', false, 'User'); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN', 'test@windmill.dev', 'test token', true); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); +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); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_2'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_3'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin; GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user; diff --git a/backend/windmill-common/tests/notify_events.rs b/backend/windmill-common/tests/notify_events.rs index 36302b8e66..b9f639caea 100644 --- a/backend/windmill-common/tests/notify_events.rs +++ b/backend/windmill-common/tests/notify_events.rs @@ -40,13 +40,20 @@ async fn count_events_for_channel(db: &Pool, channel: &str) -> i64 { #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_get_latest_event_id_returns_valid_id(db: Pool) { // Get current latest id - let latest_id = get_latest_event_id(&db).await.expect("Should get latest event id"); + let latest_id = get_latest_event_id(&db) + .await + .expect("Should get latest event id"); assert!(latest_id >= 0, "Latest id should be non-negative"); // Insert a new event and verify latest_id increases let new_id = insert_test_event(&db, "test_latest_id", "payload").await; - let new_latest_id = get_latest_event_id(&db).await.expect("Should get latest event id"); - assert!(new_latest_id >= new_id, "Latest id should be >= new event id"); + let new_latest_id = get_latest_event_id(&db) + .await + .expect("Should get latest event id"); + assert!( + new_latest_id >= new_id, + "Latest id should be >= new event id" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -55,7 +62,9 @@ async fn test_get_latest_event_id_with_events(db: Pool) { let _id2 = insert_test_event(&db, "test_channel_2", "payload2").await; let id3 = insert_test_event(&db, "test_channel_3", "payload3").await; - let latest_id = get_latest_event_id(&db).await.expect("Should get latest event id"); + let latest_id = get_latest_event_id(&db) + .await + .expect("Should get latest event id"); assert!(latest_id >= id3, "Latest id should be >= last inserted id"); } @@ -65,8 +74,13 @@ async fn test_poll_notify_events_no_new_events(db: Pool) { let latest_id = get_latest_event_id(&db).await.unwrap(); // Poll from the latest id - should return empty since no new events - let events = poll_notify_events(&db, latest_id).await.expect("Should poll events"); - assert!(events.is_empty(), "Should return empty vec when polling from latest id"); + let events = poll_notify_events(&db, latest_id) + .await + .expect("Should poll events"); + assert!( + events.is_empty(), + "Should return empty vec when polling from latest id" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -76,7 +90,9 @@ async fn test_poll_notify_events_returns_new_events(db: Pool) { let _id1 = insert_test_event(&db, "test_poll_channel", "payload1").await; let _id2 = insert_test_event(&db, "test_poll_channel", "payload2").await; - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); assert!(events.len() >= 2, "Should return at least 2 new events"); // Verify the events we inserted are present @@ -87,7 +103,10 @@ async fn test_poll_notify_events_returns_new_events(db: Pool) { assert_eq!(our_events.len(), 2, "Should have exactly our 2 test events"); // Verify ordering (ascending by id) - assert!(our_events[0].id < our_events[1].id, "Events should be ordered by id ascending"); + assert!( + our_events[0].id < our_events[1].id, + "Events should be ordered by id ascending" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -97,14 +116,19 @@ async fn test_poll_notify_events_respects_last_event_id(db: Pool) { let _id3 = insert_test_event(&db, "test_respect_id", "payload3").await; // Poll from id1 should only return id2 and id3 - let events = poll_notify_events(&db, id1).await.expect("Should poll events"); + let events = poll_notify_events(&db, id1) + .await + .expect("Should poll events"); let our_events: Vec<_> = events .iter() .filter(|e| e.channel == "test_respect_id") .collect(); assert_eq!(our_events.len(), 2, "Should only return events after id1"); - assert!(our_events.iter().all(|e| e.id > id1), "All events should have id > id1"); + assert!( + our_events.iter().all(|e| e.id > id1), + "All events should have id > id1" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -124,21 +148,24 @@ async fn test_cleanup_old_events(db: Pool) { .expect("Failed to insert old event"); // Insert a recent event - sqlx::query( - "INSERT INTO notify_event (channel, payload) VALUES ($1, $2)", - ) - .bind(&recent_channel) - .bind("recent_payload") - .execute(&db) - .await - .expect("Failed to insert recent event"); + sqlx::query("INSERT INTO notify_event (channel, payload) VALUES ($1, $2)") + .bind(&recent_channel) + .bind("recent_payload") + .execute(&db) + .await + .expect("Failed to insert recent event"); // Count before cleanup let old_count_before = count_events_for_channel(&db, &old_channel).await; - assert_eq!(old_count_before, 1, "Should have 1 old event before cleanup"); + assert_eq!( + old_count_before, 1, + "Should have 1 old event before cleanup" + ); // Cleanup events older than 10 minutes - let deleted = cleanup_old_events(&db, 10).await.expect("Should cleanup events"); + let deleted = cleanup_old_events(&db, 10) + .await + .expect("Should cleanup events"); assert!(deleted >= 1, "Should delete at least 1 old event"); // Verify old event is gone @@ -167,13 +194,18 @@ async fn test_trigger_notify_config_change(db: Pool) { .await .expect("Failed to insert config"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let config_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_config_change" && e.payload == "test_config_trigger") .collect(); - assert!(!config_events.is_empty(), "Should have notify_config_change event"); + assert!( + !config_events.is_empty(), + "Should have notify_config_change event" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -190,13 +222,18 @@ async fn test_trigger_notify_global_setting_change_insert(db: Pool) { .await .expect("Failed to insert global setting"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let setting_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_global_setting_change" && e.payload == setting_name) .collect(); - assert!(!setting_events.is_empty(), "Should have notify_global_setting_change event on insert"); + assert!( + !setting_events.is_empty(), + "Should have notify_global_setting_change event on insert" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -220,13 +257,18 @@ async fn test_trigger_notify_global_setting_change_update(db: Pool) { .await .expect("Failed to update global setting"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let setting_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_global_setting_change" && e.payload == setting_name) .collect(); - assert!(!setting_events.is_empty(), "Should have notify_global_setting_change event on update"); + assert!( + !setting_events.is_empty(), + "Should have notify_global_setting_change event on update" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -250,13 +292,18 @@ async fn test_trigger_notify_global_setting_change_delete(db: Pool) { .await .expect("Failed to delete global setting"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let setting_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_global_setting_change" && e.payload == setting_name) .collect(); - assert!(!setting_events.is_empty(), "Should have notify_global_setting_change event on delete"); + assert!( + !setting_events.is_empty(), + "Should have notify_global_setting_change event on delete" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -272,13 +319,18 @@ async fn test_trigger_notify_workspace_envs_change(db: Pool) { .await .expect("Failed to insert workspace env"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let env_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_workspace_envs_change" && e.payload == "test-workspace") .collect(); - assert!(!env_events.is_empty(), "Should have notify_workspace_envs_change event"); + assert!( + !env_events.is_empty(), + "Should have notify_workspace_envs_change event" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -294,44 +346,57 @@ async fn test_trigger_notify_workspace_key_change(db: Pool) { .await .expect("Failed to insert workspace key"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let key_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_workspace_key_change" && e.payload == "test-workspace") .collect(); - assert!(!key_events.is_empty(), "Should have notify_workspace_key_change event"); + assert!( + !key_events.is_empty(), + "Should have notify_workspace_key_change event" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_trigger_notify_token_invalidation(db: Pool) { - // First insert a session token + // First insert a session token with token_hash and token_prefix let token = format!("test_token_{}", uuid::Uuid::new_v4()); + let token_hash = windmill_common::utils::calculate_hash(&token); + let token_prefix = &token[..10]; sqlx::query( - "INSERT INTO token (token, label, email, workspace_id, owner, expiration) - VALUES ($1, 'session', 'test@test.com', 'test-workspace', 'test-user', now() + interval '1 hour')", + "INSERT INTO token (token_hash, token_prefix, label, email, workspace_id, owner, expiration) + VALUES ($1, $2, 'session', 'test@test.com', 'test-workspace', 'test-user', now() + interval '1 hour')", ) - .bind(&token) + .bind(&token_hash) + .bind(token_prefix) .execute(&db) .await .expect("Failed to insert token"); let before_id = get_latest_event_id(&db).await.unwrap(); - // Delete the token (should trigger notification) - sqlx::query("DELETE FROM token WHERE token = $1") - .bind(&token) + // Delete the token (should trigger notification with prefix) + sqlx::query("DELETE FROM token WHERE token_hash = $1") + .bind(&token_hash) .execute(&db) .await .expect("Failed to delete token"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let token_events: Vec<_> = events .iter() - .filter(|e| e.channel == "notify_token_invalidation" && e.payload == token) + .filter(|e| e.channel == "notify_token_invalidation" && e.payload == token_prefix) .collect(); - assert!(!token_events.is_empty(), "Should have notify_token_invalidation event"); + assert!( + !token_events.is_empty(), + "Should have notify_token_invalidation event" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -344,13 +409,18 @@ async fn test_trigger_notify_webhook_change(db: Pool) { .await .expect("Failed to update webhook"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let webhook_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_webhook_change" && e.payload == "test-workspace") .collect(); - assert!(!webhook_events.is_empty(), "Should have notify_webhook_change event"); + assert!( + !webhook_events.is_empty(), + "Should have notify_webhook_change event" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -363,13 +433,18 @@ async fn test_trigger_notify_workspace_premium_change(db: Pool) { .await .expect("Failed to update workspace premium"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let premium_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_workspace_premium_change" && e.payload == "test-workspace") .collect(); - assert!(!premium_events.is_empty(), "Should have notify_workspace_premium_change event"); + assert!( + !premium_events.is_empty(), + "Should have notify_workspace_premium_change event" + ); } // ============================================================================ @@ -392,14 +467,19 @@ async fn test_trigger_notify_http_trigger_change(db: Pool) { .await .expect("Failed to insert HTTP trigger"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let http_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_http_trigger_change") .filter(|e| e.payload.contains("test-workspace") && e.payload.contains(&trigger_path)) .collect(); - assert!(!http_events.is_empty(), "Should have notify_http_trigger_change event"); + assert!( + !http_events.is_empty(), + "Should have notify_http_trigger_change event" + ); } // ============================================================================ @@ -431,19 +511,27 @@ async fn test_trigger_notify_runnable_version_change_script(db: Pool) .await .expect("Failed to update script lock"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let script_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_runnable_version_change") .filter(|e| e.payload.contains("test-workspace") && e.payload.contains("script")) .collect(); - assert!(!script_events.is_empty(), "Should have notify_runnable_version_change event for script"); + assert!( + !script_events.is_empty(), + "Should have notify_runnable_version_change event for script" + ); // Verify payload format: workspace_id:source_type:path:kind let parts: Vec<&str> = script_events[0].payload.split(':').collect(); assert!(parts.len() >= 4, "Payload should have at least 4 parts"); - assert_eq!(parts[0], "test-workspace", "First part should be workspace_id"); + assert_eq!( + parts[0], "test-workspace", + "First part should be workspace_id" + ); assert_eq!(parts[1], "script", "Second part should be 'script'"); } @@ -472,19 +560,27 @@ async fn test_trigger_notify_runnable_version_change_flow(db: Pool) { .await .expect("Failed to update flow versions"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let flow_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_runnable_version_change") .filter(|e| e.payload.contains("test-workspace") && e.payload.contains("flow")) .collect(); - assert!(!flow_events.is_empty(), "Should have notify_runnable_version_change event for flow"); + assert!( + !flow_events.is_empty(), + "Should have notify_runnable_version_change event for flow" + ); // Verify payload format let parts: Vec<&str> = flow_events[0].payload.split(':').collect(); assert!(parts.len() >= 4, "Payload should have at least 4 parts"); - assert_eq!(parts[0], "test-workspace", "First part should be workspace_id"); + assert_eq!( + parts[0], "test-workspace", + "First part should be workspace_id" + ); assert_eq!(parts[1], "flow", "Second part should be 'flow'"); } @@ -521,13 +617,16 @@ async fn test_concurrent_event_insertion(db: Pool) { handle.await.expect("Task should complete"); } - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); - let concurrent_events: Vec<_> = events - .iter() - .filter(|e| e.channel == channel) - .collect(); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); + let concurrent_events: Vec<_> = events.iter().filter(|e| e.channel == channel).collect(); - assert_eq!(concurrent_events.len(), 10, "Should have all 10 concurrent events"); + assert_eq!( + concurrent_events.len(), + 10, + "Should have all 10 concurrent events" + ); // Verify all events have unique IDs let ids: std::collections::HashSet = concurrent_events.iter().map(|e| e.id).collect(); @@ -571,18 +670,45 @@ async fn test_polling_isolation(db: Pool) { .expect("Failed to insert event"); // Two different "consumers" polling from different points - let events_from_baseline = poll_notify_events(&db, baseline_id).await.expect("Should poll events"); - let events_from_id1 = poll_notify_events(&db, id1).await.expect("Should poll events"); - let events_from_id2 = poll_notify_events(&db, id2).await.expect("Should poll events"); + let events_from_baseline = poll_notify_events(&db, baseline_id) + .await + .expect("Should poll events"); + let events_from_id1 = poll_notify_events(&db, id1) + .await + .expect("Should poll events"); + let events_from_id2 = poll_notify_events(&db, id2) + .await + .expect("Should poll events"); // Filter to our test events - let from_baseline: Vec<_> = events_from_baseline.iter().filter(|e| e.channel == channel).collect(); - let from_id1: Vec<_> = events_from_id1.iter().filter(|e| e.channel == channel).collect(); - let from_id2: Vec<_> = events_from_id2.iter().filter(|e| e.channel == channel).collect(); + let from_baseline: Vec<_> = events_from_baseline + .iter() + .filter(|e| e.channel == channel) + .collect(); + let from_id1: Vec<_> = events_from_id1 + .iter() + .filter(|e| e.channel == channel) + .collect(); + let from_id2: Vec<_> = events_from_id2 + .iter() + .filter(|e| e.channel == channel) + .collect(); - assert_eq!(from_baseline.len(), 3, "Polling from baseline should include all 3 events"); - assert_eq!(from_id1.len(), 2, "Polling from id1 should include id2 and id3"); - assert_eq!(from_id2.len(), 1, "Polling from id2 should include only id3"); + assert_eq!( + from_baseline.len(), + 3, + "Polling from baseline should include all 3 events" + ); + assert_eq!( + from_id1.len(), + 2, + "Polling from id1 should include id2 and id3" + ); + assert_eq!( + from_id2.len(), + 1, + "Polling from id2 should include only id3" + ); } // ============================================================================ @@ -595,14 +721,23 @@ async fn test_empty_payload(db: Pool) { insert_test_event(&db, "test_empty_payload", "").await; - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let empty_events: Vec<_> = events .iter() .filter(|e| e.channel == "test_empty_payload") .collect(); - assert_eq!(empty_events.len(), 1, "Should have event with empty payload"); - assert_eq!(empty_events[0].payload, "", "Payload should be empty string"); + assert_eq!( + empty_events.len(), + 1, + "Should have event with empty payload" + ); + assert_eq!( + empty_events[0].payload, "", + "Payload should be empty string" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -613,14 +748,24 @@ async fn test_large_payload(db: Pool) { let large_payload = "x".repeat(1024); insert_test_event(&db, "test_large_payload", &large_payload).await; - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let large_events: Vec<_> = events .iter() .filter(|e| e.channel == "test_large_payload") .collect(); - assert_eq!(large_events.len(), 1, "Should have event with large payload"); - assert_eq!(large_events[0].payload.len(), 1024, "Payload should be preserved"); + assert_eq!( + large_events.len(), + 1, + "Should have event with large payload" + ); + assert_eq!( + large_events[0].payload.len(), + 1024, + "Payload should be preserved" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -630,14 +775,23 @@ async fn test_special_characters_in_payload(db: Pool) { let special_payload = r#"{"key": "value with \"quotes\" and 'apostrophes'", "unicode": "日本語", "newline": "line1\nline2"}"#; insert_test_event(&db, "test_special_chars", special_payload).await; - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let special_events: Vec<_> = events .iter() .filter(|e| e.channel == "test_special_chars") .collect(); - assert_eq!(special_events.len(), 1, "Should have event with special characters"); - assert_eq!(special_events[0].payload, special_payload, "Special characters should be preserved"); + assert_eq!( + special_events.len(), + 1, + "Should have event with special characters" + ); + assert_eq!( + special_events[0].payload, special_payload, + "Special characters should be preserved" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -664,7 +818,9 @@ async fn test_cleanup_with_no_old_events(db: Pool) { assert_eq!(before_count, 2, "Should have 2 recent events"); // Cleanup old events (none of our events should be deleted since they're recent) - let _deleted = cleanup_old_events(&db, 10).await.expect("Should cleanup events"); + let _deleted = cleanup_old_events(&db, 10) + .await + .expect("Should cleanup events"); let after_count = count_events_for_channel(&db, &channel).await; assert_eq!(after_count, 2, "Recent events should not be deleted"); @@ -732,7 +888,11 @@ impl ServerProcess { } fn logs_contain(&self, needle: &str) -> bool { - self.log_lines.lock().unwrap().iter().any(|l| l.contains(needle)) + self.log_lines + .lock() + .unwrap() + .iter() + .any(|l| l.contains(needle)) } fn dump_logs(&self) -> String { @@ -783,12 +943,17 @@ async fn test_two_server_processes_both_receive_event() { let mut server_b = ServerProcess::start(19200, &db_url); // Wait for both servers to be ready - let (ready_a, ready_b) = tokio::join!( - wait_for_server(19100, 30), - wait_for_server(19200, 30), + let (ready_a, ready_b) = tokio::join!(wait_for_server(19100, 30), wait_for_server(19200, 30),); + assert!( + ready_a, + "Server A (port 19100) failed to start. Logs:\n{}", + server_a.dump_logs() + ); + assert!( + ready_b, + "Server B (port 19200) failed to start. Logs:\n{}", + server_b.dump_logs() ); - assert!(ready_a, "Server A (port 19100) failed to start. Logs:\n{}", server_a.dump_logs()); - assert!(ready_b, "Server B (port 19200) failed to start. Logs:\n{}", server_b.dump_logs()); // Give servers a moment to complete their first poll cycle tokio::time::sleep(std::time::Duration::from_secs(2)).await; diff --git a/backend/windmill-native-triggers/src/google/external.rs b/backend/windmill-native-triggers/src/google/external.rs index 756090fa02..f68706fb2f 100644 --- a/backend/windmill-native-triggers/src/google/external.rs +++ b/backend/windmill-native-triggers/src/google/external.rs @@ -11,7 +11,7 @@ use windmill_common::{ use windmill_queue::PushArgsOwned; use crate::{ - generate_webhook_service_url, get_token_by_prefix, + generate_webhook_service_url, rotate_webhook_token, sync::{SyncAction, SyncError, TriggerSyncInfo}, update_native_trigger_error, update_native_trigger_service_config, External, NativeTrigger, NativeTriggerData, ServiceName, @@ -309,14 +309,16 @@ impl Google { } /// Renew an expiring Google watch channel. - /// Stops the old channel and creates a new one with the same channel ID. - /// Returns the updated service_config with new expiration. + /// Rotates the webhook token (creating a new one with the same label), + /// stops the old channel and creates a new one with the same channel ID. + /// Returns (new_service_config, new_plaintext_token, old_token_hash). + /// Callers should delete old_token_hash after successfully updating the trigger. pub async fn renew_channel( &self, w_id: &str, trigger: &NativeTrigger, db: &DB, - ) -> Result { + ) -> Result<(serde_json::Value, String, String)> { let config: GoogleServiceConfig = trigger .service_config .as_ref() @@ -324,10 +326,15 @@ impl Google { .transpose()? .ok_or_else(|| Error::InternalErr("Missing service config".to_string()))?; - let webhook_token = get_token_by_prefix(db, &trigger.webhook_token_prefix) - .await? - .ok_or_else(|| Error::InternalErr("Webhook token not found".to_string()))?; - + let rotated = match rotate_webhook_token(db, &trigger.webhook_token_hash).await? { + Some(r) => r, + None => { + return Err(Error::InternalErr(format!( + "Cannot renew channel {}: webhook token no longer exists and no user context to create a fresh one", + trigger.external_id + ))); + } + }; let base_url = &*BASE_URL.read().await; // Reuse the same channel ID so external_id stays permanent let channel_id = trigger.external_id.clone(); @@ -338,7 +345,7 @@ impl Google { trigger.is_flow, Some(&channel_id), ServiceName::Google, - &webhook_token, + &rotated.new_token, ); tracing::info!( @@ -403,8 +410,9 @@ impl Google { new_config.google_resource_id = Some(resp.resource_id); new_config.expiration = Some(resp.expiration); - serde_json::to_value(&new_config) - .map_err(|e| Error::internal_err(format!("Failed to serialize config: {}", e))) + let config_value = serde_json::to_value(&new_config) + .map_err(|e| Error::internal_err(format!("Failed to serialize config: {}", e)))?; + Ok((config_value, rotated.new_token, rotated.old_token_hash)) } } @@ -461,17 +469,25 @@ async fn renew_expiring_channels( ); match handler.renew_channel(workspace_id, trigger, db).await { - Ok(new_config) => { + Ok((new_config, new_token, old_token_hash)) => { match update_native_trigger_service_config( db, workspace_id, ServiceName::Google, &trigger.external_id, &new_config, + Some(&new_token), ) .await { Ok(()) => { + // Trigger updated — clean up old token (best-effort) + if let Err(e) = crate::delete_token_by_hash(db, &old_token_hash).await { + tracing::warn!( + "Failed to delete old webhook token after channel renewal for {}: {}", + trigger.external_id, e + ); + } tracing::info!( "Renewed Google channel {} for '{}'", trigger.external_id, diff --git a/backend/windmill-native-triggers/src/handler.rs b/backend/windmill-native-triggers/src/handler.rs index 9384bde8b6..b66a8ae3e0 100644 --- a/backend/windmill-native-triggers/src/handler.rs +++ b/backend/windmill-native-triggers/src/handler.rs @@ -1,6 +1,6 @@ use crate::{ - decrypt_oauth_data, delete_native_trigger, delete_token_by_prefix, get_native_trigger, - get_token_by_prefix, list_native_triggers, store_native_trigger, update_native_trigger_error, + decrypt_oauth_data, delete_native_trigger, delete_token_by_hash, get_native_trigger, + list_native_triggers, rotate_webhook_token, store_native_trigger, update_native_trigger_error, External, NativeTrigger, NativeTriggerConfig, NativeTriggerData, ServiceName, }; use axum::{ @@ -234,31 +234,48 @@ async fn update_native_trigger_handler( let runnable_changed = existing.script_path != data.script_path || existing.is_flow != data.is_flow; - let webhook_token = match get_token_by_prefix(&db, &existing.webhook_token_prefix).await? { - Some(token) if !runnable_changed => token, - existing_token => { - if let Some(_) = existing_token { - delete_token_by_prefix(&db, &existing.webhook_token_prefix).await?; - } else { - tracing::warn!( - "Webhook token not found for trigger {} (prefix: {}), recreating token", - external_id, - existing.webhook_token_prefix - ); + // Track old token hash so we can clean it up after everything succeeds + let mut old_token_hash_to_delete: Option = None; + + let webhook_token = if runnable_changed { + // Scopes change when the runnable changes — delete old, create fresh token + old_token_hash_to_delete = Some(existing.webhook_token_hash.clone()); + let token = new_webhook_token( + &mut *tx, + &db, + &authed, + &data.script_path, + data.is_flow, + &workspace_id, + service_name, + ) + .await?; + tx.commit().await?; + tx = user_db.begin(&authed).await?; + token + } else { + // Same runnable — rotate the token keeping the same label + match rotate_webhook_token(&db, &existing.webhook_token_hash).await? { + Some(rotated) => { + old_token_hash_to_delete = Some(rotated.old_token_hash); + rotated.new_token + } + None => { + // Old token gone — create a fresh one + let token = new_webhook_token( + &mut *tx, + &db, + &authed, + &data.script_path, + data.is_flow, + &workspace_id, + service_name, + ) + .await?; + tx.commit().await?; + tx = user_db.begin(&authed).await?; + token } - let token = new_webhook_token( - &mut *tx, - &db, - &authed, - &data.script_path, - data.is_flow, - &workspace_id, - service_name, - ) - .await?; - tx.commit().await?; - tx = user_db.begin(&authed).await?; - token } }; @@ -303,6 +320,16 @@ async fn update_native_trigger_handler( tx.commit().await?; + // Everything succeeded — clean up old token (best-effort) + if let Some(old_hash) = old_token_hash_to_delete { + if let Err(e) = delete_token_by_hash(&db, &old_hash).await { + tracing::warn!( + "Failed to delete old webhook token after trigger update: {}", + e + ); + } + } + Ok(format!("Native trigger updated")) } @@ -428,12 +455,12 @@ async fn delete_native_trigger_handler( return Err(Error::NotFound(format!("Native trigger not found"))); } - // Delete the webhook token using its prefix - if !delete_token_by_prefix(&db, &existing.webhook_token_prefix).await? { + // Delete the webhook token using its hash + if !delete_token_by_hash(&db, &existing.webhook_token_hash).await? { tracing::warn!( - "Webhook token not found when deleting trigger {} (prefix: {})", + "Webhook token not found when deleting trigger {} (hash: {})", external_id, - existing.webhook_token_prefix + existing.webhook_token_hash ); } diff --git a/backend/windmill-native-triggers/src/lib.rs b/backend/windmill-native-triggers/src/lib.rs index 416cf62d86..25234ff3f0 100644 --- a/backend/windmill-native-triggers/src/lib.rs +++ b/backend/windmill-native-triggers/src/lib.rs @@ -190,7 +190,7 @@ pub struct NativeTrigger { pub service_name: ServiceName, pub script_path: String, pub is_flow: bool, - pub webhook_token_prefix: String, + pub webhook_token_hash: String, pub service_config: Option, pub error: Option, pub created_at: DateTime, @@ -731,41 +731,85 @@ async fn update_oauth_token_resource( } } -/// Look up the full token from the token table using its prefix -pub async fn get_token_by_prefix<'c, E: sqlx::Executor<'c, Database = Postgres>>( - db: E, - token_prefix: &str, -) -> Result> { - let token = sqlx::query_scalar!( - r#" - SELECT token as "token!" - FROM token - WHERE token LIKE concat($1::text, '%') - LIMIT 1 - "#, - token_prefix +/// Create a new webhook token that keeps the same label as the old one. +/// The old token is **not** deleted — callers must call `delete_token_by_hash` +/// on `old_token_hash` after the trigger row has been successfully updated. +/// This ensures the trigger keeps working if the external service call or +/// subsequent DB update fails. +/// +/// Returns `Ok(None)` if the old token no longer exists (e.g. manually deleted by user). +/// In that case, `renew_channel` returns an error which `renew_expiring_channels` writes +/// to the trigger's `error` column — visible in the UI so the user can re-create the trigger. +pub async fn rotate_webhook_token(db: &DB, old_token_hash: &str) -> Result> { + use windmill_common::auth::{hash_token, TOKEN_PREFIX_LEN}; + use windmill_common::min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH; + use windmill_common::utils::rd_string; + + let old = match sqlx::query!( + "SELECT label, email, scopes, workspace_id, super_admin, owner, expiration FROM token WHERE token_hash = $1", + old_token_hash ) .fetch_optional(db) - .await?; + .await? + { + Some(row) => row, + None => { + tracing::warn!( + "Webhook token not found for hash {}, caller should create a fresh token", + old_token_hash + ); + return Ok(None); + } + }; - Ok(token) -} + let new_token = rd_string(32); + let new_hash = hash_token(&new_token); + let new_prefix = new_token.get(..TOKEN_PREFIX_LEN).unwrap_or(&new_token); + let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await { + None + } else { + Some(&new_token) + }; -/// Delete a token from the token table using its prefix -pub async fn delete_token_by_prefix<'c, E: sqlx::Executor<'c, Database = Postgres>>( - db: E, - token_prefix: &str, -) -> Result { - let deleted = sqlx::query!( - r#" - DELETE FROM token - WHERE token LIKE concat($1::text, '%') - "#, - token_prefix + sqlx::query!( + "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes, workspace_id, owner, expiration) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + new_hash, + new_prefix, + plaintext as Option<&str>, + old.email, + old.label, + old.super_admin, + old.scopes.as_deref(), + old.workspace_id, + old.owner, + old.expiration, ) .execute(db) - .await? - .rows_affected(); + .await?; + + Ok(Some(RotatedToken { + new_token, + old_token_hash: old_token_hash.to_string(), + })) +} + +pub struct RotatedToken { + pub new_token: String, + /// Hash of the old token — callers should delete this after the + /// trigger row has been successfully updated to point at the new token. + pub old_token_hash: String, +} + +/// Delete a token from the token table using its hash (exact match). +pub async fn delete_token_by_hash<'c, E: sqlx::Executor<'c, Database = Postgres>>( + db: E, + token_hash: &str, +) -> Result { + let deleted = sqlx::query!("DELETE FROM token WHERE token_hash = $1", token_hash) + .execute(db) + .await? + .rows_affected(); Ok(deleted > 0) } @@ -778,8 +822,9 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres> config: &NativeTriggerConfig, service_config: C, ) -> Result<()> { - // Store only the first 10 characters of the webhook token as a prefix - let webhook_token_prefix: String = config.webhook_token.chars().take(10).collect(); + use windmill_common::auth::hash_token; + + let webhook_token_hash = hash_token(&config.webhook_token); sqlx::query!( r#" @@ -789,20 +834,20 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres> service_name, script_path, is_flow, - webhook_token_prefix, + webhook_token_hash, service_config ) VALUES ( $1, $2, $3, $4, $5, $6, $7 ) ON CONFLICT (external_id, workspace_id, service_name) - DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_prefix = $6, service_config = $7, error = NULL, updated_at = NOW() + DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, error = NULL, updated_at = NOW() "#, external_id, workspace_id, service_name as ServiceName, config.script_path, config.is_flow, - webhook_token_prefix, + webhook_token_hash, sqlx::types::Json(service_config) as _, ) .execute(db) @@ -819,13 +864,14 @@ pub async fn update_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres config: &NativeTriggerConfig, service_config: Option<&RawValue>, ) -> Result<()> { - // Store only the first 10 characters of the webhook token as a prefix - let webhook_token_prefix: String = config.webhook_token.chars().take(10).collect(); + use windmill_common::auth::hash_token; + + let webhook_token_hash = hash_token(&config.webhook_token); sqlx::query!( r#" UPDATE native_trigger - SET script_path = $1, is_flow = $2, webhook_token_prefix = $3, service_config = $4, error = NULL, updated_at = NOW() + SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, error = NULL, updated_at = NOW() WHERE workspace_id = $5 AND service_name = $6 @@ -833,7 +879,7 @@ pub async fn update_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres "#, config.script_path, config.is_flow, - webhook_token_prefix, + webhook_token_hash, service_config.map(sqlx::types::Json) as _, workspace_id, service_name as ServiceName, @@ -884,7 +930,7 @@ pub async fn get_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>>( service_name AS "service_name!: ServiceName", script_path, is_flow, - webhook_token_prefix, + webhook_token_hash, service_config, error, created_at, @@ -922,7 +968,7 @@ pub async fn get_native_trigger_by_script<'c, E: sqlx::Executor<'c, Database = P service_name AS "service_name!: ServiceName", script_path, is_flow, - webhook_token_prefix, + webhook_token_hash, service_config, error, created_at, @@ -968,7 +1014,7 @@ pub async fn list_native_triggers<'c, E: sqlx::Executor<'c, Database = Postgres> nt.service_name AS "service_name!: ServiceName", nt.script_path, nt.is_flow, - nt.webhook_token_prefix, + nt.webhook_token_hash, nt.service_config, nt.error, nt.created_at, @@ -1045,11 +1091,16 @@ pub async fn update_native_trigger_service_config< service_name: ServiceName, external_id: &str, service_config: &serde_json::Value, + new_webhook_token: Option<&str>, ) -> Result<()> { + let new_hash = new_webhook_token.map(windmill_common::auth::hash_token); + sqlx::query!( r#" UPDATE native_trigger - SET service_config = $1, updated_at = NOW() + SET service_config = $1, + webhook_token_hash = COALESCE($5, webhook_token_hash), + updated_at = NOW() WHERE workspace_id = $2 AND service_name = $3 @@ -1059,6 +1110,7 @@ pub async fn update_native_trigger_service_config< workspace_id, service_name as ServiceName, external_id, + new_hash.as_deref(), ) .execute(db) .await?; diff --git a/backend/windmill-native-triggers/src/sync.rs b/backend/windmill-native-triggers/src/sync.rs index 873ba22557..292d00de2a 100644 --- a/backend/windmill-native-triggers/src/sync.rs +++ b/backend/windmill-native-triggers/src/sync.rs @@ -390,6 +390,7 @@ pub async fn reconcile_with_external_state( service_name, &trigger.external_id, external_service_config, + None, ) .await { diff --git a/backend/windmill-native-triggers/src/workspace_integrations.rs b/backend/windmill-native-triggers/src/workspace_integrations.rs index 3d3db5eea5..87d40d5b05 100644 --- a/backend/windmill-native-triggers/src/workspace_integrations.rs +++ b/backend/windmill-native-triggers/src/workspace_integrations.rs @@ -34,8 +34,8 @@ use windmill_api_auth::ApiAuthed; #[cfg(feature = "native_trigger")] use crate::{ - decrypt_oauth_data, delete_token_by_prefix, delete_workspace_integration, - nextcloud::OcsResponse, resolve_endpoint, store_workspace_integration, ServiceName, + decrypt_oauth_data, delete_token_by_hash, delete_workspace_integration, nextcloud::OcsResponse, + resolve_endpoint, store_workspace_integration, ServiceName, }; #[cfg(feature = "native_trigger")] @@ -253,7 +253,7 @@ async fn fetch_nextcloud_user_id(base_url: &str, access_token: &str) -> anyhow:: #[cfg(feature = "native_trigger")] async fn delete_triggers_for_service(db: &DB, workspace_id: &str, service_name: ServiceName) { let triggers = sqlx::query!( - "SELECT external_id, webhook_token_prefix FROM native_trigger WHERE workspace_id = $1 AND service_name = $2", + "SELECT external_id, webhook_token_hash FROM native_trigger WHERE workspace_id = $1 AND service_name = $2", workspace_id, service_name as ServiceName ) @@ -303,10 +303,10 @@ async fn delete_triggers_for_service(db: &DB, workspace_id: &str, service_name: // Delete all associated webhook tokens for trigger in &triggers { - if let Err(e) = delete_token_by_prefix(db, &trigger.webhook_token_prefix).await { + if let Err(e) = delete_token_by_hash(db, &trigger.webhook_token_hash).await { tracing::error!( - "Failed to delete webhook token with prefix {}: {e}", - trigger.webhook_token_prefix + "Failed to delete webhook token with hash {}: {e}", + trigger.webhook_token_hash ); } } diff --git a/backend/windmill-queue/tests/fixtures/base.sql b/backend/windmill-queue/tests/fixtures/base.sql index 7db9918fba..412fa1029f 100644 --- a/backend/windmill-queue/tests/fixtures/base.sql +++ b/backend/windmill-queue/tests/fixtures/base.sql @@ -33,9 +33,9 @@ INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES ('test-workspace', 'test3@windmill.dev', 'test-user-3', false, 'User'); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN', 'test@windmill.dev', 'test token', true); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); +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); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_2'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_3'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin; GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user; From 18b3528ba4188721d918fd47f0f86a6b41209453 Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Mon, 16 Mar 2026 21:19:46 -0400 Subject: [PATCH 082/204] feat: instance groups instance-level role support (#8404) * [ee] feat: instance groups instance-level role support Add ability to assign instance-level roles (superadmin/devops) to instance groups. Group members automatically receive the role with proper precedence: manual elevated roles always win, superadmin > devops. - Migration: add instance_role to instance_group, role_source to password - Role propagation on all mutation paths (add/remove/update/delete/import) - SCIM sync integration (companion PR: windmill-ee-private#463) - Frontend: role toggle in group editor, role column in tables, role source indicator in superadmin settings Co-Authored-By: Claude Opus 4.6 * chore: update ee-repo-ref to 278a3887f759f9d1146554baa0765518d5bc70f2 This commit updates the EE repository reference after PR #463 was merged in windmill-ee-private. Previous ee-repo-ref: b407fe4604153d09ff223e11d43c2df83a1de5d0 New ee-repo-ref: 278a3887f759f9d1146554baa0765518d5bc70f2 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: windmill-internal-app[bot] Co-authored-by: Ruben Fiszel --- ...be922e1463d2046753cf80377369a90013ab.json} | 10 +- ...5d3488e0969f2c334ab7c0bfcd5235e3dd82.json} | 12 +- ...1303a7756f654d1a2c04bd47c0f2d1122155.json} | 12 +- ...56ef8c427aa1bbcfe2a11ba34cc8eab9fb4e.json} | 4 +- ...5d37d14d51d5c95776e2d5491b5c5ab3e25b7.json | 22 ++ ...ea8be1f62e4607fcd8f401701ab66f6d20cae.json | 28 ++ ...6fedb0f3f0040844c3774145e8f1f4c023ce.json} | 10 +- ...b1e7ce2bb160270b59c0f359b7952453b2b9.json} | 10 +- ...8e719ec455acd53e30984087d5c3a4a03cd25.json | 34 +++ ...70226ceb1b94a53dd540cdc27a0e1060aff12.json | 34 +++ ...fa81e8134f25b1dfcedb765a701f0dae8564.json} | 5 +- ...37dbe2e92de1b45dfc9f2656fea9d08b354a0.json | 16 ++ ...973bd9d2d7818320ae4d6c02d68a29298e63.json} | 4 +- ...f60634f06fa6a2b5c65f654f4da87e3724c25.json | 20 ++ ...3e26ec51302e8916e3b186123c024546ff4a.json} | 12 +- ...0cb549a34b96554ae1872355b90304f5dcb76.json | 4 +- ...1d4a0dc97741055d83d4102395f1a05824d6b.json | 22 ++ ...120eb8e28c823f9e04fb54b7a545a353495c1.json | 22 ++ ...9ee04f062c989bd1c7abfc0098b408a216cc.json} | 10 +- backend/ee-repo-ref.txt | 2 +- ...0260316000000_instance_group_role.down.sql | 2 + .../20260316000000_instance_group_role.up.sql | 9 + backend/windmill-api-groups/src/groups.rs | 272 +++++++++++++++++- backend/windmill-api-users/src/users.rs | 80 +++++- backend/windmill-api/openapi.yaml | 28 +- .../lib/components/InstanceGroupEditor.svelte | 121 ++++++-- .../components/SuperadminSettingsInner.svelte | 141 +++++---- .../(root)/(logged)/groups/+page.svelte | 20 +- .../(logged)/instance_groups/+page.svelte | 4 +- 29 files changed, 841 insertions(+), 129 deletions(-) rename backend/.sqlx/{query-b5ade857a358f2fee4bb7d005e5fef1cabea003419c891f8b1e52bc2c0156b0b.json => query-05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab.json} (82%) rename backend/.sqlx/{query-a00f3f18087326432c9114998e47cff4f78d1b28cdb8adc6b18b937e1cf142d1.json => query-0aef85e3dc8910d7243f9e5a26795d3488e0969f2c334ab7c0bfcd5235e3dd82.json} (57%) rename backend/.sqlx/{query-10f6d3ffd7406146572b1becdce5c8da5242b58f6ce46ab10296cff9d6a3a6c4.json => query-0b0f601716c6713f8b521a65dba01303a7756f654d1a2c04bd47c0f2d1122155.json} (58%) rename backend/.sqlx/{query-411788111afccd826ce78b266153600939c65c75be8894322b90d9da18dcb824.json => query-11d89b437b9fe5d493e1806438dd56ef8c427aa1bbcfe2a11ba34cc8eab9fb4e.json} (50%) create mode 100644 backend/.sqlx/query-449934711f09b700fca46be3e165d37d14d51d5c95776e2d5491b5c5ab3e25b7.json create mode 100644 backend/.sqlx/query-4afabac265755dd90c33193260eea8be1f62e4607fcd8f401701ab66f6d20cae.json rename backend/.sqlx/{query-9229d9a9ff389cf26e480b604b83900e2d362ee934ef27284ef39f4eed440e59.json => query-60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce.json} (81%) rename backend/.sqlx/{query-37e23397905e25bbbf5a7047c790967a97cc8f6948beef706b0c053621882330.json => query-65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9.json} (84%) create mode 100644 backend/.sqlx/query-6a1cc753157c51481932ad8079f8e719ec455acd53e30984087d5c3a4a03cd25.json create mode 100644 backend/.sqlx/query-7bdee54a36cc873c611f8a41c0470226ceb1b94a53dd540cdc27a0e1060aff12.json rename backend/.sqlx/{query-234a278f20cb73f8ce10d2bfb67af58e5dd888581467c976e76f140b2c00f6d7.json => query-88b5d7e6806b1a6f4e6ef5af5fc3fa81e8134f25b1dfcedb765a701f0dae8564.json} (63%) create mode 100644 backend/.sqlx/query-9330c172624e5a4ac9d3b6c465d37dbe2e92de1b45dfc9f2656fea9d08b354a0.json rename backend/.sqlx/{query-6c7186de56bcd9983a64de0c01a733e818ebc30af2377158c8a92ec66c06464c.json => query-96ee5b8253ee54bcea68f0b5256a973bd9d2d7818320ae4d6c02d68a29298e63.json} (51%) create mode 100644 backend/.sqlx/query-98c512c011d176366f3c4a0633bf60634f06fa6a2b5c65f654f4da87e3724c25.json rename backend/.sqlx/{query-ef0f4447498a117e4495ed9335d803403ad0055efb6da4bdd467b4ac9bf4e478.json => query-9dbd6cbae01ae05f4b56a8e3ad563e26ec51302e8916e3b186123c024546ff4a.json} (58%) create mode 100644 backend/.sqlx/query-e8717d0197b51a0837f85a2615d1d4a0dc97741055d83d4102395f1a05824d6b.json create mode 100644 backend/.sqlx/query-e959176da22a76c43c63c26a993120eb8e28c823f9e04fb54b7a545a353495c1.json rename backend/.sqlx/{query-9cd6e964ba933de247ba7ddce0acef0b70b784c2410f3a5f51288aaea6904b8f.json => query-fd61a081912cd70edbee757891319ee04f062c989bd1c7abfc0098b408a216cc.json} (74%) create mode 100644 backend/migrations/20260316000000_instance_group_role.down.sql create mode 100644 backend/migrations/20260316000000_instance_group_role.up.sql diff --git a/backend/.sqlx/query-b5ade857a358f2fee4bb7d005e5fef1cabea003419c891f8b1e52bc2c0156b0b.json b/backend/.sqlx/query-05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab.json similarity index 82% rename from backend/.sqlx/query-b5ade857a358f2fee4bb7d005e5fef1cabea003419c891f8b1e52bc2c0156b0b.json rename to backend/.sqlx/query-05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab.json index 7857ca1b4e..53a3863587 100644 --- a/backend/.sqlx/query-b5ade857a358f2fee4bb7d005e5fef1cabea003419c891f8b1e52bc2c0156b0b.json +++ b/backend/.sqlx/query-05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT $1 OFFSET $2", + "query": "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT $1 OFFSET $2", "describe": { "columns": [ { @@ -52,6 +52,11 @@ "ordinal": 9, "name": "first_time_user", "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "role_source", + "type_info": "Varchar" } ], "parameters": { @@ -70,8 +75,9 @@ true, true, null, + false, false ] }, - "hash": "b5ade857a358f2fee4bb7d005e5fef1cabea003419c891f8b1e52bc2c0156b0b" + "hash": "05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab" } diff --git a/backend/.sqlx/query-a00f3f18087326432c9114998e47cff4f78d1b28cdb8adc6b18b937e1cf142d1.json b/backend/.sqlx/query-0aef85e3dc8910d7243f9e5a26795d3488e0969f2c334ab7c0bfcd5235e3dd82.json similarity index 57% rename from backend/.sqlx/query-a00f3f18087326432c9114998e47cff4f78d1b28cdb8adc6b18b937e1cf142d1.json rename to backend/.sqlx/query-0aef85e3dc8910d7243f9e5a26795d3488e0969f2c334ab7c0bfcd5235e3dd82.json index b703a00e96..39104e0251 100644 --- a/backend/.sqlx/query-a00f3f18087326432c9114998e47cff4f78d1b28cdb8adc6b18b937e1cf142d1.json +++ b/backend/.sqlx/query-0aef85e3dc8910d7243f9e5a26795d3488e0969f2c334ab7c0bfcd5235e3dd82.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name", + "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, summary, instance_role", "describe": { "columns": [ { @@ -17,6 +17,11 @@ "ordinal": 2, "name": "emails", "type_info": "VarcharArray" + }, + { + "ordinal": 3, + "name": "instance_role", + "type_info": "Varchar" } ], "parameters": { @@ -25,8 +30,9 @@ "nullable": [ false, true, - null + null, + true ] }, - "hash": "a00f3f18087326432c9114998e47cff4f78d1b28cdb8adc6b18b937e1cf142d1" + "hash": "0aef85e3dc8910d7243f9e5a26795d3488e0969f2c334ab7c0bfcd5235e3dd82" } diff --git a/backend/.sqlx/query-10f6d3ffd7406146572b1becdce5c8da5242b58f6ce46ab10296cff9d6a3a6c4.json b/backend/.sqlx/query-0b0f601716c6713f8b521a65dba01303a7756f654d1a2c04bd47c0f2d1122155.json similarity index 58% rename from backend/.sqlx/query-10f6d3ffd7406146572b1becdce5c8da5242b58f6ce46ab10296cff9d6a3a6c4.json rename to backend/.sqlx/query-0b0f601716c6713f8b521a65dba01303a7756f654d1a2c04bd47c0f2d1122155.json index 98bafc734c..b15885016f 100644 --- a/backend/.sqlx/query-10f6d3ffd7406146572b1becdce5c8da5242b58f6ce46ab10296cff9d6a3a6c4.json +++ b/backend/.sqlx/query-0b0f601716c6713f8b521a65dba01303a7756f654d1a2c04bd47c0f2d1122155.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, summary", + "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, instance_role", "describe": { "columns": [ { @@ -17,6 +17,11 @@ "ordinal": 2, "name": "emails", "type_info": "VarcharArray" + }, + { + "ordinal": 3, + "name": "instance_role", + "type_info": "Varchar" } ], "parameters": { @@ -25,8 +30,9 @@ "nullable": [ false, true, - null + null, + true ] }, - "hash": "10f6d3ffd7406146572b1becdce5c8da5242b58f6ce46ab10296cff9d6a3a6c4" + "hash": "0b0f601716c6713f8b521a65dba01303a7756f654d1a2c04bd47c0f2d1122155" } diff --git a/backend/.sqlx/query-411788111afccd826ce78b266153600939c65c75be8894322b90d9da18dcb824.json b/backend/.sqlx/query-11d89b437b9fe5d493e1806438dd56ef8c427aa1bbcfe2a11ba34cc8eab9fb4e.json similarity index 50% rename from backend/.sqlx/query-411788111afccd826ce78b266153600939c65c75be8894322b90d9da18dcb824.json rename to backend/.sqlx/query-11d89b437b9fe5d493e1806438dd56ef8c427aa1bbcfe2a11ba34cc8eab9fb4e.json index 56edad4b2c..df0c67b88e 100644 --- a/backend/.sqlx/query-411788111afccd826ce78b266153600939c65c75be8894322b90d9da18dcb824.json +++ b/backend/.sqlx/query-11d89b437b9fe5d493e1806438dd56ef8c427aa1bbcfe2a11ba34cc8eab9fb4e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE password SET devops = $1 WHERE email = $2", + "query": "UPDATE password SET super_admin = $1, role_source = 'manual' WHERE email = $2", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "411788111afccd826ce78b266153600939c65c75be8894322b90d9da18dcb824" + "hash": "11d89b437b9fe5d493e1806438dd56ef8c427aa1bbcfe2a11ba34cc8eab9fb4e" } diff --git a/backend/.sqlx/query-449934711f09b700fca46be3e165d37d14d51d5c95776e2d5491b5c5ab3e25b7.json b/backend/.sqlx/query-449934711f09b700fca46be3e165d37d14d51d5c95776e2d5491b5c5ab3e25b7.json new file mode 100644 index 0000000000..49c3763688 --- /dev/null +++ b/backend/.sqlx/query-449934711f09b700fca46be3e165d37d14d51d5c95776e2d5491b5c5ab3e25b7.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT ig.instance_role FROM email_to_igroup eig\n JOIN instance_group ig ON ig.name = eig.igroup\n WHERE eig.email = $1 AND ig.instance_role IS NOT NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "instance_role", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "449934711f09b700fca46be3e165d37d14d51d5c95776e2d5491b5c5ab3e25b7" +} diff --git a/backend/.sqlx/query-4afabac265755dd90c33193260eea8be1f62e4607fcd8f401701ab66f6d20cae.json b/backend/.sqlx/query-4afabac265755dd90c33193260eea8be1f62e4607fcd8f401701ab66f6d20cae.json new file mode 100644 index 0000000000..db78e7dc89 --- /dev/null +++ b/backend/.sqlx/query-4afabac265755dd90c33193260eea8be1f62e4607fcd8f401701ab66f6d20cae.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT super_admin, devops FROM password WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "super_admin", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "devops", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "4afabac265755dd90c33193260eea8be1f62e4607fcd8f401701ab66f6d20cae" +} diff --git a/backend/.sqlx/query-9229d9a9ff389cf26e480b604b83900e2d362ee934ef27284ef39f4eed440e59.json b/backend/.sqlx/query-60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce.json similarity index 81% rename from backend/.sqlx/query-9229d9a9ff389cf26e480b604b83900e2d362ee934ef27284ef39f4eed440e59.json rename to backend/.sqlx/query-60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce.json index 9eb47caf8a..6d2382f494 100644 --- a/backend/.sqlx/query-9229d9a9ff389cf26e480b604b83900e2d362ee934ef27284ef39f4eed440e59.json +++ b/backend/.sqlx/query-60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce.json @@ -1,6 +1,6 @@ { "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, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n ORDER BY super_admin DESC, devops DESC\n LIMIT $1 OFFSET $2", + "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, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n ORDER BY super_admin DESC, devops DESC\n LIMIT $1 OFFSET $2", "describe": { "columns": [ { @@ -52,6 +52,11 @@ "ordinal": 9, "name": "first_time_user", "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "role_source", + "type_info": "Varchar" } ], "parameters": { @@ -70,8 +75,9 @@ true, true, true, + false, false ] }, - "hash": "9229d9a9ff389cf26e480b604b83900e2d362ee934ef27284ef39f4eed440e59" + "hash": "60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce" } diff --git a/backend/.sqlx/query-37e23397905e25bbbf5a7047c790967a97cc8f6948beef706b0c053621882330.json b/backend/.sqlx/query-65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9.json similarity index 84% rename from backend/.sqlx/query-37e23397905e25bbbf5a7047c790967a97cc8f6948beef706b0c053621882330.json rename to backend/.sqlx/query-65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9.json index 9474727ace..2ccf7bfdc8 100644 --- a/backend/.sqlx/query-37e23397905e25bbbf5a7047c790967a97cc8f6948beef706b0c053621882330.json +++ b/backend/.sqlx/query-65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user FROM password WHERE email = $1", + "query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password WHERE email = $1", "describe": { "columns": [ { @@ -52,6 +52,11 @@ "ordinal": 9, "name": "first_time_user", "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "role_source", + "type_info": "Varchar" } ], "parameters": { @@ -69,8 +74,9 @@ true, true, null, + false, false ] }, - "hash": "37e23397905e25bbbf5a7047c790967a97cc8f6948beef706b0c053621882330" + "hash": "65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9" } diff --git a/backend/.sqlx/query-6a1cc753157c51481932ad8079f8e719ec455acd53e30984087d5c3a4a03cd25.json b/backend/.sqlx/query-6a1cc753157c51481932ad8079f8e719ec455acd53e30984087d5c3a4a03cd25.json new file mode 100644 index 0000000000..53999f934a --- /dev/null +++ b/backend/.sqlx/query-6a1cc753157c51481932ad8079f8e719ec455acd53e30984087d5c3a4a03cd25.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n ws.workspace_id,\n w.name as workspace_name,\n ws.auto_invite->'instance_groups_roles'->$1 as role\n FROM workspace_settings ws\n INNER JOIN workspace w ON w.id = ws.workspace_id AND w.deleted = false\n WHERE ws.auto_invite->'instance_groups' ? $1\n ORDER BY ws.workspace_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "workspace_name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "role", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + null + ] + }, + "hash": "6a1cc753157c51481932ad8079f8e719ec455acd53e30984087d5c3a4a03cd25" +} diff --git a/backend/.sqlx/query-7bdee54a36cc873c611f8a41c0470226ceb1b94a53dd540cdc27a0e1060aff12.json b/backend/.sqlx/query-7bdee54a36cc873c611f8a41c0470226ceb1b94a53dd540cdc27a0e1060aff12.json new file mode 100644 index 0000000000..90ee071042 --- /dev/null +++ b/backend/.sqlx/query-7bdee54a36cc873c611f8a41c0470226ceb1b94a53dd540cdc27a0e1060aff12.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT super_admin, devops, role_source FROM password WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "super_admin", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "devops", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "role_source", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "7bdee54a36cc873c611f8a41c0470226ceb1b94a53dd540cdc27a0e1060aff12" +} diff --git a/backend/.sqlx/query-234a278f20cb73f8ce10d2bfb67af58e5dd888581467c976e76f140b2c00f6d7.json b/backend/.sqlx/query-88b5d7e6806b1a6f4e6ef5af5fc3fa81e8134f25b1dfcedb765a701f0dae8564.json similarity index 63% rename from backend/.sqlx/query-234a278f20cb73f8ce10d2bfb67af58e5dd888581467c976e76f140b2c00f6d7.json rename to backend/.sqlx/query-88b5d7e6806b1a6f4e6ef5af5fc3fa81e8134f25b1dfcedb765a701f0dae8564.json index b62dba4bbe..84833c9718 100644 --- a/backend/.sqlx/query-234a278f20cb73f8ce10d2bfb67af58e5dd888581467c976e76f140b2c00f6d7.json +++ b/backend/.sqlx/query-88b5d7e6806b1a6f4e6ef5af5fc3fa81e8134f25b1dfcedb765a701f0dae8564.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO instance_group (name, summary, id, scim_display_name, external_id) VALUES ($1, $2, $3, $4, $5)", + "query": "INSERT INTO instance_group (name, summary, id, scim_display_name, external_id, instance_role) VALUES ($1, $2, $3, $4, $5, $6)", "describe": { "columns": [], "parameters": { @@ -9,10 +9,11 @@ "Varchar", "Varchar", "Varchar", + "Varchar", "Varchar" ] }, "nullable": [] }, - "hash": "234a278f20cb73f8ce10d2bfb67af58e5dd888581467c976e76f140b2c00f6d7" + "hash": "88b5d7e6806b1a6f4e6ef5af5fc3fa81e8134f25b1dfcedb765a701f0dae8564" } diff --git a/backend/.sqlx/query-9330c172624e5a4ac9d3b6c465d37dbe2e92de1b45dfc9f2656fea9d08b354a0.json b/backend/.sqlx/query-9330c172624e5a4ac9d3b6c465d37dbe2e92de1b45dfc9f2656fea9d08b354a0.json new file mode 100644 index 0000000000..c711fdfc59 --- /dev/null +++ b/backend/.sqlx/query-9330c172624e5a4ac9d3b6c465d37dbe2e92de1b45dfc9f2656fea9d08b354a0.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE password SET super_admin = $1, devops = $2, role_source = 'instance_group' WHERE email = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bool", + "Bool", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9330c172624e5a4ac9d3b6c465d37dbe2e92de1b45dfc9f2656fea9d08b354a0" +} diff --git a/backend/.sqlx/query-6c7186de56bcd9983a64de0c01a733e818ebc30af2377158c8a92ec66c06464c.json b/backend/.sqlx/query-96ee5b8253ee54bcea68f0b5256a973bd9d2d7818320ae4d6c02d68a29298e63.json similarity index 51% rename from backend/.sqlx/query-6c7186de56bcd9983a64de0c01a733e818ebc30af2377158c8a92ec66c06464c.json rename to backend/.sqlx/query-96ee5b8253ee54bcea68f0b5256a973bd9d2d7818320ae4d6c02d68a29298e63.json index a9df312f5e..f03e49e330 100644 --- a/backend/.sqlx/query-6c7186de56bcd9983a64de0c01a733e818ebc30af2377158c8a92ec66c06464c.json +++ b/backend/.sqlx/query-96ee5b8253ee54bcea68f0b5256a973bd9d2d7818320ae4d6c02d68a29298e63.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE password SET super_admin = $1 WHERE email = $2", + "query": "UPDATE password SET devops = $1, role_source = 'manual' WHERE email = $2", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "6c7186de56bcd9983a64de0c01a733e818ebc30af2377158c8a92ec66c06464c" + "hash": "96ee5b8253ee54bcea68f0b5256a973bd9d2d7818320ae4d6c02d68a29298e63" } diff --git a/backend/.sqlx/query-98c512c011d176366f3c4a0633bf60634f06fa6a2b5c65f654f4da87e3724c25.json b/backend/.sqlx/query-98c512c011d176366f3c4a0633bf60634f06fa6a2b5c65f654f4da87e3724c25.json new file mode 100644 index 0000000000..519fb4f367 --- /dev/null +++ b/backend/.sqlx/query-98c512c011d176366f3c4a0633bf60634f06fa6a2b5c65f654f4da87e3724c25.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email FROM password\n WHERE role_source = 'instance_group' AND (super_admin = true OR devops = true)\n AND email NOT IN (\n SELECT eig.email FROM email_to_igroup eig\n JOIN instance_group ig ON ig.name = eig.igroup\n WHERE ig.instance_role IS NOT NULL\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "98c512c011d176366f3c4a0633bf60634f06fa6a2b5c65f654f4da87e3724c25" +} diff --git a/backend/.sqlx/query-ef0f4447498a117e4495ed9335d803403ad0055efb6da4bdd467b4ac9bf4e478.json b/backend/.sqlx/query-9dbd6cbae01ae05f4b56a8e3ad563e26ec51302e8916e3b186123c024546ff4a.json similarity index 58% rename from backend/.sqlx/query-ef0f4447498a117e4495ed9335d803403ad0055efb6da4bdd467b4ac9bf4e478.json rename to backend/.sqlx/query-9dbd6cbae01ae05f4b56a8e3ad563e26ec51302e8916e3b186123c024546ff4a.json index 8e10da3f7b..eb34d76f61 100644 --- a/backend/.sqlx/query-ef0f4447498a117e4495ed9335d803403ad0055efb6da4bdd467b4ac9bf4e478.json +++ b/backend/.sqlx/query-9dbd6cbae01ae05f4b56a8e3ad563e26ec51302e8916e3b186123c024546ff4a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup WHERE name = $1 GROUP BY name", + "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup WHERE name = $1 GROUP BY name, instance_role", "describe": { "columns": [ { @@ -17,6 +17,11 @@ "ordinal": 2, "name": "emails", "type_info": "VarcharArray" + }, + { + "ordinal": 3, + "name": "instance_role", + "type_info": "Varchar" } ], "parameters": { @@ -27,8 +32,9 @@ "nullable": [ false, true, - null + null, + true ] }, - "hash": "ef0f4447498a117e4495ed9335d803403ad0055efb6da4bdd467b4ac9bf4e478" + "hash": "9dbd6cbae01ae05f4b56a8e3ad563e26ec51302e8916e3b186123c024546ff4a" } diff --git a/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json b/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json index 54e94cfb8f..99269c9851 100644 --- a/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json +++ b/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json @@ -18,8 +18,8 @@ "Left": [] }, "nullable": [ - false, - true + true, + false ] }, "hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76" diff --git a/backend/.sqlx/query-e8717d0197b51a0837f85a2615d1d4a0dc97741055d83d4102395f1a05824d6b.json b/backend/.sqlx/query-e8717d0197b51a0837f85a2615d1d4a0dc97741055d83d4102395f1a05824d6b.json new file mode 100644 index 0000000000..7bf373db2d --- /dev/null +++ b/backend/.sqlx/query-e8717d0197b51a0837f85a2615d1d4a0dc97741055d83d4102395f1a05824d6b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT instance_role FROM instance_group WHERE name = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "instance_role", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "e8717d0197b51a0837f85a2615d1d4a0dc97741055d83d4102395f1a05824d6b" +} diff --git a/backend/.sqlx/query-e959176da22a76c43c63c26a993120eb8e28c823f9e04fb54b7a545a353495c1.json b/backend/.sqlx/query-e959176da22a76c43c63c26a993120eb8e28c823f9e04fb54b7a545a353495c1.json new file mode 100644 index 0000000000..a73a1a8444 --- /dev/null +++ b/backend/.sqlx/query-e959176da22a76c43c63c26a993120eb8e28c823f9e04fb54b7a545a353495c1.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT ig.instance_role FROM email_to_igroup eig\n JOIN instance_group ig ON ig.name = eig.igroup\n WHERE eig.email = $1 AND ig.instance_role IS NOT NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "instance_role", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "e959176da22a76c43c63c26a993120eb8e28c823f9e04fb54b7a545a353495c1" +} diff --git a/backend/.sqlx/query-9cd6e964ba933de247ba7ddce0acef0b70b784c2410f3a5f51288aaea6904b8f.json b/backend/.sqlx/query-fd61a081912cd70edbee757891319ee04f062c989bd1c7abfc0098b408a216cc.json similarity index 74% rename from backend/.sqlx/query-9cd6e964ba933de247ba7ddce0acef0b70b784c2410f3a5f51288aaea6904b8f.json rename to backend/.sqlx/query-fd61a081912cd70edbee757891319ee04f062c989bd1c7abfc0098b408a216cc.json index ed63942f01..eee7213e44 100644 --- a/backend/.sqlx/query-9cd6e964ba933de247ba7ddce0acef0b70b784c2410f3a5f51288aaea6904b8f.json +++ b/backend/.sqlx/query-fd61a081912cd70edbee757891319ee04f062c989bd1c7abfc0098b408a216cc.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, id, scim_display_name, external_id FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name", + "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, id, scim_display_name, external_id, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name", "describe": { "columns": [ { @@ -32,6 +32,11 @@ "ordinal": 5, "name": "external_id", "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "instance_role", + "type_info": "Varchar" } ], "parameters": { @@ -43,8 +48,9 @@ null, true, true, + true, true ] }, - "hash": "9cd6e964ba933de247ba7ddce0acef0b70b784c2410f3a5f51288aaea6904b8f" + "hash": "fd61a081912cd70edbee757891319ee04f062c989bd1c7abfc0098b408a216cc" } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 4615ab8810..648a079815 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -2d0823a471014e2bc2d898c63518323946b7474f +278a3887f759f9d1146554baa0765518d5bc70f2 diff --git a/backend/migrations/20260316000000_instance_group_role.down.sql b/backend/migrations/20260316000000_instance_group_role.down.sql new file mode 100644 index 0000000000..80af3b5006 --- /dev/null +++ b/backend/migrations/20260316000000_instance_group_role.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE instance_group DROP COLUMN instance_role; +ALTER TABLE password DROP COLUMN role_source; diff --git a/backend/migrations/20260316000000_instance_group_role.up.sql b/backend/migrations/20260316000000_instance_group_role.up.sql new file mode 100644 index 0000000000..ef1c8ee3db --- /dev/null +++ b/backend/migrations/20260316000000_instance_group_role.up.sql @@ -0,0 +1,9 @@ +-- instance_group: add instance-level role (NULL = none, 'devops', 'superadmin') +ALTER TABLE instance_group ADD COLUMN instance_role VARCHAR(20) DEFAULT NULL; +ALTER TABLE instance_group ADD CONSTRAINT check_instance_role + CHECK (instance_role IN ('devops', 'superadmin')); + +-- password: track whether elevated role was set manually or by instance group +ALTER TABLE password ADD COLUMN role_source VARCHAR(20) NOT NULL DEFAULT 'manual'; +ALTER TABLE password ADD CONSTRAINT check_role_source + CHECK (role_source IN ('manual', 'instance_group')); diff --git a/backend/windmill-api-groups/src/groups.rs b/backend/windmill-api-groups/src/groups.rs index a68b72072d..4b07ae7a4d 100644 --- a/backend/windmill-api-groups/src/groups.rs +++ b/backend/windmill-api-groups/src/groups.rs @@ -328,9 +328,133 @@ async fn create_igroup( Ok(format!("Created group {}", normalized_name)) } +fn validate_instance_role(role: &Option) -> Result> { + match role.as_deref() { + None => Ok(None), + Some("") | Some("user") => Ok(None), + Some("devops") => Ok(Some("devops".to_string())), + Some("superadmin") => Ok(Some("superadmin".to_string())), + Some(other) => Err(Error::BadRequest(format!( + "Invalid instance_role '{}'. Must be 'devops', 'superadmin', 'user', or empty to clear", + other + ))), + } +} + +/// Compute the highest-precedence instance role from all groups a user belongs to. +/// superadmin > devops > none +pub async fn compute_effective_instance_role( + email: &str, + tx: &mut Transaction<'_, Postgres>, +) -> Result> { + let roles = sqlx::query_scalar!( + "SELECT ig.instance_role FROM email_to_igroup eig + JOIN instance_group ig ON ig.name = eig.igroup + WHERE eig.email = $1 AND ig.instance_role IS NOT NULL", + email + ) + .fetch_all(&mut **tx) + .await?; + + let mut highest: Option = None; + for role in roles.into_iter().flatten() { + match role.as_str() { + "superadmin" => return Ok(Some("superadmin".to_string())), + "devops" => highest = Some("devops".to_string()), + _ => {} + } + } + Ok(highest) +} + +/// Apply computed instance role to password table and invalidate session tokens. +/// Only applies if role_source = 'instance_group' or user has no elevated role. +pub async fn apply_instance_role( + email: &str, + role: Option<&str>, + tx: &mut Transaction<'_, Postgres>, +) -> Result<()> { + let current = sqlx::query!( + "SELECT super_admin, devops, role_source FROM password WHERE email = $1", + email + ) + .fetch_optional(&mut **tx) + .await?; + + let current = match current { + Some(c) => c, + None => return Ok(()), // user doesn't exist in password table + }; + + // Don't touch manually-set elevated roles — manual always wins + if current.role_source == "manual" && (current.super_admin || current.devops) { + return Ok(()); + } + + let (new_super_admin, new_devops) = match role { + Some("superadmin") => (true, false), + Some("devops") => (false, true), + _ => (false, false), + }; + + // Only update if something actually changed + if current.super_admin == new_super_admin && current.devops == new_devops { + return Ok(()); + } + + sqlx::query!( + "UPDATE password SET super_admin = $1, devops = $2, role_source = 'instance_group' WHERE email = $3", + new_super_admin, + new_devops, + email + ) + .execute(&mut **tx) + .await?; + + // Invalidate session tokens to force re-login with new privileges + sqlx::query!( + "DELETE FROM token WHERE email = $1 AND label = 'session'", + email + ) + .execute(&mut **tx) + .await?; + + // Update super_admin flag on non-session tokens + sqlx::query!( + "UPDATE token SET super_admin = $1 WHERE email = $2 AND label != 'session'", + new_super_admin, + email + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +/// Recompute and apply roles for all members of a given instance group. +pub async fn propagate_instance_group_roles( + group_name: &str, + tx: &mut Transaction<'_, Postgres>, +) -> Result<()> { + let members = sqlx::query_scalar!( + "SELECT email FROM email_to_igroup WHERE igroup = $1", + group_name + ) + .fetch_all(&mut **tx) + .await?; + + for email in members { + let effective_role = compute_effective_instance_role(&email, tx).await?; + apply_instance_role(&email, effective_role.as_deref(), tx).await?; + } + + Ok(()) +} + #[derive(Deserialize)] struct IGroupUpdate { new_summary: String, + instance_role: Option, } async fn update_igroup( @@ -348,17 +472,37 @@ async fn update_igroup( .await?; not_found_if_none(exists_opt, "instance_group", name.clone())?; - sqlx::query("UPDATE instance_group SET summary = $1 WHERE name = $2") + let validated_role = validate_instance_role(&igroup_update.instance_role)?; + + // Fetch old role before updating so we can detect changes + let old_role = if igroup_update.instance_role.is_some() { + sqlx::query_scalar!( + "SELECT instance_role FROM instance_group WHERE name = $1", + &name + ) + .fetch_one(&mut *tx) + .await? + } else { + None + }; + + sqlx::query("UPDATE instance_group SET summary = $1, instance_role = $2 WHERE name = $3") .bind(igroup_update.new_summary) + .bind(&validated_role) .bind(&name) .execute(&mut *tx) .await?; + // If instance_role actually changed, propagate to all group members + if igroup_update.instance_role.is_some() && old_role != validated_role { + propagate_instance_group_roles(&name, &mut tx).await?; + } + audit_log( &mut *tx, &authed, "igroup.updated", - ActionKind::Delete, + ActionKind::Update, "global", Some(&name.to_string()), None, @@ -366,7 +510,7 @@ async fn update_igroup( .await?; tx.commit().await?; - Ok(format!("Deleted group {}", name)) + Ok(format!("Updated group {}", name)) } async fn delete_igroup( @@ -376,14 +520,38 @@ async fn delete_igroup( ) -> Result { require_super_admin(&db, &authed.email).await?; let mut tx: Transaction<'_, Postgres> = db.begin().await?; - sqlx::query!("DELETE FROM instance_group WHERE name = $1", name) - .execute(&mut *tx) - .await?; + + // Fetch group's instance_role and members before deletion + let group_role = sqlx::query_scalar!( + "SELECT instance_role FROM instance_group WHERE name = $1", + &name + ) + .fetch_optional(&mut *tx) + .await? + .flatten(); + + let affected_members: Vec = if group_role.is_some() { + sqlx::query_scalar!("SELECT email FROM email_to_igroup WHERE igroup = $1", &name) + .fetch_all(&mut *tx) + .await? + } else { + vec![] + }; sqlx::query!("DELETE FROM email_to_igroup WHERE igroup = $1", name) .execute(&mut *tx) .await?; + sqlx::query!("DELETE FROM instance_group WHERE name = $1", name) + .execute(&mut *tx) + .await?; + + // Recompute roles for affected members after deletion + for email in &affected_members { + let effective_role = compute_effective_instance_role(email, &mut tx).await?; + apply_instance_role(email, effective_role.as_deref(), &mut tx).await?; + } + audit_log( &mut *tx, &authed, @@ -723,6 +891,10 @@ async fn add_user_igroup( } } + // Apply instance-level role from group membership + let effective_role = compute_effective_instance_role(&email, &mut tx).await?; + apply_instance_role(&email, effective_role.as_deref(), &mut tx).await?; + tx.commit().await?; Ok(format!("Added {} to igroup {}", email, name)) } @@ -732,6 +904,7 @@ struct IGroup { name: String, summary: Option, emails: Option>, + instance_role: Option, } #[derive(Serialize)] @@ -739,6 +912,7 @@ struct IGroupWithWorkspaces { name: String, summary: Option, emails: Option>, + instance_role: Option, workspaces: Vec, } @@ -753,7 +927,7 @@ async fn list_igroups(Extension(db): Extension) -> JsonResult> { let groups = sqlx::query_as!( IGroup, - "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name" + "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, instance_role" ) .fetch_all(&mut *tx) .await?; @@ -770,7 +944,7 @@ async fn list_igroups_with_workspaces( // Get all instance groups with their emails first let groups = sqlx::query_as!( IGroup, - "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, summary" + "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, summary, instance_role" ) .fetch_all(&mut *tx) .await?; @@ -825,6 +999,7 @@ async fn list_igroups_with_workspaces( name: group.name, summary: group.summary, emails: group.emails, + instance_role: group.instance_role, workspaces, }); } @@ -833,16 +1008,54 @@ async fn list_igroups_with_workspaces( return Ok(Json(result)); } -async fn get_igroup(Path(name): Path, Extension(db): Extension) -> JsonResult { +async fn get_igroup( + Path(name): Path, + Extension(db): Extension, +) -> JsonResult { let group = sqlx::query_as!( IGroup, - "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup WHERE name = $1 GROUP BY name", + "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup WHERE name = $1 GROUP BY name, instance_role", name ) .fetch_optional(&db) .await?; let group = not_found_if_none(group, "IGroup", &name)?; - return Ok(Json(group)); + + let workspace_mappings = sqlx::query!( + r#" + SELECT + ws.workspace_id, + w.name as workspace_name, + ws.auto_invite->'instance_groups_roles'->$1 as role + FROM workspace_settings ws + INNER JOIN workspace w ON w.id = ws.workspace_id AND w.deleted = false + WHERE ws.auto_invite->'instance_groups' ? $1 + ORDER BY ws.workspace_id + "#, + &name + ) + .fetch_all(&db) + .await?; + + let workspaces: Vec = workspace_mappings + .into_iter() + .map(|m| WorkspaceInfo { + workspace_id: m.workspace_id, + workspace_name: m.workspace_name, + role: m + .role + .and_then(|r| r.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| "developer".to_string()), + }) + .collect(); + + return Ok(Json(IGroupWithWorkspaces { + name: group.name, + summary: group.summary, + emails: group.emails, + instance_role: group.instance_role, + workspaces, + })); } async fn remove_user_igroup( @@ -886,6 +1099,10 @@ async fn remove_user_igroup( remove_users_from_instance_group_workspaces(&email, &name, &mut tx).await?; } + // Recompute instance-level role after group removal + let effective_role = compute_effective_instance_role(&email, &mut tx).await?; + apply_instance_role(&email, effective_role.as_deref(), &mut tx).await?; + tx.commit().await?; Ok(format!("Removed {} from igroup {}", email, name)) } @@ -967,6 +1184,8 @@ struct ExportedIGroup { external_id: Option, #[serde(skip_serializing_if = "Option::is_none")] emails: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + instance_role: Option, } #[cfg(feature = "enterprise")] @@ -978,7 +1197,7 @@ async fn export_igroups( let mut tx = db.begin().await?; let igroups = sqlx::query_as!( ExportedIGroup, - "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, id, scim_display_name, external_id FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name", + "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, id, scim_display_name, external_id, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name", ).fetch_all(&mut *tx).await?; audit_log( @@ -1022,13 +1241,15 @@ async fn overwrite_igroups( .await?; for igroup in igroups.iter() { + let validated_role = validate_instance_role(&igroup.instance_role)?; sqlx::query!( - "INSERT INTO instance_group (name, summary, id, scim_display_name, external_id) VALUES ($1, $2, $3, $4, $5)", + "INSERT INTO instance_group (name, summary, id, scim_display_name, external_id, instance_role) VALUES ($1, $2, $3, $4, $5, $6)", igroup.name, igroup.summary, igroup.id, igroup.scim_display_name, igroup.external_id, + validated_role, ) .execute(&mut *tx) .await?; @@ -1046,6 +1267,31 @@ async fn overwrite_igroups( } } + // Propagate instance roles for all groups that have one + for igroup in igroups.iter() { + if igroup.instance_role.is_some() { + propagate_instance_group_roles(&igroup.name, &mut tx).await?; + } + } + + // Demote orphaned users: those whose role was set by a group that no longer + // grants them any instance_role after the import + let orphaned_users = sqlx::query_scalar!( + "SELECT email FROM password + WHERE role_source = 'instance_group' AND (super_admin = true OR devops = true) + AND email NOT IN ( + SELECT eig.email FROM email_to_igroup eig + JOIN instance_group ig ON ig.name = eig.igroup + WHERE ig.instance_role IS NOT NULL + )" + ) + .fetch_all(&mut *tx) + .await?; + + for email in &orphaned_users { + apply_instance_role(email, None, &mut tx).await?; + } + audit_log( &mut *tx, &authed, diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index b911739eaa..8f7bec1398 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -156,6 +156,7 @@ pub struct GlobalUserInfo { #[serde(skip_serializing_if = "Option::is_none")] operator_only: Option, first_time_user: bool, + role_source: String, } #[derive(Serialize, Debug)] @@ -395,7 +396,7 @@ async fn list_users_as_super_admin( GlobalUserInfo, "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, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user + SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source FROM password WHERE email IN (SELECT email FROM active_users) ORDER BY super_admin DESC, devops DESC @@ -408,7 +409,7 @@ async fn list_users_as_super_admin( } else { sqlx::query_as!( GlobalUserInfo, - "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT \ + "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT \ $1 OFFSET $2", per_page as i32, offset as i32 @@ -624,7 +625,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 FROM password WHERE \ + "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password WHERE \ email = $1", email ) @@ -646,6 +647,7 @@ async fn global_whoami( username: None, operator_only: None, first_time_user: false, + role_source: "manual".to_string(), })) } else { Err(user.unwrap_err()) @@ -1281,7 +1283,7 @@ async fn update_user( let mut new_super_admin: Option = None; if let Some(sa) = eu.is_super_admin { sqlx::query_scalar!( - "UPDATE password SET super_admin = $1 WHERE email = $2", + "UPDATE password SET super_admin = $1, role_source = 'manual' WHERE email = $2", sa, &email_to_update ) @@ -1292,7 +1294,7 @@ async fn update_user( if let Some(dv) = eu.is_devops { sqlx::query_scalar!( - "UPDATE password SET devops = $1 WHERE email = $2", + "UPDATE password SET devops = $1, role_source = 'manual' WHERE email = $2", dv, &email_to_update ) @@ -1327,6 +1329,74 @@ async fn update_user( .await?; } + // If the result is "user" (no elevation), recompute from instance groups. + // Setting to "user" means "clear manual override, fall back to group role". + // Manual elevated roles (devops/superadmin) are never overridden by groups. + if eu.is_super_admin.is_some() || eu.is_devops.is_some() { + let current = sqlx::query!( + "SELECT super_admin, devops FROM password WHERE email = $1", + &email_to_update + ) + .fetch_optional(&mut *tx) + .await?; + + if let Some(c) = current { + if !c.super_admin && !c.devops { + // Compute effective role from all instance groups + let roles = sqlx::query_scalar!( + "SELECT ig.instance_role FROM email_to_igroup eig + JOIN instance_group ig ON ig.name = eig.igroup + WHERE eig.email = $1 AND ig.instance_role IS NOT NULL", + &email_to_update + ) + .fetch_all(&mut *tx) + .await?; + + let mut effective: Option<&str> = None; + for role in roles.iter().flatten() { + match role.as_str() { + "superadmin" => { + effective = Some("superadmin"); + break; + } + "devops" if effective.is_none() => { + effective = Some("devops"); + } + _ => {} + } + } + + if let Some(role) = effective { + let (sa, dv) = match role { + "superadmin" => (true, false), + _ => (false, true), + }; + sqlx::query!( + "UPDATE password SET super_admin = $1, devops = $2, role_source = 'instance_group' WHERE email = $3", + sa, dv, &email_to_update + ) + .execute(&mut *tx) + .await?; + + // Re-invalidate tokens with the group role + sqlx::query!( + "DELETE FROM token WHERE email = $1 AND label = 'session'", + &email_to_update + ) + .execute(&mut *tx) + .await?; + sqlx::query!( + "UPDATE token SET super_admin = $1 WHERE email = $2 AND label != 'session'", + sa, + &email_to_update + ) + .execute(&mut *tx) + .await?; + } + } + } + } + if let Some(n) = eu.name { sqlx::query_scalar!( "UPDATE password SET name = $1 WHERE email = $2", diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 7158f315ec..6a3532142c 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -14164,7 +14164,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/InstanceGroup" + $ref: "#/components/schemas/InstanceGroupWithWorkspaces" /groups/create: post: @@ -14212,6 +14212,10 @@ paths: properties: new_summary: type: string + instance_role: + type: string + nullable: true + description: "Instance-level role for group members. 'superadmin', 'devops', 'user' or empty to clear." required: - new_summary responses: @@ -22629,6 +22633,12 @@ components: type: array items: type: string + instance_role: + type: string + nullable: true + enum: + - superadmin + - devops InstanceGroupWithWorkspaces: type: object @@ -22643,6 +22653,12 @@ components: type: array items: type: string + instance_role: + type: string + nullable: true + enum: + - superadmin + - devops workspaces: type: array items: @@ -22926,6 +22942,9 @@ components: type: boolean first_time_user: type: boolean + role_source: + type: string + enum: ["manual", "instance_group"] required: - email @@ -22933,6 +22952,7 @@ components: - super_admin - verified - first_time_user + - role_source Flow: allOf: @@ -23802,6 +23822,12 @@ components: type: string external_id: type: string + instance_role: + type: string + nullable: true + enum: + - superadmin + - devops required: - name diff --git a/frontend/src/lib/components/InstanceGroupEditor.svelte b/frontend/src/lib/components/InstanceGroupEditor.svelte index f3d8df33de..ffbd305462 100644 --- a/frontend/src/lib/components/InstanceGroupEditor.svelte +++ b/frontend/src/lib/components/InstanceGroupEditor.svelte @@ -1,7 +1,5 @@
@@ -56,7 +56,10 @@ on:click={async () => { await GroupService.updateInstanceGroup({ name, - requestBody: { new_summary: instance_group?.summary ?? '' } + requestBody: { + new_summary: instance_group?.summary ?? '', + instance_role: instance_group?.instance_role ?? 'user' + } }) dispatch('update') sendUserToast('New summary saved') @@ -64,6 +67,72 @@ >
+ + {#if $superadmin} +
+

Instance Role

+

Assign an instance-level role to all members of this group. Superadmin grants full admin + access, devops grants read-only admin visibility.

+ { + let role = e.detail + await GroupService.updateInstanceGroup({ + name, + requestBody: { + new_summary: instance_group?.summary ?? '', + instance_role: role + } + }) + if (instance_group) { + instance_group.instance_role = + role === 'user' ? undefined : (role as 'superadmin' | 'devops') + } + dispatch('update') + sendUserToast('Instance role updated') + }} + > + {#snippet children({ item })} + + + + {/snippet} + +
+ {/if} + + {#if instance_group.workspaces && instance_group.workspaces.length > 0} +
+

Workspace Membership

+ + {#snippet headerRow()} + + Workspace + Role + + {/snippet} + {#snippet body()} + + {#each instance_group?.workspaces ?? [] as ws (ws.workspace_id)} + + {ws.workspace_name ?? ws.workspace_id} + {ws.role} + + {/each} + + {/snippet} + +
+ {/if} +

Members ({#if members?.length != undefined}{members?.length ?? 0}{:else} {#snippet headerRow()} - + user - {/snippet} + {/snippet} {#snippet body()} - - {#each members as { member_email }} + + {#each members as { member_email } (member_email)} + {member_email} remove - {/each} + + {/each} - {/snippet} + {/snippet} {:else}
- {#each new Array(6) as _} + {#each new Array(6) as _, i (i)} {/each}
diff --git a/frontend/src/lib/components/SuperadminSettingsInner.svelte b/frontend/src/lib/components/SuperadminSettingsInner.svelte index 37fca0037b..964414cafa 100644 --- a/frontend/src/lib/components/SuperadminSettingsInner.svelte +++ b/frontend/src/lib/components/SuperadminSettingsInner.svelte @@ -343,7 +343,7 @@ {#if filteredUsers && users} - {#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only }, i (email)} + {#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, role_source }, i (email)} {/if} - { - if (email == $userStore?.email) { - sendUserToast('You cannot demote yourself', true) - listUsers(activeOnly) - return - } +
diff --git a/frontend/src/routes/(root)/(logged)/groups/+page.svelte b/frontend/src/routes/(root)/(logged)/groups/+page.svelte index 69b5b83c13..0668c4c307 100644 --- a/frontend/src/routes/(root)/(logged)/groups/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/groups/+page.svelte @@ -8,6 +8,7 @@ import Popover from '$lib/components/meltComponents/Popover.svelte' import Dropdown from '$lib/components/DropdownV2.svelte' import GroupEditor from '$lib/components/GroupEditor.svelte' + import InstanceGroupEditor from '$lib/components/InstanceGroupEditor.svelte' import GroupInfo from '$lib/components/GroupInfo.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' @@ -69,6 +70,8 @@ }) let editGroupName: string = $state('') + let instanceGroupDrawer: Drawer | undefined = $state() + let editInstanceGroupName: string = $state('') @@ -77,6 +80,15 @@ + + + + + + {#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.groups} {/if} + {#if node.workflow_as_code_status} +
+
Workflow timeline
+ +
+ {/if}
| null ): Promise { return abstractRun( () => @@ -310,7 +311,8 @@ tag, lock, script_hash: hash, - flow_path: flowPath + flow_path: flowPath, + modules: modules ?? undefined } }), callbacks diff --git a/frontend/src/lib/components/NoMainFuncBadge.svelte b/frontend/src/lib/components/NoMainFuncBadge.svelte index 3e2a2d28bb..c72e05583c 100644 --- a/frontend/src/lib/components/NoMainFuncBadge.svelte +++ b/frontend/src/lib/components/NoMainFuncBadge.svelte @@ -5,7 +5,7 @@ {#snippet text()} - The script has no main function exported + Library script (no exported main function) {/snippet} - No main + Library diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 7de0926f0a..eb8709751c 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -107,6 +107,22 @@ import { isRuleActive } from '$lib/workspaceProtectionRules.svelte' 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(), @@ -182,6 +198,7 @@ let editor: Editor | undefined = $state(undefined) let scriptEditor: ScriptEditor | undefined = $state(undefined) let captureTable: CaptureTable | undefined = $state(undefined) + let wacExportDrawer: WacExportDrawer | undefined = $state(undefined) // Draft triggers confirmation modal let draftTriggersModalOpen = $state(false) @@ -362,6 +379,13 @@ } if (script.content == '') { + if (template === 'wac_python') { + script.modules = { 'helper.py': { content: 'def main(a: str) -> str:\n return f"hello {a}"\n', language: 'python3' } } + showWacAlphaModalIfNeeded() + } else if (template === 'wac_typescript') { + script.modules = { 'helper.ts': { content: 'export function main(a: string): string {\n return `hello ${a}`\n}\n', language: 'bun' } } + showWacAlphaModalIfNeeded() + } initContent(script.language, script.kind, template) } @@ -388,7 +412,7 @@ async function initContent( language: SupportedLanguage, kind: Script['kind'] | undefined, - template: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative' | 'claudesandbox' + template: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative' | 'claudesandbox' | 'wac_python' | 'wac_typescript' ) { scriptEditor?.disableCollaboration() const templateScript = await isTemplateScript() @@ -403,6 +427,7 @@ } async function handleEditScript(stay: boolean, deployMsg?: string): Promise { + scriptEditor?.flushModuleState() // Fetch latest version and fetch entire script after if needed let actual_parent_hash: string | undefined = undefined @@ -510,10 +535,10 @@ script.kind === 'preprocessor' ? 'preprocessor' : undefined ) if (script.kind === 'preprocessor') { - script.no_main_func = undefined + script.auto_kind = undefined script.has_preprocessor = undefined } else { - script.no_main_func = result?.no_main_func || undefined + script.auto_kind = result?.auto_kind || undefined script.has_preprocessor = result?.has_preprocessor || undefined } } catch (error) { @@ -554,12 +579,13 @@ timeout: script.timeout, concurrency_key: emptyString(script.concurrency_key) ? undefined : script.concurrency_key, visible_to_runner_only: script.visible_to_runner_only, - no_main_func: script.no_main_func, + auto_kind: script.auto_kind, has_preprocessor: script.has_preprocessor, deployment_message: deploymentMsg || undefined, on_behalf_of_email: script.on_behalf_of_email, preserve_on_behalf_of: preserveOnBehalfOf || undefined, - assets: script.assets + assets: script.assets, + modules: script.modules } }) @@ -592,7 +618,7 @@ if (!disableHistoryChange) { history.replaceState(history.state, '', `/scripts/edit/${script.path}`) } - if (stay || (script.no_main_func && script.kind !== 'preprocessor' && !isWorkflowAsCode(script.content, script.language))) { + if (stay || (script.auto_kind === 'lib' && script.kind !== 'preprocessor' && !isWorkflowAsCode(script.content, script.language))) { script.parent_hash = newHash sendUserToast('Deployed') } else { @@ -606,6 +632,7 @@ } async function saveDraft(forceSave = false): Promise { + scriptEditor?.flushModuleState() if (initialPath != '' && !savedScript) { return } @@ -643,10 +670,10 @@ script.kind === 'preprocessor' ? 'preprocessor' : undefined ) if (script.kind === 'preprocessor') { - script.no_main_func = undefined + script.auto_kind = undefined script.has_preprocessor = undefined } else { - script.no_main_func = result?.no_main_func || undefined + script.auto_kind = result?.auto_kind || undefined script.has_preprocessor = result?.has_preprocessor || undefined } } catch (error) { @@ -707,10 +734,11 @@ ? undefined : script.concurrency_key, visible_to_runner_only: script.visible_to_runner_only, - no_main_func: script.no_main_func, + auto_kind: script.auto_kind, has_preprocessor: script.has_preprocessor, on_behalf_of_email: script.on_behalf_of_email, - assets: script.assets + assets: script.assets, + modules: script.modules } }) } @@ -816,7 +844,7 @@ } ] : []), - ...(!script.draft_only && script.kind === 'script' && !script.no_main_func + ...(!script.draft_only && script.kind === 'script' && !script.auto_kind ? [ { label: 'Exit & See details', @@ -825,10 +853,34 @@ } } ] + : []), + ...(isWorkflowAsCode(script.content, script.language) + ? [ + { + label: 'Export as YAML/JSON', + onClick: () => { + wacExportDrawer?.open(script) + } + } + ] : []) ] : [] + if ( + dropdownItems.length === 0 && + isWorkflowAsCode(script.content, script.language) + ) { + dropdownItems = [ + { + label: 'Export as YAML/JSON', + onClick: () => { + wacExportDrawer?.open(script) + } + } + ] + } + return dropdownItems.length > 0 ? dropdownItems : undefined } @@ -1201,7 +1253,7 @@

{/if} -
+
Template + + + + + +
{#if customUi?.settingsPanel?.metadata?.disableScriptKind !== true}
@@ -1948,9 +2040,23 @@ bind:hasPreprocessor bind:captureTable bind:assets={script.assets} + bind:modules={script.modules} enablePreprocessorSnippet />
{:else} Script Builder not available to operators {/if} + + + + +
+ diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index f310010c18..600dfde5af 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -2,7 +2,14 @@ import { BROWSER } from 'esm-env' import type { Schema, SupportedLanguage } from '$lib/common' - import { type CompletedJob, type Job, JobService, type Preview, type ScriptLang } from '$lib/gen' + import { + type CompletedJob, + type Job, + JobService, + type Preview, + type ScriptLang, + type ScriptModule + } from '$lib/gen' import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores' import { copyToClipboard, @@ -25,8 +32,10 @@ import WindmillIcon from './icons/WindmillIcon.svelte' import * as Y from 'yjs' import { scriptLangToEditorLang } from '$lib/scripts' + import { langToExt } from '$lib/editorLangUtils' import { WebsocketProvider } from 'y-websocket' import Modal from './common/modal/Modal.svelte' + import Popover from './meltComponents/Popover.svelte' import DiffEditor from './DiffEditor.svelte' import { AlertTriangle, @@ -40,8 +49,11 @@ GitBranch, Play, PlayIcon, + Plus, Terminal, - WandSparkles + Pencil, + WandSparkles, + X } from 'lucide-svelte' import { DebugToolbar, @@ -100,7 +112,16 @@ path: string | undefined lang: Preview['language'] kind?: string | undefined - template?: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative' | 'claudesandbox' + template?: + | 'pgsql' + | 'mysql' + | 'script' + | 'docker' + | 'powershell' + | 'bunnative' + | 'claudesandbox' + | 'wac_python' + | 'wac_typescript' tag: string | undefined initialArgs?: Record fixedOverflowWidgets?: boolean @@ -123,6 +144,7 @@ lastDeployedCode?: string | undefined disableAi?: boolean assets?: AssetWithAltAccessType[] + modules?: { [key: string]: ScriptModule } | null editorBarRight?: import('svelte').Snippet enablePreprocessorSnippet?: boolean } @@ -155,6 +177,7 @@ lastDeployedCode = undefined, disableAi = false, assets = $bindable(), + modules = $bindable(undefined), editorBarRight, enablePreprocessorSnippet = false }: Props = $props() @@ -163,6 +186,267 @@ let jsonView = $state(false) let schemaHeight = $state(0) + // Module tab state + let activeModuleTab: string | null = $state(null) + // editorCode is what the editor shows; code always holds the main script content + let editorCode: string = $state(code) + // Sync editorCode when code changes externally (template reset, copilot, etc.) + let lastSyncedCode = code + $effect.pre(() => { + if (activeModuleTab === null && code !== lastSyncedCode) { + editorCode = code + lastSyncedCode = code + } + }) + + function switchToModule(modulePath: string) { + if (activeModuleTab !== null && modules && activeModuleTab !== modulePath) { + // Switching from another module: save its content + modules[activeModuleTab] = { ...modules[activeModuleTab], content: editorCode } + } + if (modules && modules[modulePath]) { + activeModuleTab = modulePath + editorCode = modules[modulePath].content + editor?.setCode(editorCode) + } + } + + function switchToMain() { + if (activeModuleTab !== null && modules) { + // Save current module content + modules[activeModuleTab] = { ...modules[activeModuleTab], content: editorCode } + } + activeModuleTab = null + editorCode = code + lastSyncedCode = code + editor?.setCode(editorCode) + } + + let effectiveLang = $derived( + activeModuleTab && modules?.[activeModuleTab] + ? (modules[activeModuleTab].language as Preview['language']) + : lang + ) + + let isWacV2 = $derived.by(() => { + const mainCode = code + const isTsWac = + mainCode.includes('windmill-client') && + mainCode.includes('workflow') && + mainCode.includes('task') + const isPyWac = + (mainCode.includes('import wmill') || mainCode.includes('from wmill')) && + mainCode.includes('workflow') && + mainCode.includes('task') + return isTsWac || isPyWac + }) + let supportsModules = $derived((lang === 'bun' || lang === 'python3') && isWacV2) + let mainFileName = $derived('script.' + langToExt(scriptLangToEditorLang(lang))) + + let modulePathInput = $state('') + let showAddModulePopover = $state(false) + let modulePathInputEl: HTMLInputElement | undefined = $state(undefined) + let modulePathError = $state('') + + let renameModuleInput = $state('') + let renameModuleError = $state('') + let renameModuleInputEl: HTMLInputElement | undefined = $state(undefined) + + const ALL_MODULE_EXTENSIONS: Record = { + '.ts': 'bun', + '.py': 'python3', + '.go': 'go', + '.sh': 'bash', + '.ps1': 'powershell', + '.sql': 'postgresql', + '.gql': 'graphql', + '.php': 'php', + '.rs': 'rust', + '.yml': 'ansible', + '.cs': 'csharp', + '.nu': 'nu', + '.java': 'java', + '.rb': 'ruby' + } + + /** Map main script language to allowed module file extensions. */ + const LANG_MODULE_EXTENSIONS: Partial> = { + python3: ['.py'], + bun: ['.ts'], + deno: ['.ts'], + nativets: ['.ts'], + go: ['.go'], + bash: ['.sh'], + powershell: ['.ps1'], + postgresql: ['.sql'], + mysql: ['.sql'], + bigquery: ['.sql'], + snowflake: ['.sql'], + mssql: ['.sql'], + oracledb: ['.sql'], + duckdb: ['.sql'], + graphql: ['.gql'], + php: ['.php'], + rust: ['.rs'], + ansible: ['.yml'], + csharp: ['.cs'], + nu: ['.nu'], + java: ['.java'], + ruby: ['.rb'], + bunnative: ['.ts'] + } + + let allowedModuleExtensions = $derived( + lang + ? (LANG_MODULE_EXTENSIONS[lang] ?? Object.keys(ALL_MODULE_EXTENSIONS)) + : Object.keys(ALL_MODULE_EXTENSIONS) + ) + + function inferModuleLang(filePath: string): ScriptModule['language'] | undefined { + for (const [ext, moduleLang] of Object.entries(ALL_MODULE_EXTENSIONS)) { + if (filePath.endsWith(ext)) return moduleLang + } + return undefined + } + + function getModuleDefaultContent(filePath: string): string { + if (filePath.endsWith('.py')) { + return `def hello() -> str:\n return "world"\n` + } else if (filePath.endsWith('.ts')) { + return `export function hello(): string {\n return "world"\n}\n` + } else if (filePath.endsWith('.go')) { + return `package inner\n\nfunc Hello() string {\n\treturn "world"\n}\n` + } else if (filePath.endsWith('.sh')) { + return `#!/bin/bash\necho "world"\n` + } else if (filePath.endsWith('.ps1')) { + return `function Hello {\n return "world"\n}\n` + } else if (filePath.endsWith('.sql')) { + return `SELECT 'world' as result;\n` + } else if (filePath.endsWith('.gql')) { + return `query Hello {\n hello\n}\n` + } else if (filePath.endsWith('.php')) { + return ` String {\n "world".to_string()\n}\n` + } else if (filePath.endsWith('.yml')) { + return `---\n- name: Hello\n debug:\n msg: "world"\n` + } else if (filePath.endsWith('.cs')) { + return `public static string Hello() {\n return "world";\n}\n` + } else if (filePath.endsWith('.nu')) { + return `def hello [] {\n "world"\n}\n` + } else if (filePath.endsWith('.java')) { + return `public class Helper {\n public static String hello() {\n return "world";\n }\n}\n` + } else if (filePath.endsWith('.rb')) { + return `def hello\n "world"\nend\n` + } + return '' + } + + function validateModulePath(path: string): string { + if (!path.trim()) return '' + const moduleLang = inferModuleLang(path) + if (!moduleLang) { + const exts = allowedModuleExtensions.join(', ') + return `File must end with a supported extension: ${exts}` + } + const matchedExt = allowedModuleExtensions.find((ext) => path.endsWith(ext)) + if (!matchedExt) { + const exts = allowedModuleExtensions.join(', ') + return `File must end with a supported extension for this language: ${exts}` + } + if (modules?.[path.trim()]) { + return `Module ${path.trim()} already exists` + } + return '' + } + + function addModule() { + const modulePath = modulePathInput.trim() + if (!modulePath) return + const error = validateModulePath(modulePath) + if (error) { + modulePathError = error + return + } + if (!modules) { + modules = {} + } + modules[modulePath] = { + content: getModuleDefaultContent(modulePath), + language: inferModuleLang(modulePath)! + } + modulePathInput = '' + modulePathError = '' + showAddModulePopover = false + switchToModule(modulePath) + } + + function removeModule(modulePath: string) { + if (!modules) return + if (activeModuleTab === modulePath) { + switchToMain() + } + delete modules[modulePath] + modules = { ...modules } + } + + function validateRenameModulePath(newPath: string, oldPath: string): string { + if (!newPath.trim()) return '' + const moduleLang = inferModuleLang(newPath) + if (!moduleLang) { + const exts = allowedModuleExtensions.join(', ') + return `File must end with a supported extension: ${exts}` + } + const matchedExt = allowedModuleExtensions.find((ext) => newPath.endsWith(ext)) + if (!matchedExt) { + const exts = allowedModuleExtensions.join(', ') + return `File must end with a supported extension for this language: ${exts}` + } + if (newPath.trim() !== oldPath && modules?.[newPath.trim()]) { + return `Module ${newPath.trim()} already exists` + } + return '' + } + + function renameModule(oldPath: string) { + const newPath = renameModuleInput.trim() + if (!newPath || newPath === oldPath) { + return + } + const error = validateRenameModulePath(newPath, oldPath) + if (error) { + renameModuleError = error + return + } + if (!modules) return + const mod = modules[oldPath] + const newLang = inferModuleLang(newPath) + delete modules[oldPath] + modules[newPath] = { ...mod, language: newLang ?? mod.language } + modules = { ...modules } + if (activeModuleTab === oldPath) { + activeModuleTab = newPath + } + renameModuleInput = '' + renameModuleError = '' + } + + /** Save the active module tab's editor content back into the modules map (no UI side-effects). */ + function flushModuleContent() { + if (activeModuleTab !== null && modules) { + modules[activeModuleTab] = { ...modules[activeModuleTab], content: editorCode } + } + } + + /** Flush module content and reset the editor back to the main script tab. */ + export function flushModuleState() { + if (activeModuleTab !== null && modules) { + flushModuleContent() + activeModuleTab = null + editorCode = code + } + } + $effect.pre(() => { if (schema == undefined) { schema = emptySchema() @@ -329,6 +613,8 @@ export async function runTest() { // Not defined if JobProgressBar not loaded jobProgressBar?.reset() + // Flush module edits back to modules map before running preview + flushModuleContent() //@ts-ignore let job = await jobLoader.runPreview( path, @@ -355,7 +641,9 @@ } console.error(error) } - } + }, + undefined, + modules ) logPanel?.setFocusToLogs() return job @@ -410,8 +698,7 @@ selectedTab = 'main' } else { hasPreprocessor = - (selectedTab === 'preprocessor' ? !result?.no_main_func : result?.has_preprocessor) ?? - false + (selectedTab === 'preprocessor' ? !result?.auto_kind : result?.has_preprocessor) ?? false if (!hasPreprocessor && selectedTab === 'preprocessor') { selectedTab = 'main' @@ -1316,140 +1603,333 @@ +{#snippet addModuleForm(close: () => void)} +
+ + { + modulePathError = validateModulePath(modulePathInput) + }} + onkeydown={(e) => { + if (e.key === 'Enter') addModule() + if (e.key === 'Escape') close() + }} + /> + {#if modulePathError} +

{modulePathError}

+ {/if} +

Supports subfolders, e.g. utils/math{allowedModuleExtensions[0] ?? '.ts'}

+
+ + +
+
+{/snippet} + +{#snippet renameModuleForm(oldPath: string, close: () => void)} +
+ + { + renameModuleError = validateRenameModulePath(renameModuleInput, oldPath) + }} + onkeydown={(e) => { + if (e.key === 'Enter') { + renameModule(oldPath) + close() + } + if (e.key === 'Escape') close() + }} + /> + {#if renameModuleError} +

{renameModuleError}

+ {/if} +
+ + +
+
+{/snippet} + {#snippet editorContent()} -
-
- {#if assets?.length} - - {/if} - {#if isDebuggableScript && customUi?.editorBar?.debug != false} - - {/if} - {#if showDebugPanel && !showDebugConsole} - + {#each Object.keys(modules ?? {}) as modulePath} +
+ +
+ + {#snippet trigger()} + { + e.stopPropagation() + renameModuleInput = modulePath + renameModuleError = '' + }} + onkeydown={(e) => { + if (e.key === 'Enter') { + e.stopPropagation() + renameModuleInput = modulePath + renameModuleError = '' + } + }} + > + + + {/snippet} + {#snippet content({ close })} + {@render renameModuleForm(modulePath, close)} + {/snippet} + + { + e.stopPropagation() + removeModule(modulePath) + }} + onkeydown={(e) => { + if (e.key === 'Enter') { + e.stopPropagation() + removeModule(modulePath) + } + }} + > + + +
+
+ {/each} + - Console - - {/if} - {#if lang === 'ansible' && hasDelegateToGitRepo} - - {/if} - {#if testPanelSize === 0} - +
+ {/if} +
+
+ {#if assets?.length} + + {/if} + {#if isDebuggableScript && customUi?.editorBar?.debug != false} + + {/if} + {#if showDebugPanel && !showDebugConsole} + + {/if} + {#if lang === 'ansible' && hasDelegateToGitRepo} + + {/if} + {#if testPanelSize === 0} + btnClasses="bg-marine-400 hover:bg-marine-200 !text-primary-inverse hover:!text-primary-inverse hover:dark:!text-primary-inverse dark:bg-marine-50 dark:hover:bg-marine-50/70" + color="marine" + /> {/if} + {#if !aiChatManager.open && !disableAi} + {#if customUi?.editorBar?.aiGen != false && SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(lang ?? '')} + + {/if} + {/if} +
+ + {#if debugConsoleVisible} + + + + {@render editorPane()} + + + (showDebugConsole = false)} + workspace={$workspaceStore} + jobId={debugSessionJobId ?? undefined} + /> + + + {:else} + +
+ {@render editorPane()} +
{/if}
- - {#if debugConsoleVisible} - - - - {@render editorPane()} - - - (showDebugConsole = false)} - workspace={$workspaceStore} - jobId={debugSessionJobId ?? undefined} - /> - - - {:else} - -
- {@render editorPane()} -
- {/if}
{/snippet} {#snippet editorPane()} - {#key lang} + {#key effectiveLang} { - inferSchema(e.detail) + if (activeModuleTab === null) { + code = editorCode + lastSyncedCode = code + inferSchema(e.detail) + } else { + flushModuleContent() + } // Refresh breakpoint positions when code changes (decorations track their lines) if (debugMode && breakpointDecorations.length > 0) { refreshBreakpointPositions() @@ -1458,20 +1938,24 @@ on:saveDraft on:toggleTestPanel={toggleTestPanel} cmdEnterAction={async () => { - await inferSchema(code) + if (activeModuleTab === null) { + await inferSchema(editorCode) + } runTest() }} formatAction={async () => { - await inferSchema(code) + if (activeModuleTab === null) { + await inferSchema(editorCode) + } try { - localStorage.setItem(path ?? 'last_save', code) + localStorage.setItem(path ?? 'last_save', activeModuleTab === null ? editorCode : code) } catch (e) { console.error('Could not save last_save to local storage', e) } dispatch('format') }} class="flex flex-1 h-full !overflow-visible" - scriptLang={lang} + scriptLang={effectiveLang} automaticLayout={true} {fixedOverflowWidgets} {args} diff --git a/frontend/src/lib/components/WorkflowTimeline.svelte b/frontend/src/lib/components/WorkflowTimeline.svelte index 66fbb5b6c5..d71a4bc015 100644 --- a/frontend/src/lib/components/WorkflowTimeline.svelte +++ b/frontend/src/lib/components/WorkflowTimeline.svelte @@ -3,17 +3,33 @@ import { displayDate, msToSec } from '$lib/utils' import { onDestroy } from 'svelte' import { getDbClockNow } from '$lib/forLater' - import { Loader2 } from 'lucide-svelte' + import { ChevronDown, ChevronRight, Loader2 } from 'lucide-svelte' import TimelineBar from './TimelineBar.svelte' - import type { WorkflowStatus } from '$lib/gen' + import LogViewer from './LogViewer.svelte' + import ObjectViewer from './propertyPicker/ObjectViewer.svelte' + import { CheckCircle2, XCircle } from 'lucide-svelte' + import { JobService, type Job, type WorkflowStatus } from '$lib/gen' + import { workspaceStore } from '$lib/stores' interface Props { - flow_status: Record; - flowDone?: boolean; + flow_status: Record + flowDone?: boolean + stepResults?: Record + result?: any + success?: boolean + autoExpandResult?: boolean } - let { flow_status, flowDone = false }: Props = $props(); + let { flow_status, flowDone = false, stepResults = {}, result = undefined, success = true, autoExpandResult = false }: Props = $props() + let resultExpanded = $state(false) + + // Auto-expand result row when job completes (only if requested) + $effect(() => { + if (autoExpandResult && flowDone && result !== undefined) { + resultExpanded = true + } + }) let now = $state(getDbClockNow().getTime()) @@ -25,35 +41,94 @@ onDestroy(() => { interval && clearInterval(interval) + pollInterval && clearInterval(pollInterval) }) - let min = $derived(Object.values(flow_status).reduce( - (a, b) => Math.min(a, b.scheduled_for ? new Date(b.scheduled_for).getTime() : Infinity), - Infinity - )) - let max = $derived(flowDone - ? Object.values(flow_status).reduce( - (a, b) => - Math.max(a, b.started_at ? new Date(b.started_at).getTime() + (b.duration_ms ?? 0) : 0), - 0 - ) - : undefined) + + let min = $derived( + Object.values(flow_status).reduce( + (a, b) => Math.min(a, b.scheduled_for ? new Date(b.scheduled_for).getTime() : Infinity), + Infinity + ) + ) + let max = $derived( + flowDone + ? Object.values(flow_status).reduce( + (a, b) => + Math.max( + a, + b.started_at ? new Date(b.started_at).getTime() + (b.duration_ms ?? 0) : 0 + ), + 0 + ) + : undefined + ) let total = $derived(flowDone && max ? max - min : Math.max(now - min, 2000)) + + // Collapsible state + let expandedRows: Record = $state({}) + let childJobs: Record = $state({}) + let loadingJobs: Record = $state({}) + + function isStep(key: string): boolean { + return key.startsWith('_step/') + } + + function stepKey(key: string): string { + return key.slice('_step/'.length) + } + + function toggleRow(id: string) { + expandedRows[id] = !expandedRows[id] + if (expandedRows[id] && !isStep(id) && !childJobs[id]) { + fetchChildJob(id) + } + } + + async function fetchChildJob(id: string) { + const ws = $workspaceStore + if (!ws) return + loadingJobs[id] = true + try { + const job = await JobService.getJob({ workspace: ws, id }) + childJobs[id] = job as Job & { result?: any } + } catch (e) { + console.error(`Failed to fetch job ${id}:`, e) + } finally { + loadingJobs[id] = false + } + } + + // Poll for updates on expanded in-progress jobs + let pollInterval = setInterval(() => { + for (const [id, v] of Object.entries(flow_status)) { + if (isStep(id)) continue + const isRunning = v.duration_ms == undefined && v.started_at != undefined + if (expandedRows[id] && isRunning) { + fetchChildJob(id) + } + } + }, 2000) {#if flow_status}
-
-
{min ? displayDate(new Date(min), true) : ''}
{#if max && min} - {/if}
{max ? displayDate(new Date(max), true) : ''}{#if !max && min}{#if now} +
+
+
+
{min ? displayDate(new Date(min), true) : ''}
+ {#if max && min} + + {/if} +
+ {max ? displayDate(new Date(max), true) : ''} + {#if !max && min} + {#if now} {msToSec(now - min, 3)}s - {/if}{/if}
+ {/if} + + {/if} +
+
@@ -61,26 +136,57 @@
Waiting for executor
-
Execution
- {#each Object.entries(flow_status) as [k, v] (k)} -
-
- {v.name ?? k} -
+ {#each Object.entries(flow_status).sort(([, a], [, b]) => { + const ta = new Date(a.started_at ?? a.scheduled_for ?? 0).getTime() + const tb = new Date(b.started_at ?? b.scheduled_for ?? 0).getTime() + return ta - tb + }) as [k, v] (k)} + {@const isInlineStep = isStep(k)} + {@const isRunning = v.duration_ms == undefined && v.started_at != undefined} + {@const isDone = v.duration_ms != undefined} + {@const isExpanded = expandedRows[k] ?? false} +
+
+ {/if} +
+ + + {#if isExpanded} +
+ {#if isInlineStep} + + {@const result = stepResults[stepKey(k)]} + {#if isDone && result !== undefined} +
+
Result
+
+ +
+
+ {:else} +
Step completed (no result)
+ {/if} + {:else if loadingJobs[k] && !childJobs[k]} +
+ + Loading... +
+ {:else if childJobs[k]} + {@const job = childJobs[k]} + + {#if job.logs || isRunning} +
+
Logs
+ +
+ {/if} + + + {#if isDone && job.result !== undefined} +
+
Result
+
+ +
+
+ {/if} + {:else} +
No data available
+ {/if} +
+ {/if}
{/each} + {#if flowDone && result !== undefined} +
+ + {#if resultExpanded} +
+
+ +
+
+ {/if} +
+ {/if}
{:else} diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index c98e326067..91a2bd3b3b 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -35,11 +35,14 @@ Share, Trash, History, - Globe2 + Globe2, + FileText } from 'lucide-svelte' import ScriptVersionHistory from '$lib/components/ScriptVersionHistory.svelte' + import WacExportDrawer from '$lib/components/scripts/WacExportDrawer.svelte' import { Drawer, DrawerContent } from '..' import NoMainFuncBadge from '$lib/components/NoMainFuncBadge.svelte' + import Popover from '$lib/components/Popover.svelte' import Tooltip from '$lib/components/Tooltip.svelte' import { getDeployUiSettings } from '$lib/components/home/deploy_ui' import { scriptToHubUrl } from '$lib/hub' @@ -106,6 +109,7 @@ const dlt: 'delete' = 'delete' let versionsDrawerOpen: boolean = $state(false) + let wacExportDrawer: WacExportDrawer | undefined = $state(undefined) {#if menuOpen} @@ -115,7 +119,7 @@ Archived {/if} - {#if script.no_main_func && script.kind !== 'preprocessor'} + {#if script.auto_kind === 'lib' && script.kind !== 'preprocessor'} {/if} + {#if script.auto_kind === 'wac'} + + {#snippet text()} + Workflow-as-Code + {/snippet} + wac + + {/if} {#if script.kind !== 'script'} {script.kind === 'failure' ? 'Error handler' : capitalize(script.kind)} { + const fullScript = await ScriptService.getScriptByPath({ + workspace: $workspaceStore!, + path: script.path + }) + wacExportDrawer?.open(fullScript) + } + } + ] + : []), { displayName: 'Duplicate/Fork', icon: GitFork, @@ -412,3 +439,5 @@ {/if} + + diff --git a/frontend/src/lib/components/flows/CreateActionsFlow.svelte b/frontend/src/lib/components/flows/CreateActionsFlow.svelte index f2080fb281..73f8cfedba 100644 --- a/frontend/src/lib/components/flows/CreateActionsFlow.svelte +++ b/frontend/src/lib/components/flows/CreateActionsFlow.svelte @@ -7,11 +7,28 @@ import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import { importFlowStore } from '$lib/components/flows/flowStore.svelte' - import { Loader2, Plus } from 'lucide-svelte' + import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte' + import Modal from '$lib/components/common/modal/Modal.svelte' + import Toggle from '$lib/components/Toggle.svelte' + import Tabs from '$lib/components/common/tabs/Tabs.svelte' + import Tab from '$lib/components/common/tabs/Tab.svelte' + import { PythonIcon, TypeScriptIcon } from '$lib/components/common/languageIcons' + import { Code2, Loader2, Plus } from 'lucide-svelte' import YAML from 'yaml' + + const SKIP_FLOW_MODAL_KEY = 'windmill_skip_flow_modal' + let drawer: Drawer | undefined = $state(undefined) + let wacDrawer: Drawer | undefined = $state(undefined) let pendingRaw: string | undefined = $state(undefined) + let pendingWacRaw: string | undefined = $state(undefined) let importType: 'yaml' | 'json' = $state('yaml') + let wacImportType: 'yaml' | 'json' = $state('yaml') + let flowModalOpen = $state(false) + let wacHovered = $state(false) + let skipModal = $state( + typeof localStorage !== 'undefined' && localStorage.getItem(SKIP_FLOW_MODAL_KEY) === 'true' + ) async function importRaw() { $importFlowStore = @@ -19,58 +36,229 @@ await goto('/flows/add') drawer?.closeDrawer?.() } + + async function importWacRaw() { + const parsed = + wacImportType === 'yaml' ? YAML.parse(pendingWacRaw ?? '') : JSON.parse(pendingWacRaw ?? '') + $importScriptStore = parsed + await goto(`${base}/scripts/add?import=true`) + wacDrawer?.closeDrawer?.() + } + + function handleFlowClick() { + if (skipModal) { + goto(`${base}/flows/add?nodraft=true`) + } else { + flowModalOpen = true + } + } + + function selectFlowEditor() { + flowModalOpen = false + goto(`${base}/flows/add?nodraft=true`) + } + + function selectWacPython() { + flowModalOpen = false + goto(`${base}/scripts/add?nodraft=true&wac=python`) + } + + function selectWacTypescript() { + flowModalOpen = false + goto(`${base}/scripts/add?nodraft=true&wac=typescript`) + } + + function toggleSkipModal() { + skipModal = !skipModal + localStorage.setItem(SKIP_FLOW_MODAL_KEY, String(skipModal)) + }
- -
- - - - drawer?.toggleDrawer?.()} + }, + { + label: 'Workflow-as-Code in TypeScript', + onClick: () => selectWacTypescript() + }, + { + label: 'Workflow-as-Code in Python', + onClick: () => selectWacPython() + }, + { + label: 'Import Workflow-as-Code', + onClick: () => { + wacDrawer?.toggleDrawer?.() + } + } + ]} > - {#await import('$lib/components/SimpleEditor.svelte')} - - {:then Module} - - {/await} + Flow + +
+ + + +
+
+ + + + + +
(wacHovered = true)} + onmouseleave={() => (wacHovered = false)} + > + +
+ Alpha +
+ + +
+
+ +
+
+

Workflow-as-Code

+

+ Write workflows as Python or TypeScript code as a regular Windmill script. +

+
+
+ + +
+ + +
+
+
+ +
+ + Always use the Flow editor (skip this modal) +
+
+
+ + + + drawer?.toggleDrawer?.()}> + + + + {#snippet content()} +
+ {#key importType} + {#await import('$lib/components/SimpleEditor.svelte')} + + {:then Module} + + {/await} + {/key} +
+ {/snippet} +
{#snippet actions()} {/snippet}
+ + + + wacDrawer?.toggleDrawer?.()}> + + + + {#snippet content()} +
+ {#key wacImportType} + {#await import('$lib/components/SimpleEditor.svelte')} + + {:then Module} + + {/await} + {/key} +
+ {/snippet} +
+ {#snippet actions()} + + {/snippet} +
+
diff --git a/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte b/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte index abdb497e93..ae9d75daeb 100644 --- a/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte +++ b/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte @@ -50,7 +50,7 @@ dedicated_worker?: boolean visible_to_runner_only?: boolean on_behalf_of_email?: string - no_main_func?: boolean + auto_kind?: string has_preprocessor?: boolean } | undefined = $state(undefined) @@ -71,7 +71,7 @@ dedicated_worker?: boolean visible_to_runner_only?: boolean on_behalf_of_email?: string - no_main_func?: boolean + auto_kind?: string has_preprocessor?: boolean } | undefined = $state(undefined) @@ -82,7 +82,7 @@ script.schema = script.schema ?? emptySchema() try { const result = await inferArgs(script.language, script.content, script.schema) - script.no_main_func = result?.no_main_func || undefined + script.auto_kind = result?.auto_kind || undefined script.has_preprocessor = result?.has_preprocessor || undefined } catch (error) { sendUserToast(`Could not parse code, are you sure it is valid?`, true) diff --git a/frontend/src/lib/components/graph/model.ts b/frontend/src/lib/components/graph/model.ts index d6fcf4a33a..3618406371 100644 --- a/frontend/src/lib/components/graph/model.ts +++ b/frontend/src/lib/components/graph/model.ts @@ -1,4 +1,4 @@ -import type { FlowStatusModule, Job } from '$lib/gen' +import type { FlowStatusModule, Job, WorkflowStatus } from '$lib/gen' import type { StateStore } from '$lib/utils' import type { FlowState } from '../flows/flowState' @@ -67,6 +67,7 @@ export type GraphModuleState = { skipped?: boolean agent_actions?: FlowStatusModule['agent_actions'] script_hash?: string + workflow_as_code_status?: WorkflowStatus } export type NestedNodes = GraphItem[] diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index cae0144169..12b1b7c093 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -478,7 +478,7 @@ {/if}
diff --git a/frontend/src/lib/components/runs/JobRunsPreview.svelte b/frontend/src/lib/components/runs/JobRunsPreview.svelte index 274679d3bc..62722daac8 100644 --- a/frontend/src/lib/components/runs/JobRunsPreview.svelte +++ b/frontend/src/lib/components/runs/JobRunsPreview.svelte @@ -50,11 +50,15 @@ if (!x || typeof x !== 'object') return {} const result: Record = {} for (const [k, v] of Object.entries(x)) { - if (!k.startsWith('_')) result[k] = v as WorkflowStatus + if (!k.startsWith('_') || k.startsWith('_step/')) result[k] = v as WorkflowStatus } return result } + function getStepResults(x: any): Record { + return x?._checkpoint?.completed_steps ?? {} + } + function handleFilterByConcurrencyKey(key: string) { dispatch('filterByConcurrencyKey', key) } @@ -156,6 +160,9 @@
{/if} diff --git a/frontend/src/lib/components/scriptEditor/LogPanel.svelte b/frontend/src/lib/components/scriptEditor/LogPanel.svelte index 63eb9d5a4c..51061f3fba 100644 --- a/frontend/src/lib/components/scriptEditor/LogPanel.svelte +++ b/frontend/src/lib/components/scriptEditor/LogPanel.svelte @@ -96,12 +96,18 @@ if (!x || typeof x !== 'object') return {} const result: Record = {} for (const [k, v] of Object.entries(x)) { - if (!k.startsWith('_')) result[k] = v as WorkflowStatus + if (!k.startsWith('_') || k.startsWith('_step/')) result[k] = v as WorkflowStatus } return result } + function getStepResults(x: any): Record { + return x?._checkpoint?.completed_steps ?? {} + } + let forceJson = $state(false) + let isWac = $derived(!!previewJob?.workflow_as_code_status) + let wacDone = $derived(previewJob?.type == 'CompletedJob') @@ -141,16 +147,20 @@ {#snippet content()}
{#if selectedTab === 'logs'} + {#if isWac} +
+ +
+ {:else} - {#if previewJob?.workflow_as_code_status} - - - - {/if} + {/if} {/if} {#if selectedTab === 'history'}
diff --git a/frontend/src/lib/components/script_builder.ts b/frontend/src/lib/components/script_builder.ts index f224c3bd6a..ddc5a01b89 100644 --- a/frontend/src/lib/components/script_builder.ts +++ b/frontend/src/lib/components/script_builder.ts @@ -14,7 +14,7 @@ export interface ScriptBuilderProps { disableAi?: boolean fullyLoaded?: boolean initialPath?: string - template?: 'docker' | 'bunnative' | 'claudesandbox' | 'script' + template?: 'docker' | 'bunnative' | 'claudesandbox' | 'wac_python' | 'wac_typescript' | 'script' initialArgs?: Record lockedLanguage?: boolean showMeta?: boolean diff --git a/frontend/src/lib/components/scripts/CreateActionsScript.svelte b/frontend/src/lib/components/scripts/CreateActionsScript.svelte index 9c847c9a3a..48480918e0 100644 --- a/frontend/src/lib/components/scripts/CreateActionsScript.svelte +++ b/frontend/src/lib/components/scripts/CreateActionsScript.svelte @@ -1,7 +1,7 @@ diff --git a/frontend/src/lib/components/scripts/WacExportDrawer.svelte b/frontend/src/lib/components/scripts/WacExportDrawer.svelte new file mode 100644 index 0000000000..59f1dca1b0 --- /dev/null +++ b/frontend/src/lib/components/scripts/WacExportDrawer.svelte @@ -0,0 +1,113 @@ + + + + + + drawer?.toggleDrawer()}> +
+ + + + {#snippet content()} +
+
+
+ {#key rawType} + + {/key} +
+ {/snippet} +
+
+
+
diff --git a/frontend/src/lib/components/scripts/scriptStore.svelte.ts b/frontend/src/lib/components/scripts/scriptStore.svelte.ts new file mode 100644 index 0000000000..abc1fb0d63 --- /dev/null +++ b/frontend/src/lib/components/scripts/scriptStore.svelte.ts @@ -0,0 +1,4 @@ +import type { NewScript } from '$lib/gen' +import { writable } from 'svelte/store' + +export const importScriptStore = writable(undefined) diff --git a/frontend/src/lib/infer.ts b/frontend/src/lib/infer.ts index 9861e9b016..b1b822419b 100644 --- a/frontend/src/lib/infer.ts +++ b/frontend/src/lib/infer.ts @@ -236,7 +236,7 @@ export async function inferArgs( schema: Schema, mainOverride?: string ): Promise<{ - no_main_func: boolean | null + auto_kind: string | null has_preprocessor: boolean | null } | null> { const lastRun = get(loadSchemaLastRun) @@ -398,7 +398,7 @@ export async function inferArgs( await tick() return { - no_main_func: inferedSchema.no_main_func, + auto_kind: inferedSchema.auto_kind, has_preprocessor: inferedSchema.has_preprocessor } } diff --git a/frontend/src/lib/script_helpers.ts b/frontend/src/lib/script_helpers.ts index 5744971f32..d05d78f6ef 100644 --- a/frontend/src/lib/script_helpers.ts +++ b/frontend/src/lib/script_helpers.ts @@ -3,6 +3,8 @@ import { type Script } from './gen' import type { SupportedLanguage } from './common' import CLAUDE_SANDBOX_INIT_CODE from './templates/claude_sandbox.ts.template?raw' +import WAC_PYTHON_INIT_CODE from './templates/wac_python.py.template?raw' +import WAC_TYPESCRIPT_INIT_CODE from './templates/wac_typescript.ts.template?raw' const PYTHON_FAILURE_MODULE_CODE = `import os @@ -1378,6 +1380,12 @@ export const INITIAL_CODE = { }, claudesandbox: { script: CLAUDE_SANDBOX_INIT_CODE + }, + wac_python: { + script: WAC_PYTHON_INIT_CODE + }, + wac_typescript: { + script: WAC_TYPESCRIPT_INIT_CODE } // for related places search: ADD_NEW_LANG } @@ -1406,6 +1414,8 @@ export function initialCode( | 'powershell' | 'bunnative' | 'claudesandbox' + | 'wac_python' + | 'wac_typescript' | undefined, templateScript?: boolean ): string { @@ -1436,6 +1446,10 @@ export function initialCode( } else { return INITIAL_CODE.deno.script } + } else if (subkind === 'wac_python') { + return INITIAL_CODE.wac_python.script + } else if (subkind === 'wac_typescript') { + return INITIAL_CODE.wac_typescript.script } else if (language === 'python3') { if (kind === 'trigger') { return INITIAL_CODE.python3.trigger @@ -1538,6 +1552,8 @@ export function getResetCode( | 'powershell' | 'bunnative' | 'claudesandbox' + | 'wac_python' + | 'wac_typescript' | undefined ) { if (language === 'deno') { diff --git a/frontend/src/lib/templates/wac_python.py.template b/frontend/src/lib/templates/wac_python.py.template new file mode 100644 index 0000000000..42f166ecc0 --- /dev/null +++ b/frontend/src/lib/templates/wac_python.py.template @@ -0,0 +1,42 @@ +from wmill import task, task_script, step, sleep, wait_for_approval, get_resume_urls, workflow + +# IMPORTANT: All computation must happen inside @task(), task_script(), or step(). +# Code outside these wrappers is NOT checkpointed and WILL be re-executed +# on every resume or retry. Never put API calls, database writes, or +# non-deterministic logic (e.g. datetime.now()) in the top-level workflow body. + +# task_script() references a module file (see the helper.py tab) +helper = task_script("./helper.py") + + +# @task() wraps a function as a workflow step that runs as a separate job. +# The result is checkpointed — on retry, completed tasks are skipped. +@task() +async def process(x: str) -> str: + return f"processed: {x}" + + +@workflow +async def main(x: str): + a = await process(x) + + # task_script() calls a module file as a separate job (also checkpointed) + b = await helper(a=a) + + # step() runs inline code and checkpoints the result (no child job). + # Use it for lightweight operations you don't want as a separate script. + urls = await step("get_urls", lambda: get_resume_urls()) + + # sleep() suspends the workflow server-side without holding a worker + await sleep(1) + + # wait_for_approval() suspends until an external event resumes it. + # Like sleep(), it does not hold a worker. Approve/reject URLs are + # available in the timeline step's details in the UI. + approval = await wait_for_approval(timeout=3600) + + return { + "processed": a, + "helper_result": b, + "approval": approval, + } diff --git a/frontend/src/lib/templates/wac_typescript.ts.template b/frontend/src/lib/templates/wac_typescript.ts.template new file mode 100644 index 0000000000..0d22d6d402 --- /dev/null +++ b/frontend/src/lib/templates/wac_typescript.ts.template @@ -0,0 +1,48 @@ +import { + task, + taskScript, + step, + sleep, + waitForApproval, + getResumeUrls, + workflow, +} from "windmill-client"; + +// IMPORTANT: All computation must happen inside task(), taskScript(), or step(). +// Code outside these wrappers is NOT checkpointed and WILL be re-executed +// on every resume or retry. Never put API calls, database writes, or +// non-deterministic logic (e.g. Date.now()) in the top-level workflow body. + +// taskScript() references a module file (see the helper.ts tab) +const helper = taskScript("./helper.ts"); + +// task() wraps a function as a workflow step that runs as a separate job. +// The result is checkpointed — on retry, completed tasks are skipped. +const process = task(async (x: string): Promise => { + return `processed: ${x}`; +}); + +export const main = workflow(async (x: string) => { + const a = await process(x); + + // taskScript() calls a module file as a separate job (also checkpointed) + const b = await helper({ a }); + + // step() runs inline code and checkpoints the result (no child job). + // Use it for lightweight operations you don't want as a separate script. + const urls = await step("get_urls", () => getResumeUrls()); + + // sleep() suspends the workflow server-side without holding a worker + await sleep(1); + + // waitForApproval() suspends until an external event resumes it. + // Like sleep(), it does not hold a worker. Approve/reject URLs are + // available in the timeline step's details in the UI. + const approval = await waitForApproval({ timeout: 3600 }); + + return { + processed: a, + helper_result: b, + approval, + }; +}); diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index ac73b934fd..5bf8f21ac5 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -284,11 +284,15 @@ if (!x || typeof x !== 'object') return {} const result: Record = {} for (const [k, v] of Object.entries(x)) { - if (!k.startsWith('_')) result[k] = v as WorkflowStatus + if (!k.startsWith('_') || k.startsWith('_step/')) result[k] = v as WorkflowStatus } return result } + function getStepResults(x: any): Record { + return x?._checkpoint?.completed_steps ?? {} + } + function forkPreview() { if (isFlowPreview(job?.job_kind)) { const state = { @@ -790,6 +794,9 @@
diff --git a/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte index afc3f9e270..8f43dde796 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte @@ -14,6 +14,8 @@ import { get } from 'svelte/store' import { untrack } from 'svelte' import ScriptEditorSkeleton from '$lib/components/ScriptEditorSkeleton.svelte' + import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte' + import { isWorkflowAsCode } from '$lib/components/graph/wacToFlow' type Script = NewScript & { draft_triggers?: Trigger[] @@ -29,6 +31,8 @@ const showMeta = /true|1/i.test(page.url.searchParams.get('show_meta') ?? '0') const urlArgs = page.url.searchParams.get('initial_args') const collabLang = page.url.searchParams.get('lang') as ScriptLang | null + const wacParam = page.url.searchParams.get('wac') + const importParam = page.url.searchParams.get('import') let initialArgs = urlArgs ? decodeState(urlArgs) : (get(initialArgsStore) ?? {}) if (get(initialArgsStore)) $initialArgsStore = undefined @@ -59,7 +63,7 @@ schema: schema, is_template: false, extra_perms: {}, - language: collabLang ?? ($defaultScripts?.order?.filter( + 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' @@ -122,6 +126,27 @@ loadHub() + let importedWacTemplate: 'wac_python' | 'wac_typescript' | undefined = undefined + if (importParam && $importScriptStore) { + const imported = $importScriptStore + $importScriptStore = undefined + const isWac = isWorkflowAsCode(imported.content ?? '', imported.language ?? '') + script = { + ...defaultScript(), + ...imported, + path: path ?? '', + hash: '', + extra_perms: {} + } + if (isWac) { + importedWacTemplate = + imported.language === 'python3' ? 'wac_python' : 'wac_typescript' + sendUserToast('WAC script loaded from YAML/JSON') + } else { + sendUserToast('Script loaded from YAML/JSON') + } + } + $effect(() => { if ($workspaceStore) { untrack(() => loadTemplate()) @@ -134,6 +159,7 @@ {initialArgs} bind:this={scriptBuilder} lockedLanguage={templatePath != null || hubPath != null} + template={importedWacTemplate ?? (wacParam === 'python' ? 'wac_python' : wacParam === 'typescript' ? 'wac_typescript' : 'script')} onDeploy={(e) => { goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`) }} 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 4fa95cf647..4ba269b74a 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte @@ -62,11 +62,14 @@ Trash, Play, ClipboardCopy, - LayoutDashboard + LayoutDashboard, + ChevronDown, + ChevronRight } from 'lucide-svelte' import { SCRIPT_VIEW_SHOW_PUBLISH_TO_HUB } from '$lib/consts' import { scriptToHubUrl } from '$lib/hub' import SharedBadge from '$lib/components/SharedBadge.svelte' + import Popover from '$lib/components/Popover.svelte' import ScriptVersionHistory from '$lib/components/ScriptVersionHistory.svelte' import { createAppFromScript } from '$lib/components/details/createAppFromScript' import { importStore } from '$lib/components/apps/store' @@ -95,6 +98,8 @@ let can_write = $state(false) let isHubScript = $state(false) let deploymentInProgress = $state(false) + let expandedModuleLocks: Record = $state({}) + let expandedModuleCode: Record = $state({}) let deploymentJobId: string | undefined = $state(undefined) let intervalId: number let shareModal: ShareModal | undefined = $state() @@ -208,7 +213,7 @@ kind: 'script', starred: false, schema: hubScript.schema as Script['schema'], - no_main_func: false, + auto_kind: undefined, has_preprocessor: false } can_write = false @@ -352,7 +357,12 @@ }) } - if (script && !$userStore?.operator && !isCloudHosted() && !isRuleActive('DisableWorkspaceForking')) { + if ( + script && + !$userStore?.operator && + !isCloudHosted() && + !isRuleActive('DisableWorkspaceForking') + ) { buttons.push({ label: 'Edit in fork', buttonProps: { @@ -680,6 +690,14 @@ {#if $workspaceStore && script} {/if} + {#if script?.auto_kind === 'wac'} + + {#snippet text()} + Workflow-as-Code + {/snippet} + wac + + {/if} {#if script?.codebase} bundle
+ {#if script?.modules} + {#each Object.entries(script.modules) as [modulePath, mod]} +
+ + {#if expandedModuleCode[modulePath]} +
+ +
+ {/if} +
+ {/each} + {/if}
@@ -942,6 +987,45 @@ There is no lock file for this script

{/if} + {#if script?.modules} + {@const moduleEntries = Object.entries(script.modules).filter( + ([_, m]) => m.lock + )} + {#each moduleEntries as [modulePath, mod]} +
+ + {#if expandedModuleLocks[modulePath]} +
+
+ {/if} +
+ {/each} + {/if}
diff --git a/python-client/wmill/uv.lock b/python-client/wmill/uv.lock new file mode 100644 index 0000000000..6c1c3ab219 --- /dev/null +++ b/python-client/wmill/uv.lock @@ -0,0 +1,139 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[manifest] + +[manifest.dependency-groups] +dev = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=9.0.2" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] diff --git a/python-client/wmill/wmill/client.py b/python-client/wmill/wmill/client.py index c8c30de4f1..cdd3342771 100644 --- a/python-client/wmill/wmill/client.py +++ b/python-client/wmill/wmill/client.py @@ -11,7 +11,7 @@ import time import warnings import json from json import JSONDecodeError -from typing import Dict, Any, Union, Literal, Optional +from typing import Callable, Dict, Any, Union, Literal, Optional import re import httpx @@ -2431,6 +2431,7 @@ class WorkflowCtx: else: return self._never_resolve() + print(f"\n--- WAC: {key} ---") info = {"name": name or key, "script": script or key, "args": kwargs, "key": key, "dispatch_type": dispatch_type} if _task_options: for opt_key in ("timeout", "tag", "cache_ttl", "priority", "concurrent_limit", "concurrency_key", "concurrency_time_window_s"): @@ -2472,6 +2473,7 @@ class WorkflowCtx: if self._executing_key is not None: await _asyncio.Future() + print(f"\n--- WAC: wait_for_approval({key}) ---") raise _StepSuspend({ "mode": "approval", "key": key, @@ -2489,6 +2491,7 @@ class WorkflowCtx: if self._executing_key is not None: await _asyncio.Future() + print(f"\n--- WAC: sleep({key}, {seconds}s) ---") raise _StepSuspend({ "mode": "sleep", "key": key, @@ -2497,6 +2500,10 @@ class WorkflowCtx: }) async def _run_inline_step(self, name: str, fn): + import json as _json_mod + import time as _time_mod + from datetime import datetime as _dt, timezone as _tz + key = self._alloc_key(name or "step") if key in self._completed: @@ -2513,15 +2520,22 @@ class WorkflowCtx: if self._executing_key is not None: await _asyncio.Future() + print(f"\n--- WAC: {key} ---") + started_at = _dt.now(_tz.utc).isoformat() + print(f"WM_WAC_STEP: {_json_mod.dumps({'key': key, 'started_at': started_at})}") + t0 = _time_mod.monotonic() result = fn() if _asyncio.iscoroutine(result): result = await result + duration_ms = int((_time_mod.monotonic() - t0) * 1000) raise _StepSuspend({ "mode": "inline_checkpoint", "steps": [], "key": key, "result": result, + "started_at": started_at, + "duration_ms": duration_ms, }) @@ -2568,7 +2582,7 @@ def task( # Remove None values _task_opts = {k: v for k, v in _task_opts.items() if v is not None} or None - def decorator(func): + def decorator(func) -> Callable[..., Any]: task_path = path task_name = func.__name__ @@ -2585,7 +2599,6 @@ def task( merged[f"arg{i}"] = arg return merged - @functools.wraps(func) def wrapper(*args, **kwargs): # WAC v2: inside a @workflow context ctx = _workflow_ctx.get(None) @@ -2815,11 +2828,16 @@ async def _run_workflow_async(func, checkpoint: dict, input_args: dict): if mode == "step_complete": return {"type": "complete", "result": info.get("result")} if mode == "inline_checkpoint": - return { + out = { "type": "inline_checkpoint", "key": info["key"], "result": info.get("result"), } + if "started_at" in info: + out["started_at"] = info["started_at"] + if "duration_ms" in info: + out["duration_ms"] = info["duration_ms"] + return out if mode == "approval": return { "type": "approval", diff --git a/typescript-client/client.ts b/typescript-client/client.ts index 8578c98bb6..49087a8b60 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -1558,6 +1558,8 @@ export class WorkflowCtx { this._suspended = true; const steps = [...this.pending]; this.pending = []; + const names = steps.map(s => s.name).join(", "); + console.log(`\n--- WAC: ${names} ---`); throw new StepSuspend({ mode: steps.length > 1 ? "parallel" : "sequential", steps, @@ -1589,6 +1591,7 @@ export class WorkflowCtx { } // Throw immediately — approval is always a blocking step + console.log(`\n--- WAC: approval(${key}) ---`); throw new StepSuspend({ mode: "approval", key, @@ -1609,6 +1612,7 @@ export class WorkflowCtx { return { then: () => new Promise(() => {}) }; } + console.log(`\n--- WAC: sleep(${key}, ${seconds}s) ---`); throw new StepSuspend({ mode: "sleep", key, @@ -1636,8 +1640,13 @@ export class WorkflowCtx { return new Promise(() => {}); } + console.log(`\n--- WAC: ${key} ---`); + const startedAt = new Date().toISOString(); + console.log(`WM_WAC_STEP: ${JSON.stringify({ key, started_at: startedAt })}`); + const t0 = Date.now(); const result = await fn(); - throw new StepSuspend({ mode: "inline_checkpoint", steps: [], key, result }); + const durationMs = Date.now() - t0; + throw new StepSuspend({ mode: "inline_checkpoint", steps: [], key, result, started_at: startedAt, duration_ms: durationMs }); } } From 6165e01e8afb89a1b86049e40faece12a595040b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 17 Mar 2026 01:27:31 +0000 Subject: [PATCH 084/204] fix migration clash --- ..._role.down.sql => 20260316000005_instance_group_role.down.sql} | 0 ...roup_role.up.sql => 20260316000005_instance_group_role.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename backend/migrations/{20260316000000_instance_group_role.down.sql => 20260316000005_instance_group_role.down.sql} (100%) rename backend/migrations/{20260316000000_instance_group_role.up.sql => 20260316000005_instance_group_role.up.sql} (100%) diff --git a/backend/migrations/20260316000000_instance_group_role.down.sql b/backend/migrations/20260316000005_instance_group_role.down.sql similarity index 100% rename from backend/migrations/20260316000000_instance_group_role.down.sql rename to backend/migrations/20260316000005_instance_group_role.down.sql diff --git a/backend/migrations/20260316000000_instance_group_role.up.sql b/backend/migrations/20260316000005_instance_group_role.up.sql similarity index 100% rename from backend/migrations/20260316000000_instance_group_role.up.sql rename to backend/migrations/20260316000005_instance_group_role.up.sql From ced6f622072efe3ac1ae0f24c18cf157180230c7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 17 Mar 2026 01:36:03 +0000 Subject: [PATCH 085/204] chore: trigger CLI tests on migration changes and release tags Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cli-tests.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml index 20a24d2091..5478e8985e 100644 --- a/.github/workflows/cli-tests.yml +++ b/.github/workflows/cli-tests.yml @@ -3,13 +3,17 @@ name: CLI Tests on: push: branches: [main] + tags: + - "v*" paths: - "cli/**" + - "backend/migrations/**" - ".github/workflows/cli-tests.yml" pull_request: branches: [main] paths: - "cli/**" + - "backend/migrations/**" - ".github/workflows/cli-tests.yml" env: From 4e7be0d27a392135f111cccda5800e5ba1563923 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 17 Mar 2026 01:39:24 +0000 Subject: [PATCH 086/204] chore: run windows backend tests on release tags Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/backend-test-windows.yml | 2 ++ .github/workflows/cli-tests.yml | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/backend-test-windows.yml b/.github/workflows/backend-test-windows.yml index ca9ce2aaac..864693ebda 100644 --- a/.github/workflows/backend-test-windows.yml +++ b/.github/workflows/backend-test-windows.yml @@ -5,6 +5,8 @@ on: push: branches: - "ci-windows-tests" + tags: + - "v*" env: CARGO_INCREMENTAL: 0 diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml index 5478e8985e..9c87a249a3 100644 --- a/.github/workflows/cli-tests.yml +++ b/.github/workflows/cli-tests.yml @@ -3,8 +3,6 @@ name: CLI Tests on: push: branches: [main] - tags: - - "v*" paths: - "cli/**" - "backend/migrations/**" From 8cd2d06f0175c3a412883fa2e5ed4b9a608fdfa0 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 17 Mar 2026 01:50:16 +0000 Subject: [PATCH 087/204] nit fix app --- frontend/package-lock.json | 4 ++-- frontend/src/lib/components/apps/editor/appUtilsS3.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 01eea55d67..9f9a907e19 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -162,8 +162,8 @@ } }, "../backend/parsers/windmill-parser-wasm/pkg-ts": { - "name": "windmill-parser-wasm-ts", - "version": "1.657.2" + "name": "windmill-parser-wasm", + "version": "1.654.0" }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", diff --git a/frontend/src/lib/components/apps/editor/appUtilsS3.ts b/frontend/src/lib/components/apps/editor/appUtilsS3.ts index 5e35f1a4bd..f9811edd76 100644 --- a/frontend/src/lib/components/apps/editor/appUtilsS3.ts +++ b/frontend/src/lib/components/apps/editor/appUtilsS3.ts @@ -153,8 +153,8 @@ export function computeS3FileViewerPolicy(config: RichConfigurations) { } else if ( config.source.type === 'static' && typeof config.source.value === 'string' && - ((config.sourceKind.type === 'static' && - config.sourceKind.value === 's3 (workspace storage)') || + ((config.sourceKind?.type === 'static' && + config.sourceKind?.value === 's3 (workspace storage)') || config.source.value.startsWith('s3://')) ) { return { From 08215c708bc3c118c0fe4b2f2b10e481a93f83e1 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:33:11 +0100 Subject: [PATCH 088/204] Display workspace ID in select subtitle when creating token (#8407) --- .../src/lib/components/settings/CreateToken.svelte | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/settings/CreateToken.svelte b/frontend/src/lib/components/settings/CreateToken.svelte index fbd9e41081..9a2c6106b2 100644 --- a/frontend/src/lib/components/settings/CreateToken.svelte +++ b/frontend/src/lib/components/settings/CreateToken.svelte @@ -44,7 +44,6 @@ displayCreateToken = true }: Props = $props() - let newToken = $state(undefined) let newMcpToken = $state(undefined) let newTokenExpiration = $state(undefined) @@ -464,11 +463,10 @@
Workspace - + updateSetting(wsId, setting.key, e.currentTarget.value)} + /> + {/if} +
+ {/each} + + {#if getAvailableFields(wsId).length > 0} + + {/if} +
+ {/if} +
+ {/each} + + + {#if showAddWorkspace} +
+ {#if availableWorkspaces.length > 0} + + {:else} + + {/if} + + +
+ {:else} +
+ +
+ {/if} +
From 7d9fb57368ad3b2c719523ef649c9bd5fddf17a5 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 17 Mar 2026 16:26:04 +0100 Subject: [PATCH 091/204] feat: DB-backed instance events webhook with superadmin UI (#8402) * feat: make instance events webhook URL configurable via superadmin UI The instance events webhook was previously only configurable via the INSTANCE_EVENTS_WEBHOOK env var, requiring a restart to change. This adds a DB-backed global setting with a UI in superadmin settings under Monitoring > Webhooks, while keeping the env var as an override. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address PR review - prometheus timer bug and cleaner cache init - Bind prometheus timer to `let timer` and call `stop_and_record()` after the POST (was silently discarded before) - Use `Option` with `map_or` instead of `checked_sub` trick for clearer "not yet read" semantics Co-Authored-By: Claude Opus 4.6 (1M context) * chore: remove env var mention from webhook setting description Co-Authored-By: Claude Opus 4.6 (1M context) * chore: list all instance events explicitly in webhook description Co-Authored-By: Claude Opus 4.6 (1M context) * fix: restore send_instance_event guard with AtomicBool for DB setting Use a shared Arc between send_instance_event and the event loop so we skip channel sends when no webhook is configured (env or DB). Starts optimistic (true) so the first event triggers a DB read, then the loop updates it after each cache refresh. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use static AtomicBool + notify handler for webhook guard Replace the Arc instance field with a global static INSTANCE_EVENTS_WEBHOOK_DB_ENABLED, updated by the notify_global_setting_change handler in main.rs. This follows the established pattern (like REQUIRE_PREEXISTING_USER_FOR_OAUTH) and avoids the deadlock where the bool could never flip back to true. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: single Arc>> for instance webhook URL Replace the separate INSTANCE_EVENTS_WEBHOOK env var lazy_static and INSTANCE_EVENTS_WEBHOOK_DB_ENABLED AtomicBool with a single shared variable. Initialized from env var, then the reload function overwrites from DB (falls back to env var when DB has no value). Follows the same pattern as SCIM_TOKEN and other settings. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Ruben Fiszel --- backend/src/main.rs | 13 +++++++--- backend/src/monitor.rs | 21 ++++++++++++++++ .../windmill-common/src/global_settings.rs | 1 + .../windmill-common/src/instance_config.rs | 2 ++ backend/windmill-common/src/webhook.rs | 25 +++++++++++++------ .../src/lib/components/instanceSettings.ts | 19 ++++++++++++++ 6 files changed, 70 insertions(+), 11 deletions(-) diff --git a/backend/src/main.rs b/backend/src/main.rs index f0876427e4..fc0a4c29d4 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -44,9 +44,10 @@ use windmill_common::{ DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING, - INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, - JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, - MAVEN_SETTINGS_XML_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING, + INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING, + JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, + KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING, + MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING, NPM_CONFIG_REGISTRY_SETTING, NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING, @@ -102,7 +103,8 @@ use crate::monitor::{ reload_base_url_setting, reload_bunfig_install_scopes_setting, reload_critical_alert_mute_ui_setting, reload_critical_alerts_on_token_expiry_setting, reload_critical_error_channels_setting, reload_extra_pip_index_url_setting, - reload_hub_api_secret_setting, reload_hub_base_url_setting, reload_job_default_timeout_setting, + reload_hub_api_secret_setting, reload_hub_base_url_setting, + reload_instance_events_webhook_setting, reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key, reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting, reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting, @@ -1850,6 +1852,9 @@ async fn process_notify_event( tracing::error!(error = %e, "Could not reload critical alerts on token expiry setting"); } } + INSTANCE_EVENTS_WEBHOOK_SETTING => { + reload_instance_events_webhook_setting(db).await; + } "workspace_telemetry_enabled" => { // Read the new value from the database and log it let enabled = sqlx::query_scalar!( diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index b174da7905..579bfbc49e 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -235,6 +235,7 @@ pub async fn initial_load( ) } windmill_common::min_version::store_min_keep_alive_version(db).await; + reload_instance_events_webhook_setting(db).await; } } @@ -1360,6 +1361,26 @@ async fn delete_log_files_from_disk_and_store( let _: Vec<_> = delete_futures.collect().await; } +pub async fn reload_instance_events_webhook_setting(db: &DB) { + use windmill_common::global_settings::INSTANCE_EVENTS_WEBHOOK_SETTING; + use windmill_common::webhook::INSTANCE_EVENTS_WEBHOOK; + + let value = load_value_from_global_settings(db, INSTANCE_EVENTS_WEBHOOK_SETTING).await; + match value { + Ok(Some(serde_json::Value::String(s))) if !s.is_empty() => { + *INSTANCE_EVENTS_WEBHOOK.write().await = Some(s); + } + Ok(None) | Ok(Some(serde_json::Value::Null)) | Ok(Some(serde_json::Value::String(_))) => { + // Fall back to env var if DB has no value + *INSTANCE_EVENTS_WEBHOOK.write().await = std::env::var("INSTANCE_EVENTS_WEBHOOK").ok(); + } + Err(e) => { + tracing::error!("Error loading instance_events_webhook setting: {e:#}"); + } + _ => (), + }; +} + pub async fn reload_scim_token_setting(conn: &Connection) { reload_option_setting_with_tracing(conn, SCIM_TOKEN_SETTING, "SCIM_TOKEN", SCIM_TOKEN.clone()) .await; diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index d6cd953fbd..937bebf09c 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -60,6 +60,7 @@ pub const APP_WORKSPACED_ROUTE_SETTING: &str = "app_workspaced_route"; pub const SECRET_BACKEND_SETTING: &str = "secret_backend"; pub const MIN_KEEP_ALIVE_VERSION_SETTING: &str = "min_keep_alive_version"; pub const GITHUB_ENTERPRISE_APP_SETTING: &str = "github_enterprise_app"; +pub const INSTANCE_EVENTS_WEBHOOK_SETTING: &str = "instance_events_webhook"; pub const WORKSPACE_REGISTRIES_SETTING: &str = "workspace_registries"; pub const ENV_SETTINGS: &[&str] = &[ diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 1c79f2f9a0..6e791906f9 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -261,6 +261,8 @@ pub struct GlobalSettings { #[serde(skip_serializing_if = "Option::is_none")] pub saml_metadata: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub instance_events_webhook: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub openai_azure_base_path: Option, #[serde(skip_serializing_if = "Option::is_none")] pub min_keep_alive_version: Option, diff --git a/backend/windmill-common/src/webhook.rs b/backend/windmill-common/src/webhook.rs index 4677084fbe..caf8ec08fc 100644 --- a/backend/windmill-common/src/webhook.rs +++ b/backend/windmill-common/src/webhook.rs @@ -1,7 +1,9 @@ +use std::sync::Arc; use std::time::Duration; use quick_cache::sync::Cache; use serde::Serialize; +use tokio::sync::RwLock; use tokio::{select, sync::mpsc}; #[cfg(feature = "prometheus")] @@ -24,7 +26,8 @@ lazy_static::lazy_static! { lazy_static::lazy_static! { - pub static ref INSTANCE_EVENTS_WEBHOOK: Option = std::env::var("INSTANCE_EVENTS_WEBHOOK").ok(); + pub static ref INSTANCE_EVENTS_WEBHOOK: Arc>> = + Arc::new(RwLock::new(std::env::var("INSTANCE_EVENTS_WEBHOOK").ok())); pub static ref WEBHOOK_CACHE: Cache> = Cache::new(100); @@ -208,11 +211,16 @@ impl WebhookShared { } }, Some(WebhookPayload::InstanceEvent(event)) => { - #[cfg(feature = "prometheus")] - if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { Some(WEBHOOK_REQUEST_COUNT.start_timer()) } else { None }; - let r = client.post(INSTANCE_EVENTS_WEBHOOK.as_ref().unwrap()).json(&event).send().await; - if let Err(e) = r { - tracing::error!("Error sending instance event: {}", e); + let url = INSTANCE_EVENTS_WEBHOOK.read().await.clone(); + if let Some(url) = url { + #[cfg(feature = "prometheus")] + let timer = if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { Some(WEBHOOK_REQUEST_COUNT.start_timer()) } else { None }; + let r = client.post(&url).json(&event).send().await; + if let Err(e) = r { + tracing::error!("Error sending instance event: {}", e); + } + #[cfg(feature = "prometheus")] + timer.map(|x| x.stop_and_record()); } }, None => break, @@ -232,7 +240,10 @@ impl WebhookShared { } pub fn send_instance_event(&self, event: InstanceEvent) { - if INSTANCE_EVENTS_WEBHOOK.is_none() { + if INSTANCE_EVENTS_WEBHOOK + .try_read() + .is_ok_and(|v| v.is_none()) + { return; } let _ = self.channel.send(WebhookPayload::InstanceEvent(event)); diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 22077b79a7..05783ab91d 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -611,6 +611,17 @@ export const settings: Record = { ee_only: '' } ], + Webhooks: [ + { + label: 'Instance Events Webhook', + description: + 'URL to receive POST requests for instance events (user added, OAuth signup, user invited/added/joined workspace).', + key: 'instance_events_webhook', + fieldType: 'text', + placeholder: 'https://example.com/webhook', + storage: 'setting' + } + ], 'OTEL/Prom': [ { label: 'OpenTelemetry', @@ -789,6 +800,12 @@ export const instanceSettingsNavigationGroups = [ aiDescription: 'Instance alerts settings', isEE: true }, + { + id: 'webhooks', + label: 'Webhooks', + aiId: 'instance-settings-webhooks', + aiDescription: 'Instance events webhook settings' + }, { id: 'otel_prom', label: 'OTEL/Prometheus', @@ -852,6 +869,7 @@ export const tabToCategoryMap: Record = { smtp: 'SMTP', registries: 'Registries', alerts: 'Alerts', + webhooks: 'Webhooks', otel_prom: 'OTEL/Prom', indexer: 'Indexer', telemetry: 'Telemetry', @@ -883,6 +901,7 @@ export const categoryToTabMap: Record = { 'Auth/OAuth/SAML': 'sso', Registries: 'registries', Alerts: 'alerts', + Webhooks: 'webhooks', 'OTEL/Prom': 'otel_prom', Indexer: 'indexer', Telemetry: 'telemetry', From d43eca7b4bf9fd4f3a53a9177274b48a7fb81cab Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 17 Mar 2026 17:02:54 +0100 Subject: [PATCH 092/204] chore(frontend): add missing integration icons and fix dark mode visibility (#8413) * feat: add 93 missing integration icons and fix dark mode visibility Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add 11 more integration icons (round 2) Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add 5 more integration icons (round 3) Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../icons/ActiveCampaignIcon.svelte | 15 + .../lib/components/icons/AlgoliaIcon.svelte | 12 + .../lib/components/icons/ApolloIcon.svelte | 12 + .../lib/components/icons/BambooHrIcon.svelte | 12 + .../components/icons/BaremetricsIcon.svelte | 12 + .../src/lib/components/icons/BitlyIcon.svelte | 12 + .../lib/components/icons/BloggerIcon.svelte | 12 + .../lib/components/icons/BlueskyIcon.svelte | 12 + .../src/lib/components/icons/BoxIcon.svelte | 12 + .../src/lib/components/icons/BrevoIcon.svelte | 12 + .../src/lib/components/icons/BrexIcon.svelte | 12 + .../components/icons/BrowserlessIcon.svelte | 12 + .../lib/components/icons/BubbleIcon.svelte | 16 + .../lib/components/icons/BuildkiteIcon.svelte | 12 + .../lib/components/icons/CalcomIcon.svelte | 2 +- .../lib/components/icons/CalendlyIcon.svelte | 12 + .../lib/components/icons/CircleCiIcon.svelte | 12 + .../src/lib/components/icons/CiscoIcon.svelte | 12 + .../lib/components/icons/ClearbitIcon.svelte | 12 + .../src/lib/components/icons/ClerkIcon.svelte | 12 + .../src/lib/components/icons/CloseIcon.svelte | 42 +++ .../components/icons/CloudinaryIcon.svelte | 12 + .../components/icons/CockroachDbIcon.svelte | 12 + .../src/lib/components/icons/CodaIcon.svelte | 12 + .../lib/components/icons/CohereIcon.svelte | 21 ++ .../components/icons/CoinMarketCapIcon.svelte | 12 + .../lib/components/icons/CoinbaseIcon.svelte | 12 + .../components/icons/ConfluenceIcon.svelte | 12 + .../components/icons/ContentfulIcon.svelte | 12 + .../components/icons/ConvertKitIcon.svelte | 12 + .../lib/components/icons/DatoCmsIcon.svelte | 12 + .../src/lib/components/icons/DeelIcon.svelte | 12 + .../src/lib/components/icons/DeepLIcon.svelte | 12 + .../components/icons/DigitalOceanIcon.svelte | 12 + .../lib/components/icons/DiscourseIcon.svelte | 12 + .../lib/components/icons/DocusignIcon.svelte | 12 + .../lib/components/icons/DropboxIcon.svelte | 12 + .../lib/components/icons/EdgeDbIcon.svelte | 46 ++- .../components/icons/EventbriteIcon.svelte | 15 + .../src/lib/components/icons/FigmaIcon.svelte | 12 + .../src/lib/components/icons/FlyIcon.svelte | 12 + .../lib/components/icons/FreshdeskIcon.svelte | 12 + .../lib/components/icons/FrontAppIcon.svelte | 12 + .../lib/components/icons/GhostCmsIcon.svelte | 12 + .../src/lib/components/icons/GiphyIcon.svelte | 12 + .../lib/components/icons/GitBookIcon.svelte | 12 + .../src/lib/components/icons/GroqIcon.svelte | 12 + .../components/icons/HoneybadgerIcon.svelte | 12 + .../src/lib/components/icons/HttpIcon.svelte | 7 +- .../src/lib/components/icons/IftttIcon.svelte | 12 + .../lib/components/icons/IntercomIcon.svelte | 12 + .../lib/components/icons/JoomlaIcon.svelte | 12 + .../src/lib/components/icons/LineIcon.svelte | 12 + .../lib/components/icons/LinearIcon.svelte | 12 + .../lib/components/icons/LinodeIcon.svelte | 12 + .../lib/components/icons/LumaAiIcon.svelte | 44 +++ .../lib/components/icons/MSTeamsIcon.svelte | 60 ++-- .../lib/components/icons/MagentoIcon.svelte | 12 + .../lib/components/icons/MailchimpIcon.svelte | 44 ++- .../lib/components/icons/MandrillIcon.svelte | 12 + .../lib/components/icons/MauticIcon.svelte | 12 + .../lib/components/icons/MediumIcon.svelte | 12 + .../src/lib/components/icons/MiroIcon.svelte | 12 + .../lib/components/icons/MistralIcon.svelte | 12 + .../lib/components/icons/MixpanelIcon.svelte | 12 + .../lib/components/icons/MondayIcon.svelte | 12 + .../lib/components/icons/NeonDbIcon.svelte | 28 ++ .../lib/components/icons/NetlifyIcon.svelte | 12 + .../lib/components/icons/OneSignalIcon.svelte | 14 + .../components/icons/OpenWeatherIcon.svelte | 12 + .../lib/components/icons/PagerDutyIcon.svelte | 12 + .../lib/components/icons/PandaDocIcon.svelte | 12 + .../lib/components/icons/PaypalIcon.svelte | 12 + .../lib/components/icons/PersonioIcon.svelte | 12 + .../lib/components/icons/PinterestIcon.svelte | 12 + .../lib/components/icons/PipedriveIcon.svelte | 16 + .../components/icons/PlanetScaleIcon.svelte | 12 + .../lib/components/icons/PostmarkIcon.svelte | 12 + .../lib/components/icons/PusherIcon.svelte | 12 + .../components/icons/QuickbooksIcon.svelte | 20 +- .../lib/components/icons/RaindropIcon.svelte | 32 ++ .../lib/components/icons/ReadwiseIcon.svelte | 17 + .../lib/components/icons/RenderIcon.svelte | 12 + .../lib/components/icons/ReplicateIcon.svelte | 12 + .../lib/components/icons/ResendIcon.svelte | 2 +- .../src/lib/components/icons/RestIcon.svelte | 7 +- .../components/icons/RingCentralIcon.svelte | 12 + .../components/icons/RocketChatIcon.svelte | 12 + .../lib/components/icons/RunPodIcon.svelte | 13 + .../src/lib/components/icons/RustIcon.svelte | 140 ++++---- .../components/icons/SalesforceIcon.svelte | 12 + .../lib/components/icons/SegmentIcon.svelte | 12 + .../lib/components/icons/SentryIcon.svelte | 12 + .../components/icons/ServiceNowIcon.svelte | 12 + .../lib/components/icons/ShortcutIcon.svelte | 12 + .../lib/components/icons/SigNozIcon.svelte | 13 + .../components/icons/SmartsheetIcon.svelte | 12 + .../lib/components/icons/SpeechifyIcon.svelte | 12 + .../lib/components/icons/SplitwiseIcon.svelte | 12 + .../lib/components/icons/StravaIcon.svelte | 12 + .../src/lib/components/icons/TallyIcon.svelte | 12 + .../lib/components/icons/TelnyxIcon.svelte | 24 ++ .../lib/components/icons/ThreadsIcon.svelte | 12 + .../lib/components/icons/TodoistIcon.svelte | 12 + .../components/icons/TogetherAiIcon.svelte | 29 ++ .../lib/components/icons/TrelloIcon.svelte | 29 +- .../src/lib/components/icons/TursoIcon.svelte | 12 + .../lib/components/icons/TwitchIcon.svelte | 12 + .../lib/components/icons/TwitterIcon.svelte | 12 + .../lib/components/icons/TypeformIcon.svelte | 2 +- .../lib/components/icons/VercelIcon.svelte | 12 + .../lib/components/icons/WebflowIcon.svelte | 12 + .../components/icons/WooCommerceIcon.svelte | 12 + .../lib/components/icons/WordpressIcon.svelte | 12 + .../src/lib/components/icons/XataIcon.svelte | 18 + .../src/lib/components/icons/YelpIcon.svelte | 12 + .../src/lib/components/icons/YnabIcon.svelte | 12 + .../lib/components/icons/YoutubeIcon.svelte | 12 + .../lib/components/icons/ZendeskIcon.svelte | 2 +- .../lib/components/icons/ZeroTierIcon.svelte | 12 + .../src/lib/components/icons/ZoomIcon.svelte | 12 + frontend/src/lib/components/icons/index.ts | 337 +++++++++++++++++- 122 files changed, 2011 insertions(+), 160 deletions(-) create mode 100644 frontend/src/lib/components/icons/ActiveCampaignIcon.svelte create mode 100644 frontend/src/lib/components/icons/AlgoliaIcon.svelte create mode 100644 frontend/src/lib/components/icons/ApolloIcon.svelte create mode 100644 frontend/src/lib/components/icons/BambooHrIcon.svelte create mode 100644 frontend/src/lib/components/icons/BaremetricsIcon.svelte create mode 100644 frontend/src/lib/components/icons/BitlyIcon.svelte create mode 100644 frontend/src/lib/components/icons/BloggerIcon.svelte create mode 100644 frontend/src/lib/components/icons/BlueskyIcon.svelte create mode 100644 frontend/src/lib/components/icons/BoxIcon.svelte create mode 100644 frontend/src/lib/components/icons/BrevoIcon.svelte create mode 100644 frontend/src/lib/components/icons/BrexIcon.svelte create mode 100644 frontend/src/lib/components/icons/BrowserlessIcon.svelte create mode 100644 frontend/src/lib/components/icons/BubbleIcon.svelte create mode 100644 frontend/src/lib/components/icons/BuildkiteIcon.svelte create mode 100644 frontend/src/lib/components/icons/CalendlyIcon.svelte create mode 100644 frontend/src/lib/components/icons/CircleCiIcon.svelte create mode 100644 frontend/src/lib/components/icons/CiscoIcon.svelte create mode 100644 frontend/src/lib/components/icons/ClearbitIcon.svelte create mode 100644 frontend/src/lib/components/icons/ClerkIcon.svelte create mode 100644 frontend/src/lib/components/icons/CloseIcon.svelte create mode 100644 frontend/src/lib/components/icons/CloudinaryIcon.svelte create mode 100644 frontend/src/lib/components/icons/CockroachDbIcon.svelte create mode 100644 frontend/src/lib/components/icons/CodaIcon.svelte create mode 100644 frontend/src/lib/components/icons/CohereIcon.svelte create mode 100644 frontend/src/lib/components/icons/CoinMarketCapIcon.svelte create mode 100644 frontend/src/lib/components/icons/CoinbaseIcon.svelte create mode 100644 frontend/src/lib/components/icons/ConfluenceIcon.svelte create mode 100644 frontend/src/lib/components/icons/ContentfulIcon.svelte create mode 100644 frontend/src/lib/components/icons/ConvertKitIcon.svelte create mode 100644 frontend/src/lib/components/icons/DatoCmsIcon.svelte create mode 100644 frontend/src/lib/components/icons/DeelIcon.svelte create mode 100644 frontend/src/lib/components/icons/DeepLIcon.svelte create mode 100644 frontend/src/lib/components/icons/DigitalOceanIcon.svelte create mode 100644 frontend/src/lib/components/icons/DiscourseIcon.svelte create mode 100644 frontend/src/lib/components/icons/DocusignIcon.svelte create mode 100644 frontend/src/lib/components/icons/DropboxIcon.svelte create mode 100644 frontend/src/lib/components/icons/EventbriteIcon.svelte create mode 100644 frontend/src/lib/components/icons/FigmaIcon.svelte create mode 100644 frontend/src/lib/components/icons/FlyIcon.svelte create mode 100644 frontend/src/lib/components/icons/FreshdeskIcon.svelte create mode 100644 frontend/src/lib/components/icons/FrontAppIcon.svelte create mode 100644 frontend/src/lib/components/icons/GhostCmsIcon.svelte create mode 100644 frontend/src/lib/components/icons/GiphyIcon.svelte create mode 100644 frontend/src/lib/components/icons/GitBookIcon.svelte create mode 100644 frontend/src/lib/components/icons/GroqIcon.svelte create mode 100644 frontend/src/lib/components/icons/HoneybadgerIcon.svelte create mode 100644 frontend/src/lib/components/icons/IftttIcon.svelte create mode 100644 frontend/src/lib/components/icons/IntercomIcon.svelte create mode 100644 frontend/src/lib/components/icons/JoomlaIcon.svelte create mode 100644 frontend/src/lib/components/icons/LineIcon.svelte create mode 100644 frontend/src/lib/components/icons/LinearIcon.svelte create mode 100644 frontend/src/lib/components/icons/LinodeIcon.svelte create mode 100644 frontend/src/lib/components/icons/LumaAiIcon.svelte create mode 100644 frontend/src/lib/components/icons/MagentoIcon.svelte create mode 100644 frontend/src/lib/components/icons/MandrillIcon.svelte create mode 100644 frontend/src/lib/components/icons/MauticIcon.svelte create mode 100644 frontend/src/lib/components/icons/MediumIcon.svelte create mode 100644 frontend/src/lib/components/icons/MiroIcon.svelte create mode 100644 frontend/src/lib/components/icons/MistralIcon.svelte create mode 100644 frontend/src/lib/components/icons/MixpanelIcon.svelte create mode 100644 frontend/src/lib/components/icons/MondayIcon.svelte create mode 100644 frontend/src/lib/components/icons/NeonDbIcon.svelte create mode 100644 frontend/src/lib/components/icons/NetlifyIcon.svelte create mode 100644 frontend/src/lib/components/icons/OneSignalIcon.svelte create mode 100644 frontend/src/lib/components/icons/OpenWeatherIcon.svelte create mode 100644 frontend/src/lib/components/icons/PagerDutyIcon.svelte create mode 100644 frontend/src/lib/components/icons/PandaDocIcon.svelte create mode 100644 frontend/src/lib/components/icons/PaypalIcon.svelte create mode 100644 frontend/src/lib/components/icons/PersonioIcon.svelte create mode 100644 frontend/src/lib/components/icons/PinterestIcon.svelte create mode 100644 frontend/src/lib/components/icons/PipedriveIcon.svelte create mode 100644 frontend/src/lib/components/icons/PlanetScaleIcon.svelte create mode 100644 frontend/src/lib/components/icons/PostmarkIcon.svelte create mode 100644 frontend/src/lib/components/icons/PusherIcon.svelte create mode 100644 frontend/src/lib/components/icons/RaindropIcon.svelte create mode 100644 frontend/src/lib/components/icons/ReadwiseIcon.svelte create mode 100644 frontend/src/lib/components/icons/RenderIcon.svelte create mode 100644 frontend/src/lib/components/icons/ReplicateIcon.svelte create mode 100644 frontend/src/lib/components/icons/RingCentralIcon.svelte create mode 100644 frontend/src/lib/components/icons/RocketChatIcon.svelte create mode 100644 frontend/src/lib/components/icons/RunPodIcon.svelte create mode 100644 frontend/src/lib/components/icons/SalesforceIcon.svelte create mode 100644 frontend/src/lib/components/icons/SegmentIcon.svelte create mode 100644 frontend/src/lib/components/icons/SentryIcon.svelte create mode 100644 frontend/src/lib/components/icons/ServiceNowIcon.svelte create mode 100644 frontend/src/lib/components/icons/ShortcutIcon.svelte create mode 100644 frontend/src/lib/components/icons/SigNozIcon.svelte create mode 100644 frontend/src/lib/components/icons/SmartsheetIcon.svelte create mode 100644 frontend/src/lib/components/icons/SpeechifyIcon.svelte create mode 100644 frontend/src/lib/components/icons/SplitwiseIcon.svelte create mode 100644 frontend/src/lib/components/icons/StravaIcon.svelte create mode 100644 frontend/src/lib/components/icons/TallyIcon.svelte create mode 100644 frontend/src/lib/components/icons/TelnyxIcon.svelte create mode 100644 frontend/src/lib/components/icons/ThreadsIcon.svelte create mode 100644 frontend/src/lib/components/icons/TodoistIcon.svelte create mode 100644 frontend/src/lib/components/icons/TogetherAiIcon.svelte create mode 100644 frontend/src/lib/components/icons/TursoIcon.svelte create mode 100644 frontend/src/lib/components/icons/TwitchIcon.svelte create mode 100644 frontend/src/lib/components/icons/TwitterIcon.svelte create mode 100644 frontend/src/lib/components/icons/VercelIcon.svelte create mode 100644 frontend/src/lib/components/icons/WebflowIcon.svelte create mode 100644 frontend/src/lib/components/icons/WooCommerceIcon.svelte create mode 100644 frontend/src/lib/components/icons/WordpressIcon.svelte create mode 100644 frontend/src/lib/components/icons/XataIcon.svelte create mode 100644 frontend/src/lib/components/icons/YelpIcon.svelte create mode 100644 frontend/src/lib/components/icons/YnabIcon.svelte create mode 100644 frontend/src/lib/components/icons/YoutubeIcon.svelte create mode 100644 frontend/src/lib/components/icons/ZeroTierIcon.svelte create mode 100644 frontend/src/lib/components/icons/ZoomIcon.svelte diff --git a/frontend/src/lib/components/icons/ActiveCampaignIcon.svelte b/frontend/src/lib/components/icons/ActiveCampaignIcon.svelte new file mode 100644 index 0000000000..836b5a242b --- /dev/null +++ b/frontend/src/lib/components/icons/ActiveCampaignIcon.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/frontend/src/lib/components/icons/AlgoliaIcon.svelte b/frontend/src/lib/components/icons/AlgoliaIcon.svelte new file mode 100644 index 0000000000..e003c778e4 --- /dev/null +++ b/frontend/src/lib/components/icons/AlgoliaIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ApolloIcon.svelte b/frontend/src/lib/components/icons/ApolloIcon.svelte new file mode 100644 index 0000000000..85dbb1730a --- /dev/null +++ b/frontend/src/lib/components/icons/ApolloIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/BambooHrIcon.svelte b/frontend/src/lib/components/icons/BambooHrIcon.svelte new file mode 100644 index 0000000000..efbeec2ece --- /dev/null +++ b/frontend/src/lib/components/icons/BambooHrIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/BaremetricsIcon.svelte b/frontend/src/lib/components/icons/BaremetricsIcon.svelte new file mode 100644 index 0000000000..be550a88fc --- /dev/null +++ b/frontend/src/lib/components/icons/BaremetricsIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/BitlyIcon.svelte b/frontend/src/lib/components/icons/BitlyIcon.svelte new file mode 100644 index 0000000000..3cc3b43122 --- /dev/null +++ b/frontend/src/lib/components/icons/BitlyIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/BloggerIcon.svelte b/frontend/src/lib/components/icons/BloggerIcon.svelte new file mode 100644 index 0000000000..ab1d2d262f --- /dev/null +++ b/frontend/src/lib/components/icons/BloggerIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/BlueskyIcon.svelte b/frontend/src/lib/components/icons/BlueskyIcon.svelte new file mode 100644 index 0000000000..1f8545e325 --- /dev/null +++ b/frontend/src/lib/components/icons/BlueskyIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/BoxIcon.svelte b/frontend/src/lib/components/icons/BoxIcon.svelte new file mode 100644 index 0000000000..a6a5d5dc07 --- /dev/null +++ b/frontend/src/lib/components/icons/BoxIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/BrevoIcon.svelte b/frontend/src/lib/components/icons/BrevoIcon.svelte new file mode 100644 index 0000000000..2907c346f7 --- /dev/null +++ b/frontend/src/lib/components/icons/BrevoIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/BrexIcon.svelte b/frontend/src/lib/components/icons/BrexIcon.svelte new file mode 100644 index 0000000000..5f9fbf8de1 --- /dev/null +++ b/frontend/src/lib/components/icons/BrexIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/BrowserlessIcon.svelte b/frontend/src/lib/components/icons/BrowserlessIcon.svelte new file mode 100644 index 0000000000..768d51145c --- /dev/null +++ b/frontend/src/lib/components/icons/BrowserlessIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/BubbleIcon.svelte b/frontend/src/lib/components/icons/BubbleIcon.svelte new file mode 100644 index 0000000000..fdfb049e70 --- /dev/null +++ b/frontend/src/lib/components/icons/BubbleIcon.svelte @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/frontend/src/lib/components/icons/BuildkiteIcon.svelte b/frontend/src/lib/components/icons/BuildkiteIcon.svelte new file mode 100644 index 0000000000..282d782e31 --- /dev/null +++ b/frontend/src/lib/components/icons/BuildkiteIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/CalcomIcon.svelte b/frontend/src/lib/components/icons/CalcomIcon.svelte index e11dc76d24..382b8e7a56 100644 --- a/frontend/src/lib/components/icons/CalcomIcon.svelte +++ b/frontend/src/lib/components/icons/CalcomIcon.svelte @@ -18,6 +18,6 @@ > diff --git a/frontend/src/lib/components/icons/CalendlyIcon.svelte b/frontend/src/lib/components/icons/CalendlyIcon.svelte new file mode 100644 index 0000000000..7868621afd --- /dev/null +++ b/frontend/src/lib/components/icons/CalendlyIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/CircleCiIcon.svelte b/frontend/src/lib/components/icons/CircleCiIcon.svelte new file mode 100644 index 0000000000..92402ac6cd --- /dev/null +++ b/frontend/src/lib/components/icons/CircleCiIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/CiscoIcon.svelte b/frontend/src/lib/components/icons/CiscoIcon.svelte new file mode 100644 index 0000000000..d2d4b14bd8 --- /dev/null +++ b/frontend/src/lib/components/icons/CiscoIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ClearbitIcon.svelte b/frontend/src/lib/components/icons/ClearbitIcon.svelte new file mode 100644 index 0000000000..fc150c1dfe --- /dev/null +++ b/frontend/src/lib/components/icons/ClearbitIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ClerkIcon.svelte b/frontend/src/lib/components/icons/ClerkIcon.svelte new file mode 100644 index 0000000000..21baeae38d --- /dev/null +++ b/frontend/src/lib/components/icons/ClerkIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/CloseIcon.svelte b/frontend/src/lib/components/icons/CloseIcon.svelte new file mode 100644 index 0000000000..73e6f414f9 --- /dev/null +++ b/frontend/src/lib/components/icons/CloseIcon.svelte @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/CloudinaryIcon.svelte b/frontend/src/lib/components/icons/CloudinaryIcon.svelte new file mode 100644 index 0000000000..a61f3bbe7c --- /dev/null +++ b/frontend/src/lib/components/icons/CloudinaryIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/CockroachDbIcon.svelte b/frontend/src/lib/components/icons/CockroachDbIcon.svelte new file mode 100644 index 0000000000..d88f7c52de --- /dev/null +++ b/frontend/src/lib/components/icons/CockroachDbIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/CodaIcon.svelte b/frontend/src/lib/components/icons/CodaIcon.svelte new file mode 100644 index 0000000000..d8486224d9 --- /dev/null +++ b/frontend/src/lib/components/icons/CodaIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/CohereIcon.svelte b/frontend/src/lib/components/icons/CohereIcon.svelte new file mode 100644 index 0000000000..c0a4aea8ee --- /dev/null +++ b/frontend/src/lib/components/icons/CohereIcon.svelte @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/CoinMarketCapIcon.svelte b/frontend/src/lib/components/icons/CoinMarketCapIcon.svelte new file mode 100644 index 0000000000..770b60f40a --- /dev/null +++ b/frontend/src/lib/components/icons/CoinMarketCapIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/CoinbaseIcon.svelte b/frontend/src/lib/components/icons/CoinbaseIcon.svelte new file mode 100644 index 0000000000..b58a45fff0 --- /dev/null +++ b/frontend/src/lib/components/icons/CoinbaseIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ConfluenceIcon.svelte b/frontend/src/lib/components/icons/ConfluenceIcon.svelte new file mode 100644 index 0000000000..60dbcef880 --- /dev/null +++ b/frontend/src/lib/components/icons/ConfluenceIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ContentfulIcon.svelte b/frontend/src/lib/components/icons/ContentfulIcon.svelte new file mode 100644 index 0000000000..2af55d48fc --- /dev/null +++ b/frontend/src/lib/components/icons/ContentfulIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ConvertKitIcon.svelte b/frontend/src/lib/components/icons/ConvertKitIcon.svelte new file mode 100644 index 0000000000..2fbcf0e53f --- /dev/null +++ b/frontend/src/lib/components/icons/ConvertKitIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/DatoCmsIcon.svelte b/frontend/src/lib/components/icons/DatoCmsIcon.svelte new file mode 100644 index 0000000000..d462832e17 --- /dev/null +++ b/frontend/src/lib/components/icons/DatoCmsIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/DeelIcon.svelte b/frontend/src/lib/components/icons/DeelIcon.svelte new file mode 100644 index 0000000000..0b93a4a98f --- /dev/null +++ b/frontend/src/lib/components/icons/DeelIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/DeepLIcon.svelte b/frontend/src/lib/components/icons/DeepLIcon.svelte new file mode 100644 index 0000000000..028b061dc0 --- /dev/null +++ b/frontend/src/lib/components/icons/DeepLIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/DigitalOceanIcon.svelte b/frontend/src/lib/components/icons/DigitalOceanIcon.svelte new file mode 100644 index 0000000000..206e395175 --- /dev/null +++ b/frontend/src/lib/components/icons/DigitalOceanIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/DiscourseIcon.svelte b/frontend/src/lib/components/icons/DiscourseIcon.svelte new file mode 100644 index 0000000000..fd1e53f9a3 --- /dev/null +++ b/frontend/src/lib/components/icons/DiscourseIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/DocusignIcon.svelte b/frontend/src/lib/components/icons/DocusignIcon.svelte new file mode 100644 index 0000000000..91d96bac36 --- /dev/null +++ b/frontend/src/lib/components/icons/DocusignIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/DropboxIcon.svelte b/frontend/src/lib/components/icons/DropboxIcon.svelte new file mode 100644 index 0000000000..3facd1dda2 --- /dev/null +++ b/frontend/src/lib/components/icons/DropboxIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/EdgeDbIcon.svelte b/frontend/src/lib/components/icons/EdgeDbIcon.svelte index 2b94ecb6d0..cc31f328d4 100644 --- a/frontend/src/lib/components/icons/EdgeDbIcon.svelte +++ b/frontend/src/lib/components/icons/EdgeDbIcon.svelte @@ -1,30 +1,26 @@ - - - + + + diff --git a/frontend/src/lib/components/icons/EventbriteIcon.svelte b/frontend/src/lib/components/icons/EventbriteIcon.svelte new file mode 100644 index 0000000000..f851a933f3 --- /dev/null +++ b/frontend/src/lib/components/icons/EventbriteIcon.svelte @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/FigmaIcon.svelte b/frontend/src/lib/components/icons/FigmaIcon.svelte new file mode 100644 index 0000000000..e31fe0a71d --- /dev/null +++ b/frontend/src/lib/components/icons/FigmaIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/FlyIcon.svelte b/frontend/src/lib/components/icons/FlyIcon.svelte new file mode 100644 index 0000000000..02118212b2 --- /dev/null +++ b/frontend/src/lib/components/icons/FlyIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/FreshdeskIcon.svelte b/frontend/src/lib/components/icons/FreshdeskIcon.svelte new file mode 100644 index 0000000000..72f2d7b1fa --- /dev/null +++ b/frontend/src/lib/components/icons/FreshdeskIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/FrontAppIcon.svelte b/frontend/src/lib/components/icons/FrontAppIcon.svelte new file mode 100644 index 0000000000..dfba63c8b7 --- /dev/null +++ b/frontend/src/lib/components/icons/FrontAppIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/GhostCmsIcon.svelte b/frontend/src/lib/components/icons/GhostCmsIcon.svelte new file mode 100644 index 0000000000..018960d9b7 --- /dev/null +++ b/frontend/src/lib/components/icons/GhostCmsIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/GiphyIcon.svelte b/frontend/src/lib/components/icons/GiphyIcon.svelte new file mode 100644 index 0000000000..521a358840 --- /dev/null +++ b/frontend/src/lib/components/icons/GiphyIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/GitBookIcon.svelte b/frontend/src/lib/components/icons/GitBookIcon.svelte new file mode 100644 index 0000000000..b310c868f7 --- /dev/null +++ b/frontend/src/lib/components/icons/GitBookIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/GroqIcon.svelte b/frontend/src/lib/components/icons/GroqIcon.svelte new file mode 100644 index 0000000000..e8a32f8328 --- /dev/null +++ b/frontend/src/lib/components/icons/GroqIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/HoneybadgerIcon.svelte b/frontend/src/lib/components/icons/HoneybadgerIcon.svelte new file mode 100644 index 0000000000..cad2bb50d1 --- /dev/null +++ b/frontend/src/lib/components/icons/HoneybadgerIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/HttpIcon.svelte b/frontend/src/lib/components/icons/HttpIcon.svelte index eb0833ed0e..f6a29899d1 100644 --- a/frontend/src/lib/components/icons/HttpIcon.svelte +++ b/frontend/src/lib/components/icons/HttpIcon.svelte @@ -1,10 +1,10 @@ diff --git a/frontend/src/lib/components/icons/IftttIcon.svelte b/frontend/src/lib/components/icons/IftttIcon.svelte new file mode 100644 index 0000000000..166a28a3c3 --- /dev/null +++ b/frontend/src/lib/components/icons/IftttIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/IntercomIcon.svelte b/frontend/src/lib/components/icons/IntercomIcon.svelte new file mode 100644 index 0000000000..718a8179c0 --- /dev/null +++ b/frontend/src/lib/components/icons/IntercomIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/JoomlaIcon.svelte b/frontend/src/lib/components/icons/JoomlaIcon.svelte new file mode 100644 index 0000000000..34916abd9b --- /dev/null +++ b/frontend/src/lib/components/icons/JoomlaIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/LineIcon.svelte b/frontend/src/lib/components/icons/LineIcon.svelte new file mode 100644 index 0000000000..a33626338f --- /dev/null +++ b/frontend/src/lib/components/icons/LineIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/LinearIcon.svelte b/frontend/src/lib/components/icons/LinearIcon.svelte new file mode 100644 index 0000000000..e30e78639b --- /dev/null +++ b/frontend/src/lib/components/icons/LinearIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/LinodeIcon.svelte b/frontend/src/lib/components/icons/LinodeIcon.svelte new file mode 100644 index 0000000000..1a3c80e92e --- /dev/null +++ b/frontend/src/lib/components/icons/LinodeIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/LumaAiIcon.svelte b/frontend/src/lib/components/icons/LumaAiIcon.svelte new file mode 100644 index 0000000000..e98a2d3a4f --- /dev/null +++ b/frontend/src/lib/components/icons/LumaAiIcon.svelte @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/MSTeamsIcon.svelte b/frontend/src/lib/components/icons/MSTeamsIcon.svelte index d761a645a0..cbe8589023 100644 --- a/frontend/src/lib/components/icons/MSTeamsIcon.svelte +++ b/frontend/src/lib/components/icons/MSTeamsIcon.svelte @@ -1,78 +1,62 @@ - - - - - + diff --git a/frontend/src/lib/components/icons/MagentoIcon.svelte b/frontend/src/lib/components/icons/MagentoIcon.svelte new file mode 100644 index 0000000000..4723fd7d35 --- /dev/null +++ b/frontend/src/lib/components/icons/MagentoIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/MailchimpIcon.svelte b/frontend/src/lib/components/icons/MailchimpIcon.svelte index 55930c704b..2e1f756585 100644 --- a/frontend/src/lib/components/icons/MailchimpIcon.svelte +++ b/frontend/src/lib/components/icons/MailchimpIcon.svelte @@ -1,23 +1,35 @@ - - - - - - - + + + + + + + - \ No newline at end of file diff --git a/frontend/src/lib/components/icons/MandrillIcon.svelte b/frontend/src/lib/components/icons/MandrillIcon.svelte new file mode 100644 index 0000000000..c0098e9594 --- /dev/null +++ b/frontend/src/lib/components/icons/MandrillIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/MauticIcon.svelte b/frontend/src/lib/components/icons/MauticIcon.svelte new file mode 100644 index 0000000000..f040bf76d6 --- /dev/null +++ b/frontend/src/lib/components/icons/MauticIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/MediumIcon.svelte b/frontend/src/lib/components/icons/MediumIcon.svelte new file mode 100644 index 0000000000..7e96527f85 --- /dev/null +++ b/frontend/src/lib/components/icons/MediumIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/MiroIcon.svelte b/frontend/src/lib/components/icons/MiroIcon.svelte new file mode 100644 index 0000000000..64634c09b4 --- /dev/null +++ b/frontend/src/lib/components/icons/MiroIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/MistralIcon.svelte b/frontend/src/lib/components/icons/MistralIcon.svelte new file mode 100644 index 0000000000..efe6881da7 --- /dev/null +++ b/frontend/src/lib/components/icons/MistralIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/MixpanelIcon.svelte b/frontend/src/lib/components/icons/MixpanelIcon.svelte new file mode 100644 index 0000000000..aab286a515 --- /dev/null +++ b/frontend/src/lib/components/icons/MixpanelIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/MondayIcon.svelte b/frontend/src/lib/components/icons/MondayIcon.svelte new file mode 100644 index 0000000000..b9069f934a --- /dev/null +++ b/frontend/src/lib/components/icons/MondayIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/NeonDbIcon.svelte b/frontend/src/lib/components/icons/NeonDbIcon.svelte new file mode 100644 index 0000000000..eb226a64c1 --- /dev/null +++ b/frontend/src/lib/components/icons/NeonDbIcon.svelte @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/NetlifyIcon.svelte b/frontend/src/lib/components/icons/NetlifyIcon.svelte new file mode 100644 index 0000000000..5f1349d0ba --- /dev/null +++ b/frontend/src/lib/components/icons/NetlifyIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/OneSignalIcon.svelte b/frontend/src/lib/components/icons/OneSignalIcon.svelte new file mode 100644 index 0000000000..f52f181cf0 --- /dev/null +++ b/frontend/src/lib/components/icons/OneSignalIcon.svelte @@ -0,0 +1,14 @@ + + + + + + + diff --git a/frontend/src/lib/components/icons/OpenWeatherIcon.svelte b/frontend/src/lib/components/icons/OpenWeatherIcon.svelte new file mode 100644 index 0000000000..709c96e230 --- /dev/null +++ b/frontend/src/lib/components/icons/OpenWeatherIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/PagerDutyIcon.svelte b/frontend/src/lib/components/icons/PagerDutyIcon.svelte new file mode 100644 index 0000000000..5b0e9a45e3 --- /dev/null +++ b/frontend/src/lib/components/icons/PagerDutyIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/PandaDocIcon.svelte b/frontend/src/lib/components/icons/PandaDocIcon.svelte new file mode 100644 index 0000000000..b5191210d9 --- /dev/null +++ b/frontend/src/lib/components/icons/PandaDocIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/PaypalIcon.svelte b/frontend/src/lib/components/icons/PaypalIcon.svelte new file mode 100644 index 0000000000..d35b228e7f --- /dev/null +++ b/frontend/src/lib/components/icons/PaypalIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/PersonioIcon.svelte b/frontend/src/lib/components/icons/PersonioIcon.svelte new file mode 100644 index 0000000000..e33dbd0817 --- /dev/null +++ b/frontend/src/lib/components/icons/PersonioIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/PinterestIcon.svelte b/frontend/src/lib/components/icons/PinterestIcon.svelte new file mode 100644 index 0000000000..ac10cf2d7a --- /dev/null +++ b/frontend/src/lib/components/icons/PinterestIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/PipedriveIcon.svelte b/frontend/src/lib/components/icons/PipedriveIcon.svelte new file mode 100644 index 0000000000..70cce8aef5 --- /dev/null +++ b/frontend/src/lib/components/icons/PipedriveIcon.svelte @@ -0,0 +1,16 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/PlanetScaleIcon.svelte b/frontend/src/lib/components/icons/PlanetScaleIcon.svelte new file mode 100644 index 0000000000..e2948bffb0 --- /dev/null +++ b/frontend/src/lib/components/icons/PlanetScaleIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/PostmarkIcon.svelte b/frontend/src/lib/components/icons/PostmarkIcon.svelte new file mode 100644 index 0000000000..7e2cba89d5 --- /dev/null +++ b/frontend/src/lib/components/icons/PostmarkIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/PusherIcon.svelte b/frontend/src/lib/components/icons/PusherIcon.svelte new file mode 100644 index 0000000000..5f595760ea --- /dev/null +++ b/frontend/src/lib/components/icons/PusherIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/QuickbooksIcon.svelte b/frontend/src/lib/components/icons/QuickbooksIcon.svelte index aec9eebc79..8a24b47488 100644 --- a/frontend/src/lib/components/icons/QuickbooksIcon.svelte +++ b/frontend/src/lib/components/icons/QuickbooksIcon.svelte @@ -1,10 +1,22 @@ - \ No newline at end of file + diff --git a/frontend/src/lib/components/icons/RaindropIcon.svelte b/frontend/src/lib/components/icons/RaindropIcon.svelte new file mode 100644 index 0000000000..06d1fec6f4 --- /dev/null +++ b/frontend/src/lib/components/icons/RaindropIcon.svelte @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/ReadwiseIcon.svelte b/frontend/src/lib/components/icons/ReadwiseIcon.svelte new file mode 100644 index 0000000000..17e97e359b --- /dev/null +++ b/frontend/src/lib/components/icons/ReadwiseIcon.svelte @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/RenderIcon.svelte b/frontend/src/lib/components/icons/RenderIcon.svelte new file mode 100644 index 0000000000..e73b8b232f --- /dev/null +++ b/frontend/src/lib/components/icons/RenderIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ReplicateIcon.svelte b/frontend/src/lib/components/icons/ReplicateIcon.svelte new file mode 100644 index 0000000000..133f2b75ab --- /dev/null +++ b/frontend/src/lib/components/icons/ReplicateIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ResendIcon.svelte b/frontend/src/lib/components/icons/ResendIcon.svelte index 28e51bc152..6d224a5cd6 100644 --- a/frontend/src/lib/components/icons/ResendIcon.svelte +++ b/frontend/src/lib/components/icons/ResendIcon.svelte @@ -8,5 +8,5 @@ - + \ No newline at end of file diff --git a/frontend/src/lib/components/icons/RestIcon.svelte b/frontend/src/lib/components/icons/RestIcon.svelte index eb0833ed0e..f6a29899d1 100644 --- a/frontend/src/lib/components/icons/RestIcon.svelte +++ b/frontend/src/lib/components/icons/RestIcon.svelte @@ -1,10 +1,10 @@ diff --git a/frontend/src/lib/components/icons/RingCentralIcon.svelte b/frontend/src/lib/components/icons/RingCentralIcon.svelte new file mode 100644 index 0000000000..1850e965e8 --- /dev/null +++ b/frontend/src/lib/components/icons/RingCentralIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/RocketChatIcon.svelte b/frontend/src/lib/components/icons/RocketChatIcon.svelte new file mode 100644 index 0000000000..eaadd9f70f --- /dev/null +++ b/frontend/src/lib/components/icons/RocketChatIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/RunPodIcon.svelte b/frontend/src/lib/components/icons/RunPodIcon.svelte new file mode 100644 index 0000000000..c5e94c4f3f --- /dev/null +++ b/frontend/src/lib/components/icons/RunPodIcon.svelte @@ -0,0 +1,13 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/RustIcon.svelte b/frontend/src/lib/components/icons/RustIcon.svelte index 364dece5be..c2bcbf614c 100644 --- a/frontend/src/lib/components/icons/RustIcon.svelte +++ b/frontend/src/lib/components/icons/RustIcon.svelte @@ -1,10 +1,10 @@ - - + diff --git a/frontend/src/lib/components/icons/SalesforceIcon.svelte b/frontend/src/lib/components/icons/SalesforceIcon.svelte new file mode 100644 index 0000000000..48e37a8cd5 --- /dev/null +++ b/frontend/src/lib/components/icons/SalesforceIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/SegmentIcon.svelte b/frontend/src/lib/components/icons/SegmentIcon.svelte new file mode 100644 index 0000000000..c7f6231d73 --- /dev/null +++ b/frontend/src/lib/components/icons/SegmentIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/SentryIcon.svelte b/frontend/src/lib/components/icons/SentryIcon.svelte new file mode 100644 index 0000000000..637923b99b --- /dev/null +++ b/frontend/src/lib/components/icons/SentryIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ServiceNowIcon.svelte b/frontend/src/lib/components/icons/ServiceNowIcon.svelte new file mode 100644 index 0000000000..9b2994ed54 --- /dev/null +++ b/frontend/src/lib/components/icons/ServiceNowIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ShortcutIcon.svelte b/frontend/src/lib/components/icons/ShortcutIcon.svelte new file mode 100644 index 0000000000..f03d6ea6cc --- /dev/null +++ b/frontend/src/lib/components/icons/ShortcutIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/SigNozIcon.svelte b/frontend/src/lib/components/icons/SigNozIcon.svelte new file mode 100644 index 0000000000..7243776c88 --- /dev/null +++ b/frontend/src/lib/components/icons/SigNozIcon.svelte @@ -0,0 +1,13 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/SmartsheetIcon.svelte b/frontend/src/lib/components/icons/SmartsheetIcon.svelte new file mode 100644 index 0000000000..9085ff8cb0 --- /dev/null +++ b/frontend/src/lib/components/icons/SmartsheetIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/SpeechifyIcon.svelte b/frontend/src/lib/components/icons/SpeechifyIcon.svelte new file mode 100644 index 0000000000..e2fc29e099 --- /dev/null +++ b/frontend/src/lib/components/icons/SpeechifyIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/SplitwiseIcon.svelte b/frontend/src/lib/components/icons/SplitwiseIcon.svelte new file mode 100644 index 0000000000..3d88d66dd0 --- /dev/null +++ b/frontend/src/lib/components/icons/SplitwiseIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/StravaIcon.svelte b/frontend/src/lib/components/icons/StravaIcon.svelte new file mode 100644 index 0000000000..d0d1d52ca6 --- /dev/null +++ b/frontend/src/lib/components/icons/StravaIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/TallyIcon.svelte b/frontend/src/lib/components/icons/TallyIcon.svelte new file mode 100644 index 0000000000..f494f124bb --- /dev/null +++ b/frontend/src/lib/components/icons/TallyIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/TelnyxIcon.svelte b/frontend/src/lib/components/icons/TelnyxIcon.svelte new file mode 100644 index 0000000000..a326114085 --- /dev/null +++ b/frontend/src/lib/components/icons/TelnyxIcon.svelte @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/ThreadsIcon.svelte b/frontend/src/lib/components/icons/ThreadsIcon.svelte new file mode 100644 index 0000000000..67a09e86bf --- /dev/null +++ b/frontend/src/lib/components/icons/ThreadsIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/TodoistIcon.svelte b/frontend/src/lib/components/icons/TodoistIcon.svelte new file mode 100644 index 0000000000..fee8f85134 --- /dev/null +++ b/frontend/src/lib/components/icons/TodoistIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/TogetherAiIcon.svelte b/frontend/src/lib/components/icons/TogetherAiIcon.svelte new file mode 100644 index 0000000000..fd71832e7d --- /dev/null +++ b/frontend/src/lib/components/icons/TogetherAiIcon.svelte @@ -0,0 +1,29 @@ + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/TrelloIcon.svelte b/frontend/src/lib/components/icons/TrelloIcon.svelte index c93ecc1b70..b37ffa6eac 100644 --- a/frontend/src/lib/components/icons/TrelloIcon.svelte +++ b/frontend/src/lib/components/icons/TrelloIcon.svelte @@ -1,10 +1,31 @@ - \ No newline at end of file + diff --git a/frontend/src/lib/components/icons/TursoIcon.svelte b/frontend/src/lib/components/icons/TursoIcon.svelte new file mode 100644 index 0000000000..91514bcde0 --- /dev/null +++ b/frontend/src/lib/components/icons/TursoIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/TwitchIcon.svelte b/frontend/src/lib/components/icons/TwitchIcon.svelte new file mode 100644 index 0000000000..1b88756005 --- /dev/null +++ b/frontend/src/lib/components/icons/TwitchIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/TwitterIcon.svelte b/frontend/src/lib/components/icons/TwitterIcon.svelte new file mode 100644 index 0000000000..55e510c346 --- /dev/null +++ b/frontend/src/lib/components/icons/TwitterIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/TypeformIcon.svelte b/frontend/src/lib/components/icons/TypeformIcon.svelte index c9f0101c0b..6e2f07560d 100644 --- a/frontend/src/lib/components/icons/TypeformIcon.svelte +++ b/frontend/src/lib/components/icons/TypeformIcon.svelte @@ -18,6 +18,6 @@ > diff --git a/frontend/src/lib/components/icons/VercelIcon.svelte b/frontend/src/lib/components/icons/VercelIcon.svelte new file mode 100644 index 0000000000..89be3c8070 --- /dev/null +++ b/frontend/src/lib/components/icons/VercelIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/WebflowIcon.svelte b/frontend/src/lib/components/icons/WebflowIcon.svelte new file mode 100644 index 0000000000..a445732c22 --- /dev/null +++ b/frontend/src/lib/components/icons/WebflowIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/WooCommerceIcon.svelte b/frontend/src/lib/components/icons/WooCommerceIcon.svelte new file mode 100644 index 0000000000..cadf03e232 --- /dev/null +++ b/frontend/src/lib/components/icons/WooCommerceIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/WordpressIcon.svelte b/frontend/src/lib/components/icons/WordpressIcon.svelte new file mode 100644 index 0000000000..b84d5b9c17 --- /dev/null +++ b/frontend/src/lib/components/icons/WordpressIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/XataIcon.svelte b/frontend/src/lib/components/icons/XataIcon.svelte new file mode 100644 index 0000000000..033dd2e331 --- /dev/null +++ b/frontend/src/lib/components/icons/XataIcon.svelte @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/YelpIcon.svelte b/frontend/src/lib/components/icons/YelpIcon.svelte new file mode 100644 index 0000000000..636b466b07 --- /dev/null +++ b/frontend/src/lib/components/icons/YelpIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/YnabIcon.svelte b/frontend/src/lib/components/icons/YnabIcon.svelte new file mode 100644 index 0000000000..24372aab69 --- /dev/null +++ b/frontend/src/lib/components/icons/YnabIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/YoutubeIcon.svelte b/frontend/src/lib/components/icons/YoutubeIcon.svelte new file mode 100644 index 0000000000..674c3e8052 --- /dev/null +++ b/frontend/src/lib/components/icons/YoutubeIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ZendeskIcon.svelte b/frontend/src/lib/components/icons/ZendeskIcon.svelte index c52b7295f2..45423b520e 100644 --- a/frontend/src/lib/components/icons/ZendeskIcon.svelte +++ b/frontend/src/lib/components/icons/ZendeskIcon.svelte @@ -9,7 +9,7 @@ - + diff --git a/frontend/src/lib/components/icons/ZeroTierIcon.svelte b/frontend/src/lib/components/icons/ZeroTierIcon.svelte new file mode 100644 index 0000000000..57bc4ba1a1 --- /dev/null +++ b/frontend/src/lib/components/icons/ZeroTierIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ZoomIcon.svelte b/frontend/src/lib/components/icons/ZoomIcon.svelte new file mode 100644 index 0000000000..e68d208181 --- /dev/null +++ b/frontend/src/lib/components/icons/ZoomIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/index.ts b/frontend/src/lib/components/icons/index.ts index 07f74d477f..78767697a1 100644 --- a/frontend/src/lib/components/icons/index.ts +++ b/frontend/src/lib/components/icons/index.ts @@ -104,6 +104,116 @@ import McpIcon from './McpIcon.svelte' import SageIcon from './SageIcon.svelte' import ZohoIcon from './ZohoIcon.svelte' import PocketIdIcon from './PocketIdIcon.svelte' +import DuckDbIcon from './DuckDbIcon.svelte' +import ActiveCampaignIcon from './ActiveCampaignIcon.svelte' +import AlgoliaIcon from './AlgoliaIcon.svelte' +import BambooHrIcon from './BambooHrIcon.svelte' +import BaremetricsIcon from './BaremetricsIcon.svelte' +import BitlyIcon from './BitlyIcon.svelte' +import BloggerIcon from './BloggerIcon.svelte' +import BlueskyIcon from './BlueskyIcon.svelte' +import BoxIcon from './BoxIcon.svelte' +import BrevoIcon from './BrevoIcon.svelte' +import BuildkiteIcon from './BuildkiteIcon.svelte' +import CalendlyIcon from './CalendlyIcon.svelte' +import CircleCiIcon from './CircleCiIcon.svelte' +import CiscoIcon from './CiscoIcon.svelte' +import ClerkIcon from './ClerkIcon.svelte' +import CloudinaryIcon from './CloudinaryIcon.svelte' +import CockroachDbIcon from './CockroachDbIcon.svelte' +import CodaIcon from './CodaIcon.svelte' +import CoinbaseIcon from './CoinbaseIcon.svelte' +import CoinMarketCapIcon from './CoinMarketCapIcon.svelte' +import ConfluenceIcon from './ConfluenceIcon.svelte' +import ContentfulIcon from './ContentfulIcon.svelte' +import DatoCmsIcon from './DatoCmsIcon.svelte' +import DeepLIcon from './DeepLIcon.svelte' +import DigitalOceanIcon from './DigitalOceanIcon.svelte' +import DiscourseIcon from './DiscourseIcon.svelte' +import DocusignIcon from './DocusignIcon.svelte' +import DropboxIcon from './DropboxIcon.svelte' +import EventbriteIcon from './EventbriteIcon.svelte' +import FigmaIcon from './FigmaIcon.svelte' +import FlyIcon from './FlyIcon.svelte' +import FreshdeskIcon from './FreshdeskIcon.svelte' +import FrontAppIcon from './FrontAppIcon.svelte' +import GhostCmsIcon from './GhostCmsIcon.svelte' +import GiphyIcon from './GiphyIcon.svelte' +import GitBookIcon from './GitBookIcon.svelte' +import HoneybadgerIcon from './HoneybadgerIcon.svelte' +import IftttIcon from './IftttIcon.svelte' +import IntercomIcon from './IntercomIcon.svelte' +import LineIcon from './LineIcon.svelte' +import LinearIcon from './LinearIcon.svelte' +import LinodeIcon from './LinodeIcon.svelte' +import MediumIcon from './MediumIcon.svelte' +import MiroIcon from './MiroIcon.svelte' +import MistralIcon from './MistralIcon.svelte' +import MixpanelIcon from './MixpanelIcon.svelte' +import MondayIcon from './MondayIcon.svelte' +import NeonDbIcon from './NeonDbIcon.svelte' +import NetlifyIcon from './NetlifyIcon.svelte' +import OneSignalIcon from './OneSignalIcon.svelte' +import PagerDutyIcon from './PagerDutyIcon.svelte' +import PandaDocIcon from './PandaDocIcon.svelte' +import PaypalIcon from './PaypalIcon.svelte' +import PersonioIcon from './PersonioIcon.svelte' +import PinterestIcon from './PinterestIcon.svelte' +import PipedriveIcon from './PipedriveIcon.svelte' +import PlanetScaleIcon from './PlanetScaleIcon.svelte' +import PostmarkIcon from './PostmarkIcon.svelte' +import PusherIcon from './PusherIcon.svelte' +import RenderIcon from './RenderIcon.svelte' +import ReplicateIcon from './ReplicateIcon.svelte' +import RingCentralIcon from './RingCentralIcon.svelte' +import SalesforceIcon from './SalesforceIcon.svelte' +import SegmentIcon from './SegmentIcon.svelte' +import SentryIcon from './SentryIcon.svelte' +import ServiceNowIcon from './ServiceNowIcon.svelte' +import ShortcutIcon from './ShortcutIcon.svelte' +import SmartsheetIcon from './SmartsheetIcon.svelte' +import StravaIcon from './StravaIcon.svelte' +import ThreadsIcon from './ThreadsIcon.svelte' +import TodoistIcon from './TodoistIcon.svelte' +import TursoIcon from './TursoIcon.svelte' +import TwitchIcon from './TwitchIcon.svelte' +import TwitterIcon from './TwitterIcon.svelte' +import VercelIcon from './VercelIcon.svelte' +import WebflowIcon from './WebflowIcon.svelte' +import WooCommerceIcon from './WooCommerceIcon.svelte' +import WordpressIcon from './WordpressIcon.svelte' +import XataIcon from './XataIcon.svelte' +import YelpIcon from './YelpIcon.svelte' +import YoutubeIcon from './YoutubeIcon.svelte' +import ZoomIcon from './ZoomIcon.svelte' +import CohereIcon from './CohereIcon.svelte' +import TallyIcon from './TallyIcon.svelte' +import ClearbitIcon from './ClearbitIcon.svelte' +import RaindropIcon from './RaindropIcon.svelte' +import MagentoIcon from './MagentoIcon.svelte' +import DeelIcon from './DeelIcon.svelte' +import GroqIcon from './GroqIcon.svelte' +import TogetherAiIcon from './TogetherAiIcon.svelte' +import RunPodIcon from './RunPodIcon.svelte' +import SigNozIcon from './SigNozIcon.svelte' +import ReadwiseIcon from './ReadwiseIcon.svelte' +import LumaAiIcon from './LumaAiIcon.svelte' +import BrexIcon from './BrexIcon.svelte' +import CloseIcon from './CloseIcon.svelte' +import RocketChatIcon from './RocketChatIcon.svelte' +import ApolloIcon from './ApolloIcon.svelte' +import BubbleIcon from './BubbleIcon.svelte' +import JoomlaIcon from './JoomlaIcon.svelte' +import MauticIcon from './MauticIcon.svelte' +import ZeroTierIcon from './ZeroTierIcon.svelte' +import SplitwiseIcon from './SplitwiseIcon.svelte' +import TelnyxIcon from './TelnyxIcon.svelte' +import MandrillIcon from './MandrillIcon.svelte' +import OpenWeatherIcon from './OpenWeatherIcon.svelte' +import YnabIcon from './YnabIcon.svelte' +import SpeechifyIcon from './SpeechifyIcon.svelte' +import ConvertKitIcon from './ConvertKitIcon.svelte' +import BrowserlessIcon from './BrowserlessIcon.svelte' import type { Component } from 'svelte' export const APP_TO_ICON_COMPONENT = { postgresql: PostgresIcon, @@ -194,6 +304,7 @@ export const APP_TO_ICON_COMPONENT = { pushover: PushoverIcon, quickbooks: QuickbooksIcon, ms_teams_webhook: MsTeamsIcon, + teams: MsTeamsIcon, mailgun: MailgunIcon, ipinfo: IpinfoIcon, gworkspace: GoogleIcon, @@ -213,7 +324,119 @@ export const APP_TO_ICON_COMPONENT = { apify: ApifyIcon, mcp: McpIcon, zoho: ZohoIcon, - pocketid: PocketIdIcon + pocketid: PocketIdIcon, + duckdb: DuckDbIcon, + activecampaign: ActiveCampaignIcon, + algolia: AlgoliaIcon, + bamboo_hr: BambooHrIcon, + baremetrics: BaremetricsIcon, + bitly: BitlyIcon, + blogger: BloggerIcon, + bluesky: BlueskyIcon, + box: BoxIcon, + brevo: BrevoIcon, + sendinblue: BrevoIcon, + buildkite: BuildkiteIcon, + calendly: CalendlyIcon, + circleci: CircleCiIcon, + cisco: CiscoIcon, + clerk: ClerkIcon, + cloudinary: CloudinaryIcon, + cockroachdb: CockroachDbIcon, + coda: CodaIcon, + coinbase: CoinbaseIcon, + coinmarketcap: CoinMarketCapIcon, + confluence: ConfluenceIcon, + contentful: ContentfulIcon, + datocms: DatoCmsIcon, + deepl: DeepLIcon, + digitalocean: DigitalOceanIcon, + discourse: DiscourseIcon, + docusign: DocusignIcon, + dropbox: DropboxIcon, + eventbrite: EventbriteIcon, + figma: FigmaIcon, + fly: FlyIcon, + freshdesk: FreshdeskIcon, + frontapp: FrontAppIcon, + ghostcms: GhostCmsIcon, + giphy: GiphyIcon, + gitbook: GitBookIcon, + honeybadger: HoneybadgerIcon, + ifttt: IftttIcon, + intercom: IntercomIcon, + line: LineIcon, + linear: LinearIcon, + linode: LinodeIcon, + medium: MediumIcon, + miro: MiroIcon, + mistral: MistralIcon, + mixpanel: MixpanelIcon, + monday: MondayIcon, + neondb: NeonDbIcon, + netlify: NetlifyIcon, + onesignal: OneSignalIcon, + pagerduty: PagerDutyIcon, + pandadoc: PandaDocIcon, + paypal: PaypalIcon, + personio: PersonioIcon, + pinterest: PinterestIcon, + pipedrive: PipedriveIcon, + planetscale: PlanetScaleIcon, + postmark: PostmarkIcon, + pusher: PusherIcon, + render: RenderIcon, + replicate: ReplicateIcon, + ringcentral: RingCentralIcon, + salesforce: SalesforceIcon, + segment: SegmentIcon, + sentry: SentryIcon, + servicenow: ServiceNowIcon, + shortcut: ShortcutIcon, + smartsheet: SmartsheetIcon, + strava: StravaIcon, + threads: ThreadsIcon, + todoist: TodoistIcon, + turso: TursoIcon, + twitch: TwitchIcon, + twitter: TwitterIcon, + vercel: VercelIcon, + webflow: WebflowIcon, + woocommerce: WooCommerceIcon, + wordpress: WordpressIcon, + xata: XataIcon, + yelp: YelpIcon, + youtube: YoutubeIcon, + zoom: ZoomIcon, + cohere: CohereIcon, + tally: TallyIcon, + clearbit: ClearbitIcon, + raindrop: RaindropIcon, + magento: MagentoIcon, + deel: DeelIcon, + groqai: GroqIcon, + togetherai: TogetherAiIcon, + runpod: RunPodIcon, + signoz: SigNozIcon, + readwise: ReadwiseIcon, + lumaai: LumaAiIcon, + git: GitIcon, + brex: BrexIcon, + close: CloseIcon, + rocketchat: RocketChatIcon, + apollo: ApolloIcon, + bubble: BubbleIcon, + joomla: JoomlaIcon, + mautic: MauticIcon, + zerotier: ZeroTierIcon, + splitwise: SplitwiseIcon, + telnyx: TelnyxIcon, + mandrill: MandrillIcon, + openweather: OpenWeatherIcon, + ynab: YnabIcon, + speechify: SpeechifyIcon, + convertkit: ConvertKitIcon, + browserless: BrowserlessIcon } as unknown as Record // to generate correct svelte package types export { @@ -314,5 +537,115 @@ export { MqttIcon, ApifyIcon, McpIcon, - ZohoIcon + ZohoIcon, + DuckDbIcon, + ActiveCampaignIcon, + AlgoliaIcon, + BambooHrIcon, + BaremetricsIcon, + BitlyIcon, + BloggerIcon, + BlueskyIcon, + BoxIcon, + BrevoIcon, + BuildkiteIcon, + CalendlyIcon, + CircleCiIcon, + CiscoIcon, + ClerkIcon, + CloudinaryIcon, + CockroachDbIcon, + CodaIcon, + CoinbaseIcon, + CoinMarketCapIcon, + ConfluenceIcon, + ContentfulIcon, + DatoCmsIcon, + DeepLIcon, + DigitalOceanIcon, + DiscourseIcon, + DocusignIcon, + DropboxIcon, + EventbriteIcon, + FigmaIcon, + FlyIcon, + FreshdeskIcon, + FrontAppIcon, + GhostCmsIcon, + GiphyIcon, + GitBookIcon, + HoneybadgerIcon, + IftttIcon, + IntercomIcon, + LineIcon, + LinearIcon, + LinodeIcon, + MediumIcon, + MiroIcon, + MistralIcon, + MixpanelIcon, + MondayIcon, + NeonDbIcon, + NetlifyIcon, + OneSignalIcon, + PagerDutyIcon, + PandaDocIcon, + PaypalIcon, + PersonioIcon, + PinterestIcon, + PipedriveIcon, + PlanetScaleIcon, + PostmarkIcon, + PusherIcon, + RenderIcon, + ReplicateIcon, + RingCentralIcon, + SalesforceIcon, + SegmentIcon, + SentryIcon, + ServiceNowIcon, + ShortcutIcon, + SmartsheetIcon, + StravaIcon, + ThreadsIcon, + TodoistIcon, + TursoIcon, + TwitchIcon, + TwitterIcon, + VercelIcon, + WebflowIcon, + WooCommerceIcon, + WordpressIcon, + XataIcon, + YelpIcon, + YoutubeIcon, + ZoomIcon, + CohereIcon, + TallyIcon, + ClearbitIcon, + RaindropIcon, + MagentoIcon, + DeelIcon, + GroqIcon, + TogetherAiIcon, + RunPodIcon, + SigNozIcon, + ReadwiseIcon, + LumaAiIcon, + BrexIcon, + CloseIcon, + RocketChatIcon, + ApolloIcon, + BubbleIcon, + JoomlaIcon, + MauticIcon, + ZeroTierIcon, + SplitwiseIcon, + TelnyxIcon, + MandrillIcon, + OpenWeatherIcon, + YnabIcon, + SpeechifyIcon, + ConvertKitIcon, + BrowserlessIcon } From 920a7f9fa4719015885947b9de0c35e5e618fcc8 Mon Sep 17 00:00:00 2001 From: wendrul <53628737+wendrul@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:04:18 +0100 Subject: [PATCH 093/204] fix: devops getting logged out on workers page (#8416) * fix: devops getting logged out on workers page * rename local vars --- backend/windmill-api-workers/src/lib.rs | 24 +++++++++---------- .../windmill-api-workspaces/src/workspaces.rs | 4 ++-- .../src/lib/components/WorkerGroup.svelte | 6 ++++- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/backend/windmill-api-workers/src/lib.rs b/backend/windmill-api-workers/src/lib.rs index 6cfa398ac4..d4a6225e9d 100644 --- a/backend/windmill-api-workers/src/lib.rs +++ b/backend/windmill-api-workers/src/lib.rs @@ -24,7 +24,7 @@ use windmill_common::{ DB, }; -use windmill_api_auth::{require_super_admin, ApiAuthed}; +use windmill_api_auth::{require_devops_role, ApiAuthed}; pub fn global_service() -> Router { Router::new() @@ -98,8 +98,8 @@ async fn list_worker_pings( Extension(user_db): Extension, Query(query): Query, ) -> JsonResult> { - let is_super_admin = require_super_admin(&db, &authed.email).await.is_ok(); - if *HIDE_WORKERS_FOR_NON_ADMINS && !is_super_admin { + let has_devops_role = require_devops_role(&db, &authed.email).await.is_ok(); + if *HIDE_WORKERS_FOR_NON_ADMINS && !has_devops_role { return Ok(Json(vec![])); } let mut tx = user_db.begin(&authed).await?; @@ -117,13 +117,13 @@ async fn list_worker_pings( query.ping_since, per_page as i64, offset as i64, - is_super_admin + has_devops_role ) .fetch_all(&mut *tx) .await?; tx.commit().await?; - let rows = if *TAGS_ARE_SENSITIVE && !is_super_admin { + let rows = if *TAGS_ARE_SENSITIVE && !has_devops_role { rows.into_iter() .map(|mut w| { w.custom_tags = None; @@ -154,8 +154,8 @@ async fn exists_workers_with_tags( // When TAGS_ARE_SENSITIVE is enabled, filter tags based on workspace visibility if *TAGS_ARE_SENSITIVE { - let is_super_admin = require_super_admin(&db, &authed.email).await.is_ok(); - if !is_super_admin { + let has_devops_role = require_devops_role(&db, &authed.email).await.is_ok(); + if !has_devops_role { if let Some(ref workspace) = tags_query.workspace { // Filter to only tags visible in this workspace let custom_tags = CUSTOM_TAGS_PER_WORKSPACE.read().await; @@ -208,8 +208,8 @@ async fn get_custom_tags( return Ok(Json(all_tags)); } if *TAGS_ARE_SENSITIVE { - let is_super_admin = require_super_admin(&db, &authed.email).await.is_ok(); - if !is_super_admin { + let has_devops_role = require_devops_role(&db, &authed.email).await.is_ok(); + if !has_devops_role { return Ok(Json(vec![])); } } @@ -245,7 +245,7 @@ async fn get_queue_metrics( authed: ApiAuthed, Extension(db): Extension, ) -> JsonResult> { - require_super_admin(&db, &authed.email).await?; + require_devops_role(&db, &authed.email).await?; let queue_metrics = sqlx::query_as!( QueueMetric, @@ -270,7 +270,7 @@ async fn get_queue_counts( authed: ApiAuthed, Extension(db): Extension, ) -> JsonResult> { - require_super_admin(&db, &authed.email).await?; + require_devops_role(&db, &authed.email).await?; let queue_counts = windmill_common::queue::get_queue_counts(&db).await; Ok(Json(queue_counts)) } @@ -279,7 +279,7 @@ async fn get_queue_running_counts( authed: ApiAuthed, Extension(db): Extension, ) -> JsonResult> { - require_super_admin(&db, &authed.email).await?; + require_devops_role(&db, &authed.email).await?; let queue_running_counts = windmill_common::queue::get_queue_running_counts(&db).await; Ok(Json(queue_running_counts)) } diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index ea6923c6d8..92c604de33 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -6,7 +6,7 @@ * LICENSE-AGPL for a copy of the license. */ -use windmill_api_auth::{require_super_admin, ApiAuthed}; +use windmill_api_auth::{require_devops_role, require_super_admin, ApiAuthed}; use windmill_api_users::users::WorkspaceInvite; use windmill_common::email_oss::send_email_if_possible; use windmill_common::usernames::{get_instance_username_or_create_pending, VALID_USERNAME}; @@ -2586,7 +2586,7 @@ async fn list_workspaces_as_super_admin( Query(pagination): Query, ApiAuthed { email, .. }: ApiAuthed, ) -> JsonResult> { - require_super_admin(&db, &email).await?; + require_devops_role(&db, &email).await?; let (per_page, offset) = paginate(pagination); let mut tx = user_db.begin(&authed).await?; diff --git a/frontend/src/lib/components/WorkerGroup.svelte b/frontend/src/lib/components/WorkerGroup.svelte index c050d2b5c0..fdb0320c2a 100644 --- a/frontend/src/lib/components/WorkerGroup.svelte +++ b/frontend/src/lib/components/WorkerGroup.svelte @@ -230,7 +230,11 @@ let workspaces: Workspace[] = $state([]) async function listWorkspaces() { - workspaces = await WorkspaceService.listWorkspacesAsSuperAdmin() + try { + workspaces = await WorkspaceService.listWorkspacesAsSuperAdmin() + } catch (e) { + console.error('Failed to list workspaces', e) + } } // Centralized permission logic From c4c524fade0193d00e9d87e8ad5c27846eb47acf Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 17 Mar 2026 19:55:22 +0000 Subject: [PATCH 094/204] wac v2 improvements (#8419) * all * sqlx --- ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- backend/windmill-worker/src/bun_executor.rs | 63 +++- backend/windmill-worker/src/wac_executor.rs | 15 + .../lib/components/WorkflowTimeline.svelte | 289 ++++++++++-------- typescript-client/package-lock.json | 4 +- typescript-client/package.json | 2 +- 6 files changed, 240 insertions(+), 135 deletions(-) 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/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index b4b0b0b112..c3065be7d2 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -2678,6 +2678,33 @@ pub async fn handle_wac_v2_output( error::Error::internal_err(format!("Failed to save approval meta: {e}")) })?; + // Write timeline entry for the approval step + { + let now_str = chrono::Utc::now().to_rfc3339(); + let timeline_val = serde_json::json!({ + "scheduled_for": &now_str, + "started_at": &now_str, + "name": key, + "approval": true, + }); + let step_timeline_key = format!("_step/{}", key); + sqlx::query( + "UPDATE v2_job_status SET workflow_as_code_status = jsonb_set( + COALESCE(workflow_as_code_status, '{}'::jsonb), + ARRAY[$2], + $3 + ) WHERE id = $1", + ) + .bind(&job.id) + .bind(&step_timeline_key) + .bind(&timeline_val) + .execute(&mut *tx) + .await + .map_err(|e| { + error::Error::internal_err(format!("Failed to write approval timeline: {e}")) + })?; + } + // Suspend parent with suspend=1 (waiting for 1 approval event) sqlx::query!( "UPDATE v2_job_queue SET suspend = 1, suspend_until = now() + make_interval(secs => $2) WHERE id = $1", @@ -2743,6 +2770,34 @@ pub async fn handle_wac_v2_output( .await .map_err(|e| error::Error::internal_err(format!("Failed to save checkpoint: {e}")))?; + // Write a "sleep" marker in the timeline. Unlike real steps it + // carries no execution bar — the frontend renders it as a + // minimal label row (e.g. "sleep (2s)"). + { + let now_str = chrono::Utc::now().to_rfc3339(); + let timeline_val = serde_json::json!({ + "scheduled_for": &now_str, + "name": key, + "sleep_duration_s": seconds, + }); + let step_timeline_key = format!("_step/{}", key); + sqlx::query( + "UPDATE v2_job_status SET workflow_as_code_status = jsonb_set( + COALESCE(workflow_as_code_status, '{}'::jsonb), + ARRAY[$2], + $3 + ) WHERE id = $1", + ) + .bind(&job.id) + .bind(&step_timeline_key) + .bind(&timeline_val) + .execute(&mut *tx) + .await + .map_err(|e| { + error::Error::internal_err(format!("Failed to write sleep timeline: {e}")) + })?; + } + // Suspend parent — it will auto-resume when suspend_until passes. // Use suspend=1 (not 0) so the suspended pull query only picks it up // when `suspend_until <= now()`, not via `suspend <= 0`. @@ -2828,8 +2883,12 @@ pub async fn handle_wac_v2_output( error::Error::internal_err(format!("Failed to save WAC checkpoint: {e}")) })?; - // Write timeline entry for the inline step (keyed as _step/) - if let Some(ref sa) = started_at { + // Write timeline entry for the inline step (keyed as _step/). + // Fall back to now() when the client doesn't provide started_at + // (older windmill-client versions omit it). + { + let now_str = chrono::Utc::now().to_rfc3339(); + let sa = started_at.as_deref().unwrap_or(&now_str); let mut timeline_val = serde_json::json!({ "scheduled_for": sa, "started_at": sa, diff --git a/backend/windmill-worker/src/wac_executor.rs b/backend/windmill-worker/src/wac_executor.rs index 1e7ed4b090..208012ccce 100644 --- a/backend/windmill-worker/src/wac_executor.rs +++ b/backend/windmill-worker/src/wac_executor.rs @@ -259,6 +259,21 @@ pub async fn prepare_checkpoint_for_resume( checkpoint.pending_steps = None; save_checkpoint(db, job_id, &checkpoint).await?; + // Update the approval step's timeline entry with duration_ms + let step_timeline_key = format!("_step/{}", approval_key); + sqlx::query( + "UPDATE v2_job_status SET workflow_as_code_status = jsonb_set( + workflow_as_code_status, + ARRAY[$2, 'duration_ms'], + to_jsonb(EXTRACT(EPOCH FROM (now() - (workflow_as_code_status->$2->>'started_at')::timestamptz)) * 1000) + ) WHERE id = $1 AND workflow_as_code_status ? $2", + ) + .bind(job_id) + .bind(&step_timeline_key) + .execute(db) + .await + .ok(); // best-effort + tracing::info!( job_id = %job_id, approval_key = %approval_key, diff --git a/frontend/src/lib/components/WorkflowTimeline.svelte b/frontend/src/lib/components/WorkflowTimeline.svelte index d71a4bc015..037ff62df2 100644 --- a/frontend/src/lib/components/WorkflowTimeline.svelte +++ b/frontend/src/lib/components/WorkflowTimeline.svelte @@ -3,7 +3,7 @@ import { displayDate, msToSec } from '$lib/utils' import { onDestroy } from 'svelte' import { getDbClockNow } from '$lib/forLater' - import { ChevronDown, ChevronRight, Loader2 } from 'lucide-svelte' + import { ChevronDown, ChevronRight, Loader2, Moon, ShieldCheck } from 'lucide-svelte' import TimelineBar from './TimelineBar.svelte' import LogViewer from './LogViewer.svelte' import ObjectViewer from './propertyPicker/ObjectViewer.svelte' @@ -20,7 +20,14 @@ autoExpandResult?: boolean } - let { flow_status, flowDone = false, stepResults = {}, result = undefined, success = true, autoExpandResult = false }: Props = $props() + let { + flow_status, + flowDone = false, + stepResults = {}, + result = undefined, + success = true, + autoExpandResult = false + }: Props = $props() let resultExpanded = $state(false) @@ -54,10 +61,7 @@ flowDone ? Object.values(flow_status).reduce( (a, b) => - Math.max( - a, - b.started_at ? new Date(b.started_at).getTime() + (b.duration_ms ?? 0) : 0 - ), + Math.max(a, b.started_at ? new Date(b.started_at).getTime() + (b.duration_ms ?? 0) : 0), 0 ) : undefined @@ -148,145 +152,172 @@ return ta - tb }) as [k, v] (k)} {@const isInlineStep = isStep(k)} + {@const isSleep = (v as any).sleep_duration_s != undefined} + {@const isApproval = (v as any).approval === true} {@const isRunning = v.duration_ms == undefined && v.started_at != undefined} {@const isDone = v.duration_ms != undefined} {@const isExpanded = expandedRows[k] ?? false}
- - - {#if isExpanded} -
- {#if isInlineStep} - - {@const result = stepResults[stepKey(k)]} - {#if isDone && result !== undefined} -
-
Result
-
- -
-
+ {#if isInlineStep} + + {v.name ?? stepKey(k)} + {:else} -
Step completed (no result)
- {/if} - {:else if loadingJobs[k] && !childJobs[k]} -
- - Loading... -
- {:else if childJobs[k]} - {@const job = childJobs[k]} - - {#if job.logs || isRunning} -
-
Logs
- -
+ e.stopPropagation()} + > + {v.name ?? k} + {/if} +
+
+ {#if min && total} + {@const scheduledFor = v?.scheduled_for + ? new Date(v?.scheduled_for).getTime() + : undefined} + {@const startedAt = v?.started_at ? new Date(v?.started_at).getTime() : undefined} + {@const waitingLen = scheduledFor + ? startedAt + ? startedAt - scheduledFor + : now - scheduledFor + : 0} - - {#if isDone && job.result !== undefined} -
-
Result
-
- -
+
+ {#if isInlineStep} + + {#if startedAt} + + {/if} + {:else} + + {#if startedAt} + + {/if} + {/if}
{/if} - {:else} -
No data available
- {/if} -
+
+ + + {#if isExpanded} +
+ {#if isInlineStep} + + {@const result = stepResults[stepKey(k)]} + {#if isDone && result !== undefined} +
+
Result
+
+ +
+
+ {:else} +
Step completed (no result)
+ {/if} + {:else if loadingJobs[k] && !childJobs[k]} +
+ + Loading... +
+ {:else if childJobs[k]} + {@const job = childJobs[k]} + + {#if job.logs || isRunning} +
+
Logs
+ +
+ {/if} + + + {#if isDone && job.result !== undefined} +
+
Result
+
+ +
+
+ {/if} + {:else} +
No data available
+ {/if} +
+ {/if} {/if}
{/each} diff --git a/typescript-client/package-lock.json b/typescript-client/package-lock.json index a25b51cbcb..20b2270c6e 100644 --- a/typescript-client/package-lock.json +++ b/typescript-client/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-client", - "version": "1.651.1", + "version": "1.999.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-client", - "version": "1.651.1", + "version": "1.999.21", "license": "Apache 2.0", "devDependencies": { "@types/node": "^20.17.16", diff --git a/typescript-client/package.json b/typescript-client/package.json index 2f3d36abf2..1206bae22a 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.658.0", + "version": "1.999.21", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, From 9f10b44c188749d075bfd946c8ae383be0eee67b Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Tue, 17 Mar 2026 16:12:04 -0400 Subject: [PATCH 095/204] =?UTF-8?q?update=20cloudformation=20template=20to?= =?UTF-8?q?=20use=20latest=20cli/images=20+=20fix=20cl=E2=80=A6=20(#8417)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: update cloudformation template to use latest cli/images + fix cleanup script * fix: narrow SG cleanup to k8s-created groups + add CLI install error handling Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../aws-eks-cloudformation/quicklaunch.yaml | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/examples/deploy/aws-eks-cloudformation/quicklaunch.yaml b/examples/deploy/aws-eks-cloudformation/quicklaunch.yaml index 3dda495ebe..5f2b4c42dc 100644 --- a/examples/deploy/aws-eks-cloudformation/quicklaunch.yaml +++ b/examples/deploy/aws-eks-cloudformation/quicklaunch.yaml @@ -58,38 +58,10 @@ Parameters: - false Description: Enable Windmill Enterprise features (requires license key) -Mappings: - RegionMap: - us-east-1: - AMI: ami-0cff7528ff583bf9a - us-east-2: - AMI: ami-0cd3c7f72edd5b06d - us-west-1: - AMI: ami-0d9858aa3c6322f73 - us-west-2: - AMI: ami-098e42ae54c764c35 - ca-central-1: - AMI: ami-00f881f027a6d74a0 - eu-west-1: - AMI: ami-04dd4500af104442f - eu-west-2: - AMI: ami-0eb260c4d5475b901 - eu-west-3: - AMI: ami-05e8e20cef0eaa9d0 - eu-central-1: - AMI: ami-0bad4a5e987bdebde - ap-northeast-1: - AMI: ami-0b7546e839d7ace12 - ap-northeast-2: - AMI: ami-0fd0765afb77bcca7 - ap-southeast-1: - AMI: ami-0c802847a7dd848c0 - ap-southeast-2: - AMI: ami-07620139298af599e - ap-south-1: - AMI: ami-0851b76e8b1bce90b - sa-east-1: - AMI: ami-054a31f1b3bf90920 + LatestAmiId: + Type: AWS::SSM::Parameter::Value + Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-x86_64 + Description: Latest Amazon Linux 2023 AMI (automatically resolved via SSM) Resources: VPC: @@ -345,7 +317,7 @@ Resources: - EKSNodeGroup - WindmillDB Properties: - ImageId: !FindInMap [RegionMap, !Ref "AWS::Region", AMI] + ImageId: !Ref LatestAmiId InstanceType: t3.micro IamInstanceProfile: !Ref WindmillInstallerInstanceProfile SubnetId: !Ref PublicSubnet1 @@ -358,7 +330,15 @@ Resources: # Install required tools yum update -y - yum install -y aws-cli jq postgresql15 aws-cfn-bootstrap + yum install -y jq postgresql15 aws-cfn-bootstrap unzip + + # Install AWS CLI v2 (yum aws-cli package is v1 and outdated) + echo "Installing AWS CLI v2..." + if ! (curl -sf "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" && unzip -q awscliv2.zip && ./aws/install); then + echo "ERROR: Failed to install AWS CLI v2" + exit 1 + fi + rm -rf aws awscliv2.zip # Set up logging directory with correct permissions mkdir -p /var/log/windmill-installer @@ -602,6 +582,8 @@ Resources: ZipFile: | const { ElasticLoadBalancingClient, DescribeLoadBalancersCommand, DeleteLoadBalancerCommand } = require('@aws-sdk/client-elastic-load-balancing'); + const { EC2Client, DescribeSecurityGroupsCommand, + DeleteSecurityGroupCommand } = require('@aws-sdk/client-ec2'); const response = require('cfn-response'); exports.handler = async (event, context) => { @@ -611,6 +593,7 @@ Resources: try { const elb = new ElasticLoadBalancingClient(); + const ec2 = new EC2Client(); const vpcId = event.ResourceProperties.VpcId; // Find and delete Classic Load Balancers in the VPC @@ -628,14 +611,29 @@ Resources: } if (deleted) { - // Wait for deletion to complete console.log('Waiting 30 seconds for load balancer deletion to complete...'); await new Promise(r => setTimeout(r, 30000)); } + // Delete Kubernetes-created security groups (e.g. k8s-elb-*) + const sgResponse = await ec2.send(new DescribeSecurityGroupsCommand({ + Filters: [{ Name: 'vpc-id', Values: [vpcId] }] + })); + + for (const sg of sgResponse.SecurityGroups || []) { + if (sg.GroupName !== 'default' && (sg.GroupName.startsWith('k8s-') || (sg.Tags || []).some(t => t.Key.startsWith('kubernetes.io/')))) { + console.log(`Deleting security group: ${sg.GroupId} (${sg.GroupName})`); + try { + await ec2.send(new DeleteSecurityGroupCommand({ GroupId: sg.GroupId })); + } catch (e) { + console.log(`Could not delete ${sg.GroupId}: ${e.message}`); + } + } + } + return response.send(event, context, response.SUCCESS); } catch (error) { - console.error('Error deleting load balancers:', error); + console.error('Error during VPC cleanup:', error); return response.send(event, context, response.FAILED, {error: error.message}); } }; @@ -662,6 +660,8 @@ Resources: - ec2:DescribeAddresses - ec2:DisassociateAddress - ec2:DescribeNetworkInterfaces + - ec2:DescribeSecurityGroups + - ec2:DeleteSecurityGroup - elasticloadbalancing:DescribeLoadBalancers - elasticloadbalancing:DeleteLoadBalancer - elasticloadbalancingv2:DescribeLoadBalancers From fe051aa22b59cc1c450b14af9c5f203448bb3dd5 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 17 Mar 2026 21:13:04 +0100 Subject: [PATCH 096/204] feat(cli): add --env alias for --branch and environments config alias (#8415) * feat(cli): add --env alias for --branch and environments config alias Add --env as a CLI alias for --branch on sync pull, sync push, workspace bind, and workspace unbind commands. Add environments as a permanent config alias for gitBranches in wmill.yaml. This helps users who use single-branch multi-environment workflows where "branch" terminology is confusing. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: regenerate auto-generated system prompts Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- cli/src/commands/sync/sync.ts | 8 ++++---- cli/src/commands/workspace/workspace.ts | 4 ++-- cli/src/core/conf.ts | 14 ++++++++++++++ cli/src/guidance/skills.ts | 8 ++++---- system_prompts/auto-generated/cli/cli-commands.md | 8 ++++---- system_prompts/auto-generated/prompts.ts | 8 ++++---- .../auto-generated/skills/cli-commands/SKILL.md | 8 ++++---- 7 files changed, 36 insertions(+), 22 deletions(-) diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 5de149cad3..bc0eee2275 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -3488,8 +3488,8 @@ const command = new Command() "Use promotionOverrides from the specified branch instead of regular overrides", ) .option( - "--branch ", - "Override the current git branch (works even outside a git repository)", + "--branch, --env ", + "Override the current git branch/environment (works even outside a git repository)", ) .action(pull as any) .command("push") @@ -3544,8 +3544,8 @@ const command = new Command() "Specify repository path (e.g., u/user/repo) when multiple repositories exist", ) .option( - "--branch ", - "Override the current git branch (works even outside a git repository)", + "--branch, --env ", + "Override the current git branch/environment (works even outside a git repository)", ) .option("--lint", "Run lint validation before pushing") .option( diff --git a/cli/src/commands/workspace/workspace.ts b/cli/src/commands/workspace/workspace.ts index 6124bd1e1c..d82290e743 100644 --- a/cli/src/commands/workspace/workspace.ts +++ b/cli/src/commands/workspace/workspace.ts @@ -568,11 +568,11 @@ const command = new Command() .action(listRemote as any) .command("bind") .description("Bind the current Git branch to the active workspace") - .option("--branch ", "Specify branch (defaults to current)") + .option("--branch, --env ", "Specify branch/environment (defaults to current)") .action((opts) => bind(opts as any, true)) .command("unbind") .description("Remove workspace binding from the current Git branch") - .option("--branch ", "Specify branch (defaults to current)") + .option("--branch, --env ", "Specify branch/environment (defaults to current)") .action((opts) => bind(opts as any, false)) .command("fork") .description("Create a forked workspace") diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index 22acd536f0..e72aa14414 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -75,6 +75,8 @@ export interface SyncOptions { }; }; }; + // Alias for gitBranches - for users who prefer environment-based terminology + environments?: SyncOptions["gitBranches"]; // Legacy field - deprecated, use gitBranches instead git_branches?: { commonSpecificItems?: { @@ -231,6 +233,18 @@ export async function readConfigFile(): Promise { } } + // Handle environments -> gitBranches alias (permanent alias, not a deprecation) + if (conf && "environments" in conf) { + if (!conf.gitBranches) { + conf.gitBranches = conf.environments as any; + } else { + log.warn( + "⚠️ Both 'environments' and 'gitBranches' found in wmill.yaml. Using 'gitBranches' and ignoring 'environments'." + ); + } + delete (conf as any).environments; + } + // Handle git_branches to gitBranches migration if (conf && "git_branches" in conf) { if (!conf.gitBranches) { diff --git a/cli/src/guidance/skills.ts b/cli/src/guidance/skills.ts index e97d9babe2..a742d52f28 100644 --- a/cli/src/guidance/skills.ts +++ b/cli/src/guidance/skills.ts @@ -5328,7 +5328,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--extra-includes \` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy - \`--repository \` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - \`--promotion \` - Use promotionOverrides from the specified branch instead of regular overrides - - \`--branch \` - Override the current git branch (works even outside a git repository) + - \`--branch, --env \` - Override the current git branch/environment (works even outside a git repository) - \`sync push\` - Push any local changes and apply them remotely. - \`--yes\` - Push without needing confirmation - \`--dry-run\` - Show changes that would be pushed without actually pushing @@ -5358,7 +5358,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--message \` - Include a message that will be added to all scripts/flows/apps updated during this push - \`--parallel \` - Number of changes to process in parallel - \`--repository \` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - - \`--branch \` - Override the current git branch (works even outside a git repository) + - \`--branch, --env \` - Override the current git branch/environment (works even outside a git repository) - \`--lint\` - Run lint validation before pushing - \`--locks-required\` - Fail if scripts or flow inline scripts that need locks have no locks @@ -5460,9 +5460,9 @@ workspace related commands - \`workspace list\` - List local workspace profiles - \`workspace list-remote\` - List workspaces on the remote server that you have access to - \`workspace bind\` - Bind the current Git branch to the active workspace - - \`--branch \` - Specify branch (defaults to current) + - \`--branch, --env \` - Specify branch/environment (defaults to current) - \`workspace unbind\` - Remove workspace binding from the current Git branch - - \`--branch \` - Specify branch (defaults to current) + - \`--branch, --env \` - Specify branch/environment (defaults to current) - \`workspace fork [workspace_name:string] [workspace_id:string]\` - Create a forked workspace - \`--create-workspace-name \` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id. - \`workspace delete-fork \` - Delete a forked workspace and git branch diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index 94519d6aaa..4b0f934175 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -375,7 +375,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides - - `--branch ` - Override the current git branch (works even outside a git repository) + - `--branch, --env ` - Override the current git branch/environment (works even outside a git repository) - `sync push` - Push any local changes and apply them remotely. - `--yes` - Push without needing confirmation - `--dry-run` - Show changes that would be pushed without actually pushing @@ -405,7 +405,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--message ` - Include a message that will be added to all scripts/flows/apps updated during this push - `--parallel ` - Number of changes to process in parallel - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - - `--branch ` - Override the current git branch (works even outside a git repository) + - `--branch, --env ` - Override the current git branch/environment (works even outside a git repository) - `--lint` - Run lint validation before pushing - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks @@ -507,9 +507,9 @@ workspace related commands - `workspace list` - List local workspace profiles - `workspace list-remote` - List workspaces on the remote server that you have access to - `workspace bind` - Bind the current Git branch to the active workspace - - `--branch ` - Specify branch (defaults to current) + - `--branch, --env ` - Specify branch/environment (defaults to current) - `workspace unbind` - Remove workspace binding from the current Git branch - - `--branch ` - Specify branch (defaults to current) + - `--branch, --env ` - Specify branch/environment (defaults to current) - `workspace fork [workspace_name:string] [workspace_id:string]` - Create a forked workspace - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id. - `workspace delete-fork ` - Delete a forked workspace and git branch diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 63d3a702b8..d7f2d14922 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1749,7 +1749,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--extra-includes \` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy - \`--repository \` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - \`--promotion \` - Use promotionOverrides from the specified branch instead of regular overrides - - \`--branch \` - Override the current git branch (works even outside a git repository) + - \`--branch, --env \` - Override the current git branch/environment (works even outside a git repository) - \`sync push\` - Push any local changes and apply them remotely. - \`--yes\` - Push without needing confirmation - \`--dry-run\` - Show changes that would be pushed without actually pushing @@ -1779,7 +1779,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--message \` - Include a message that will be added to all scripts/flows/apps updated during this push - \`--parallel \` - Number of changes to process in parallel - \`--repository \` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - - \`--branch \` - Override the current git branch (works even outside a git repository) + - \`--branch, --env \` - Override the current git branch/environment (works even outside a git repository) - \`--lint\` - Run lint validation before pushing - \`--locks-required\` - Fail if scripts or flow inline scripts that need locks have no locks @@ -1881,9 +1881,9 @@ workspace related commands - \`workspace list\` - List local workspace profiles - \`workspace list-remote\` - List workspaces on the remote server that you have access to - \`workspace bind\` - Bind the current Git branch to the active workspace - - \`--branch \` - Specify branch (defaults to current) + - \`--branch, --env \` - Specify branch/environment (defaults to current) - \`workspace unbind\` - Remove workspace binding from the current Git branch - - \`--branch \` - Specify branch (defaults to current) + - \`--branch, --env \` - Specify branch/environment (defaults to current) - \`workspace fork [workspace_name:string] [workspace_id:string]\` - Create a forked workspace - \`--create-workspace-name \` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id. - \`workspace delete-fork \` - Delete a forked workspace and git branch diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 8db057690a..b31f479626 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -380,7 +380,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides - - `--branch ` - Override the current git branch (works even outside a git repository) + - `--branch, --env ` - Override the current git branch/environment (works even outside a git repository) - `sync push` - Push any local changes and apply them remotely. - `--yes` - Push without needing confirmation - `--dry-run` - Show changes that would be pushed without actually pushing @@ -410,7 +410,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--message ` - Include a message that will be added to all scripts/flows/apps updated during this push - `--parallel ` - Number of changes to process in parallel - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - - `--branch ` - Override the current git branch (works even outside a git repository) + - `--branch, --env ` - Override the current git branch/environment (works even outside a git repository) - `--lint` - Run lint validation before pushing - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks @@ -512,9 +512,9 @@ workspace related commands - `workspace list` - List local workspace profiles - `workspace list-remote` - List workspaces on the remote server that you have access to - `workspace bind` - Bind the current Git branch to the active workspace - - `--branch ` - Specify branch (defaults to current) + - `--branch, --env ` - Specify branch/environment (defaults to current) - `workspace unbind` - Remove workspace binding from the current Git branch - - `--branch ` - Specify branch (defaults to current) + - `--branch, --env ` - Specify branch/environment (defaults to current) - `workspace fork [workspace_name:string] [workspace_id:string]` - Create a forked workspace - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id. - `workspace delete-fork ` - Delete a forked workspace and git branch From 8c769aebbf2f957b6f2012ca92cae5c686cf2b64 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 17 Mar 2026 21:14:02 +0100 Subject: [PATCH 097/204] improve analytics (#8418) * [ee] improve analytics: add git sync & AI chat telemetry, HMAC-signed download - Add ai_chat_usage table to track chat sessions (session_id, provider, model, mode, message_count) - Add POST /w/{workspace}/workspaces/log_chat endpoint with upsert on session_id - Frontend fires logAiChat on every sendRequest, using HistoryManager's existing chat ID - EE stats: add git_sync_usage (sync vs promotion repo count) and ai_chat_usage (30-day aggregates) - Replace RSA+AES-GCM encrypted telemetry download with plaintext JSON + HMAC-SHA256 signature - Signature (12 hex chars) included in download filename for verification - Update instance settings telemetry descriptions for both EE and CE Co-Authored-By: Claude Opus 4.6 (1M context) * fix: make StatsDownload struct pub to fix private-interfaces error Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 878cc2044717e0177228529a50433fe2768e70b5 This commit updates the EE repository reference after PR #464 was merged in windmill-ee-private. Previous ee-repo-ref: 33eb863b6b881bd54ed69a540e0c65d5fe125024 New ee-repo-ref: 878cc2044717e0177228529a50433fe2768e70b5 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...34600b68125fb55f77ad3abf3333ebab22416.json | 20 +++++++++ ...83030e7a7ce32971a5621aceb738a3673f943.json | 44 +++++++++++++++++++ ...86122e4ceb34cc8993937dff24cdd7ba3fe5f.json | 12 +++++ ...d523ec2219920a64783cd5d2972f1826114cc.json | 17 +++++++ backend/ee-repo-ref.txt | 2 +- .../20260317000000_ai_chat_usage.down.sql | 1 + .../20260317000000_ai_chat_usage.up.sql | 12 +++++ backend/windmill-api-settings/src/lib.rs | 19 ++++++-- .../windmill-api-workspaces/src/workspaces.rs | 26 +++++++++++ backend/windmill-api/openapi.yaml | 44 +++++++++++++++++-- backend/windmill-common/src/stats_oss.rs | 4 +- .../lib/components/InstanceSettings.svelte | 14 ++++-- .../copilot/chat/AIChatManager.svelte.ts | 17 ++++++- .../copilot/chat/HistoryManager.svelte.ts | 4 ++ 14 files changed, 221 insertions(+), 15 deletions(-) create mode 100644 backend/.sqlx/query-1bf189625a4f14e12e0d0510eb534600b68125fb55f77ad3abf3333ebab22416.json create mode 100644 backend/.sqlx/query-3ec92c1682f3ce701028f66f7ce83030e7a7ce32971a5621aceb738a3673f943.json create mode 100644 backend/.sqlx/query-98746829ab854dff922a04822bc86122e4ceb34cc8993937dff24cdd7ba3fe5f.json create mode 100644 backend/.sqlx/query-b64f0f337aed44840cd68f4c81bd523ec2219920a64783cd5d2972f1826114cc.json create mode 100644 backend/migrations/20260317000000_ai_chat_usage.down.sql create mode 100644 backend/migrations/20260317000000_ai_chat_usage.up.sql diff --git a/backend/.sqlx/query-1bf189625a4f14e12e0d0510eb534600b68125fb55f77ad3abf3333ebab22416.json b/backend/.sqlx/query-1bf189625a4f14e12e0d0510eb534600b68125fb55f77ad3abf3333ebab22416.json new file mode 100644 index 0000000000..e7cd2e52b7 --- /dev/null +++ b/backend/.sqlx/query-1bf189625a4f14e12e0d0510eb534600b68125fb55f77ad3abf3333ebab22416.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT git_sync FROM workspace_settings WHERE git_sync IS NOT NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "git_sync", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + true + ] + }, + "hash": "1bf189625a4f14e12e0d0510eb534600b68125fb55f77ad3abf3333ebab22416" +} diff --git a/backend/.sqlx/query-3ec92c1682f3ce701028f66f7ce83030e7a7ce32971a5621aceb738a3673f943.json b/backend/.sqlx/query-3ec92c1682f3ce701028f66f7ce83030e7a7ce32971a5621aceb738a3673f943.json new file mode 100644 index 0000000000..3d91feace1 --- /dev/null +++ b/backend/.sqlx/query-3ec92c1682f3ce701028f66f7ce83030e7a7ce32971a5621aceb738a3673f943.json @@ -0,0 +1,44 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT provider, model, mode,\n COUNT(*)::BIGINT as \"session_count!\",\n COALESCE(SUM(message_count), 0)::BIGINT as \"message_count!\"\n FROM ai_chat_usage\n WHERE created_at > NOW() - INTERVAL '30 days'\n GROUP BY provider, model, mode\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "provider", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "model", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "mode", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "session_count!", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "message_count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + null, + null + ] + }, + "hash": "3ec92c1682f3ce701028f66f7ce83030e7a7ce32971a5621aceb738a3673f943" +} diff --git a/backend/.sqlx/query-98746829ab854dff922a04822bc86122e4ceb34cc8993937dff24cdd7ba3fe5f.json b/backend/.sqlx/query-98746829ab854dff922a04822bc86122e4ceb34cc8993937dff24cdd7ba3fe5f.json new file mode 100644 index 0000000000..58f8729225 --- /dev/null +++ b/backend/.sqlx/query-98746829ab854dff922a04822bc86122e4ceb34cc8993937dff24cdd7ba3fe5f.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM ai_chat_usage WHERE created_at < NOW() - INTERVAL '60 days'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "98746829ab854dff922a04822bc86122e4ceb34cc8993937dff24cdd7ba3fe5f" +} diff --git a/backend/.sqlx/query-b64f0f337aed44840cd68f4c81bd523ec2219920a64783cd5d2972f1826114cc.json b/backend/.sqlx/query-b64f0f337aed44840cd68f4c81bd523ec2219920a64783cd5d2972f1826114cc.json new file mode 100644 index 0000000000..ed4288ea5c --- /dev/null +++ b/backend/.sqlx/query-b64f0f337aed44840cd68f4c81bd523ec2219920a64783cd5d2972f1826114cc.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO ai_chat_usage (session_id, provider, model, mode) VALUES ($1, $2, $3, $4)\n ON CONFLICT (session_id) DO UPDATE SET message_count = ai_chat_usage.message_count + 1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "b64f0f337aed44840cd68f4c81bd523ec2219920a64783cd5d2972f1826114cc" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 648a079815..ebad6f5c76 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -278a3887f759f9d1146554baa0765518d5bc70f2 +878cc2044717e0177228529a50433fe2768e70b5 diff --git a/backend/migrations/20260317000000_ai_chat_usage.down.sql b/backend/migrations/20260317000000_ai_chat_usage.down.sql new file mode 100644 index 0000000000..5823ee37a1 --- /dev/null +++ b/backend/migrations/20260317000000_ai_chat_usage.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS ai_chat_usage; diff --git a/backend/migrations/20260317000000_ai_chat_usage.up.sql b/backend/migrations/20260317000000_ai_chat_usage.up.sql new file mode 100644 index 0000000000..015a7c4daa --- /dev/null +++ b/backend/migrations/20260317000000_ai_chat_usage.up.sql @@ -0,0 +1,12 @@ +-- Table to track AI chat sessions and message counts for telemetry +CREATE TABLE IF NOT EXISTS ai_chat_usage ( + id BIGSERIAL PRIMARY KEY, + session_id VARCHAR(36) NOT NULL UNIQUE, + provider VARCHAR(50) NOT NULL, + model VARCHAR(255) NOT NULL, + mode VARCHAR(50) NOT NULL, + message_count INT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_ai_chat_usage_created_at ON ai_chat_usage (created_at); diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index e026386474..3166e31bbc 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -579,8 +579,17 @@ pub async fn send_stats(Extension(db): Extension, authed: ApiAuthed) -> Resu Ok("Sent stats".to_string()) } +#[derive(serde::Serialize)] +pub struct StatsDownload { + pub signature: String, + pub data: String, +} + #[cfg(feature = "enterprise")] -pub async fn get_stats(Extension(db): Extension, authed: ApiAuthed) -> Result { +pub async fn get_stats( + Extension(db): Extension, + authed: ApiAuthed, +) -> error::JsonResult { require_super_admin(&db, &authed.email).await?; let stats = windmill_common::stats_oss::get_stats_payload( &db, @@ -588,12 +597,14 @@ pub async fn get_stats(Extension(db): Extension, authed: ApiAuthed) -> Resul false, ) .await?; - let encrypted = windmill_common::stats_oss::encrypt_stats(&stats)?; - Ok(encrypted) + let json = + serde_json::to_string(&stats).map_err(|e| error::Error::InternalErr(e.to_string()))?; + let signature = windmill_common::stats_oss::sign_stats(&json); + Ok(axum::Json(StatsDownload { signature, data: json })) } #[cfg(not(feature = "enterprise"))] -pub async fn get_stats() -> Result { +pub async fn get_stats() -> error::JsonResult { Err(error::Error::BadRequest( "Downloading telemetry is only available on enterprise edition".to_string(), )) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 92c604de33..cc2cb0c797 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -157,6 +157,7 @@ pub fn workspaced_service() -> Router { "/protection_rules/:rule_name", post(update_protection_rule).delete(delete_protection_rule), ) + .route("/log_chat", post(log_ai_chat)) } pub fn global_service() -> Router { Router::new() @@ -5372,3 +5373,28 @@ async fn compare_two_folders( exists_in_fork: target_folder.is_some(), }); } + +#[derive(Deserialize)] +struct LogAiChatPayload { + session_id: String, + provider: String, + model: String, + mode: String, +} + +async fn log_ai_chat( + Extension(db): Extension, + Json(payload): Json, +) -> Result { + sqlx::query!( + "INSERT INTO ai_chat_usage (session_id, provider, model, mode) VALUES ($1, $2, $3, $4) + ON CONFLICT (session_id) DO UPDATE SET message_count = ai_chat_usage.message_count + 1", + &payload.session_id, + &payload.provider, + &payload.model, + &payload.mode + ) + .execute(&db) + .await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 5b88630d71..4213eb39e7 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1370,17 +1370,22 @@ paths: /settings/get_stats: get: - summary: get encrypted telemetry stats (EE only) + summary: get telemetry stats with HMAC signature (EE only) operationId: getStats tags: - setting responses: "200": - description: base64-encoded encrypted telemetry blob + description: telemetry stats JSON with signature content: - text/plain: + application/json: schema: - type: string + type: object + properties: + signature: + type: string + data: + type: string /settings/latest_key_renewal_attempt: get: @@ -4455,6 +4460,37 @@ paths: type: string "404": description: protection rule not found + /w/{workspace}/workspaces/log_chat: + post: + summary: log AI chat message + operationId: logAiChat + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - session_id + - provider + - model + - mode + properties: + session_id: + type: string + provider: + type: string + model: + type: string + mode: + type: string + responses: + "204": + description: logged /w/{workspace}/workspaces/public_app_rate_limit: post: summary: Set public app rate limit for this workspace diff --git a/backend/windmill-common/src/stats_oss.rs b/backend/windmill-common/src/stats_oss.rs index d3cc2e2b44..99a3c49bc0 100644 --- a/backend/windmill-common/src/stats_oss.rs +++ b/backend/windmill-common/src/stats_oss.rs @@ -67,7 +67,7 @@ pub async fn get_stats_payload( } #[cfg(not(feature = "private"))] -pub fn encrypt_stats(_stats: &Stats) -> Result { +pub fn sign_stats(_json: &str) -> String { // stats details are closed source - Ok(String::new()) + String::new() } diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index 9049b9b08a..18514b3836 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -268,12 +268,13 @@ async function downloadStats() { try { downloadingStats = true - const encryptedData = await SettingService.getStats() - const blob = new Blob([encryptedData], { type: 'application/octet-stream' }) + const result = await SettingService.getStats() + const blob = new Blob([result.data ?? ''], { type: 'application/json' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url - a.download = `windmill-telemetry-${new Date().toISOString().split('T')[0]}.enc` + const date = new Date().toISOString().split('T')[0] + a.download = `windmill-telemetry-${date}-${result.signature}.json` document.body.appendChild(a) a.click() document.body.removeChild(a) @@ -977,6 +978,10 @@
When minimal telemetry is disabled, the following is also collected:
  • job usage (language, total duration, count)
  • +
  • git sync repo count (sync vs promotion mode)
  • +
  • AI chat usage (provider, model, mode, session count, message count — last 30 days)

For air-gapped instances, you can download the telemetry data and send it manually. @@ -1012,6 +1017,9 @@
  • worker usage (worker, worker instance, vCPUs, memory)
  • user usage (author count, operator count)
  • development instance status
  • +
  • AI chat usage (provider, model, mode, session count, message count — last 30 days)
  • {/if} diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 9ba412fb4a..00a1a11fdf 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -1,4 +1,5 @@ import type { AIProviderModel, ScriptLang } from '$lib/gen/types.gen' +import { WorkspaceService } from '$lib/gen' import type { FlowOptions, ScriptOptions } from './ContextManager.svelte' import { flowTools, @@ -42,7 +43,8 @@ import { getStringError } from './utils' import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState' import type { CurrentEditor, ExtendedOpenFlow } from '$lib/components/flows/types' import { untrack } from 'svelte' -import { type DBSchemas } from '$lib/stores' +import { get } from 'svelte/store' +import { workspaceStore, type DBSchemas } from '$lib/stores' import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core' import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte' import type { @@ -660,6 +662,19 @@ class AIChatManager { this.#automaticScroll = true this.abortController = new AbortController() + const model = tryGetCurrentModel() + if (model) { + WorkspaceService.logAiChat({ + workspace: get(workspaceStore) ?? '', + requestBody: { + session_id: this.historyManager.getCurrentChatId(), + provider: model.provider, + model: model.model, + mode: this.mode + } + }).catch(() => {}) + } + if (this.mode === AIMode.FLOW && !this.flowAiChatHelpers) { throw new Error('No flow helpers found') } diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts index d4396e0ea6..87be590dcc 100644 --- a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts @@ -65,6 +65,10 @@ export default class HistoryManager { this.indexDB?.close() } + getCurrentChatId() { + return this.currentChatId + } + getPastChats() { return this.pastChats } From ebf9347d3fd876689dba58bc24399e9036ef5b67 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:14:33 +0100 Subject: [PATCH 098/204] fix: Folders as presets in FilterSearchbar (#8409) * Folder presets in filter search bar * nit max preset height --- .../src/lib/components/FilterSearchbar.svelte | 2 +- .../(root)/(logged)/resources/+page.svelte | 21 ++++++++++++------- .../(root)/(logged)/variables/+page.svelte | 21 ++++++++++++------- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/components/FilterSearchbar.svelte b/frontend/src/lib/components/FilterSearchbar.svelte index 8b902a8896..0161e21dd9 100644 --- a/frontend/src/lib/components/FilterSearchbar.svelte +++ b/frontend/src/lib/components/FilterSearchbar.svelte @@ -604,7 +604,7 @@ {#if !currentTag || !schema[currentTag]} {#if presets.length}
    Presets
    -
    +
    {#each presets as preset} {@render presetTag(preset)} {/each} diff --git a/frontend/src/routes/(root)/(logged)/resources/+page.svelte b/frontend/src/routes/(root)/(logged)/resources/+page.svelte index eecfd72729..600cc45183 100644 --- a/frontend/src/routes/(root)/(logged)/resources/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/resources/+page.svelte @@ -30,7 +30,7 @@ import Toggle from '$lib/components/Toggle.svelte' import Tooltip from '$lib/components/Tooltip.svelte' import type { ResourceType, WorkspaceDeployUISettings } from '$lib/gen' - import { OauthService, ResourceService, WorkspaceService, type ListableResource } from '$lib/gen' + import { FolderService, OauthService, ResourceService, WorkspaceService, type ListableResource } from '$lib/gen' import { enterpriseLicense, userStore, @@ -127,6 +127,7 @@ }) let showCreateButtons = $state(false) + let folders: string[] = $state([]) // FilterSearchbar setup let userFoldersFilterType = $derived( @@ -147,6 +148,12 @@ }) ) let filters = useUrlSyncedFilterInstance(untrack(() => resourcesFilterSchema)) + let folderPresets = $derived([ + ...folders.map((f) => ({ name: `f/${f}`, value: `path_start:\\ f/${f}/` })), + ...(resourcesFilterSchema.user_folders_only + ? [{ name: resourcesFilterSchema.user_folders_only.label ?? '?', value: 'user_folders_only:\\ true' }] + : []) + ]) async function loadResources(): Promise { resources = await loadResourceInternal(undefined, 'cache,state,app_theme') @@ -538,11 +545,16 @@ }) } }) + async function loadFolders() { + folders = await FolderService.listFolderNames({ workspace: $workspaceStore! }) + } + $effect(() => { if ($workspaceStore && $userStore) { untrack(() => { loadResources() loadResourceTypes() + loadFolders() }) } }) @@ -895,12 +907,7 @@ class="max-w-[26rem] grow" bind:value={filters.val} placeholder="Filter resources..." - presets={[ - { - name: resourcesFilterSchema.user_folders_only?.label ?? '?', - value: 'user_folders_only:\\ true' - } - ]} + presets={folderPresets} />
    diff --git a/frontend/src/routes/(root)/(logged)/variables/+page.svelte b/frontend/src/routes/(root)/(logged)/variables/+page.svelte index 870bf8e7c4..ea48db42cd 100644 --- a/frontend/src/routes/(root)/(logged)/variables/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/variables/+page.svelte @@ -22,7 +22,7 @@ import Tooltip from '$lib/components/Tooltip.svelte' import VariableEditor from '$lib/components/VariableEditor.svelte' import type { ContextualVariable, ListableVariable, WorkspaceDeployUISettings } from '$lib/gen' - import { OauthService, VariableService, WorkspaceService } from '$lib/gen' + import { FolderService, OauthService, VariableService, WorkspaceService } from '$lib/gen' import { enterpriseLicense, userStore, workspaceStore, userWorkspaces } from '$lib/stores' import { sendUserToast } from '$lib/toast' import { canWrite, isOwner, truncate } from '$lib/utils' @@ -51,6 +51,7 @@ // Collect unique values for filter autocomplete let allPaths: string[] = $state([]) let allOwners: string[] = $state([]) + let folders: string[] = $state([]) // FilterSearchbar setup let userFoldersFilterType = $derived( @@ -70,6 +71,12 @@ }) ) let filters = useUrlSyncedFilterInstance(untrack(() => variablesFilterSchema)) + let folderPresets = $derived([ + ...folders.map((f) => ({ name: `f/${f}`, value: `path_start:\\ f/${f}/` })), + ...(variablesFilterSchema.user_folders_only + ? [{ name: variablesFilterSchema.user_folders_only.label ?? '?', value: 'user_folders_only:\\ true' }] + : []) + ]) let contextualVariables: ContextualVariable[] = $state([]) let shareModal: ShareModal | undefined = $state() let variableEditor: VariableEditor | undefined = $state() @@ -175,11 +182,16 @@ sendUserToast(`Variable ${path} was deleted`) } + async function loadFolders() { + folders = await FolderService.listFolderNames({ workspace: $workspaceStore! }) + } + $effect(() => { if ($workspaceStore && $userStore) { untrack(() => { loadVariables() loadContextualVariables() + loadFolders() }) } }) @@ -285,12 +297,7 @@ schema={variablesFilterSchema} bind:value={filters.val} placeholder="Filter variables..." - presets={[ - { - name: variablesFilterSchema.user_folders_only?.label ?? '?', - value: 'user_folders_only:\\ true' - } - ]} + presets={folderPresets} /> {/if} From 7d800f209d1e6590ba1f7e5d5fcfbf1f4c6d6e97 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 17 Mar 2026 20:26:15 +0000 Subject: [PATCH 099/204] chore(main): release 1.659.0 (#8397) * chore(main): release 1.659.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 21 +++ backend/Cargo.lock | 162 +++++++++--------- backend/Cargo.toml | 4 +- 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 +- 15 files changed, 168 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3522f9c43..4364acf6d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [1.659.0](https://github.com/windmill-labs/windmill/compare/v1.658.0...v1.659.0) (2026-03-17) + + +### Features + +* add end_user_email claim to OIDC ID tokens ([#8401](https://github.com/windmill-labs/windmill/issues/8401)) ([de5b13b](https://github.com/windmill-labs/windmill/commit/de5b13b840f90e23df1871f80317fdcc2b98174d)) +* add ws_base_url instance setting for WebSocket URL override ([#8405](https://github.com/windmill-labs/windmill/issues/8405)) ([372023e](https://github.com/windmill-labs/windmill/commit/372023e99560885a76e8da3487ae705fd2f861d4)) +* **cli:** add --env alias for --branch and environments config alias ([#8415](https://github.com/windmill-labs/windmill/issues/8415)) ([fe051aa](https://github.com/windmill-labs/windmill/commit/fe051aa22b59cc1c450b14af9c5f203448bb3dd5)) +* DB-backed instance events webhook with superadmin UI ([#8402](https://github.com/windmill-labs/windmill/issues/8402)) ([7d9fb57](https://github.com/windmill-labs/windmill/commit/7d9fb57368ad3b2c719523ef649c9bd5fddf17a5)) +* instance groups instance-level role support ([#8404](https://github.com/windmill-labs/windmill/issues/8404)) ([18b3528](https://github.com/windmill-labs/windmill/commit/18b3528ba4188721d918fd47f0f86a6b41209453)) +* script module mode with CLI sync, preview, and WAC UI improvements ([#8380](https://github.com/windmill-labs/windmill/issues/8380)) ([31d6660](https://github.com/windmill-labs/windmill/commit/31d6660d56cd23d9269133d430b0607d58314229)) +* store hashed tokens instead of plaintext ([#8217](https://github.com/windmill-labs/windmill/issues/8217)) ([f2be625](https://github.com/windmill-labs/windmill/commit/f2be625348ef308e9768d487e110abbd44d27855)) +* workspace-specific registry overrides ([#8406](https://github.com/windmill-labs/windmill/issues/8406)) ([73fe45b](https://github.com/windmill-labs/windmill/commit/73fe45b6cb97ce50d029240c6bd63917b301abe1)) + + +### Bug Fixes + +* devops getting logged out on workers page ([#8416](https://github.com/windmill-labs/windmill/issues/8416)) ([920a7f9](https://github.com/windmill-labs/windmill/commit/920a7f9fa4719015885947b9de0c35e5e618fcc8)) +* Folders as presets in FilterSearchbar ([#8409](https://github.com/windmill-labs/windmill/issues/8409)) ([ebf9347](https://github.com/windmill-labs/windmill/commit/ebf9347d3fd876689dba58bc24399e9036ef5b67)) +* improve OOM killer observability for debugging pod-level kills ([#8398](https://github.com/windmill-labs/windmill/issues/8398)) ([fd41cd1](https://github.com/windmill-labs/windmill/commit/fd41cd12b444fb2439214fcd25536280e5baacb2)) + ## [1.658.0](https://github.com/windmill-labs/windmill/compare/v1.657.2...v1.658.0) (2026-03-16) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 1343e441ad..1b157f616a 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -14467,11 +14467,11 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.0.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" dependencies = [ - "winnow 0.7.15", + "winnow 1.0.0", ] [[package]] @@ -15741,7 +15741,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "async-nats", @@ -15808,7 +15808,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.658.0" +version = "1.659.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15821,7 +15821,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "argon2", @@ -15962,7 +15962,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.658.0" +version = "1.659.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15985,7 +15985,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.658.0" +version = "1.659.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15998,7 +15998,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16024,7 +16024,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.658.0" +version = "1.659.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16034,7 +16034,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.658.0" +version = "1.659.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16051,7 +16051,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.658.0" +version = "1.659.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -16074,7 +16074,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16097,7 +16097,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.658.0" +version = "1.659.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16113,7 +16113,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.658.0" +version = "1.659.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16133,7 +16133,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.658.0" +version = "1.659.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16153,7 +16153,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.658.0" +version = "1.659.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16167,7 +16167,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "async-nats", @@ -16195,7 +16195,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16220,7 +16220,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.658.0" +version = "1.659.0" dependencies = [ "axum 0.7.9", "flate2", @@ -16238,7 +16238,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16259,7 +16259,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.658.0" +version = "1.659.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16279,7 +16279,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.658.0" +version = "1.659.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16309,7 +16309,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16336,7 +16336,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.658.0" +version = "1.659.0" dependencies = [ "lazy_static", "serde", @@ -16348,7 +16348,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.658.0" +version = "1.659.0" dependencies = [ "argon2", "axum 0.7.9", @@ -16371,7 +16371,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.658.0" +version = "1.659.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16385,7 +16385,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.658.0" +version = "1.659.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16416,7 +16416,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.658.0" +version = "1.659.0" dependencies = [ "chrono", "lazy_static", @@ -16430,7 +16430,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16449,7 +16449,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.658.0" +version = "1.659.0" dependencies = [ "aes-gcm", "anyhow", @@ -16548,7 +16548,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.658.0" +version = "1.659.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16567,7 +16567,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.658.0" +version = "1.659.0" dependencies = [ "regex", "serde", @@ -16582,7 +16582,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16606,7 +16606,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "futures", @@ -16623,7 +16623,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.658.0" +version = "1.659.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16639,7 +16639,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "async-trait", @@ -16660,7 +16660,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "async-trait", @@ -16691,7 +16691,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "async-oauth2", @@ -16715,7 +16715,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "async-stream", @@ -16749,7 +16749,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "futures", @@ -16767,7 +16767,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.658.0" +version = "1.659.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16776,7 +16776,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "lazy_static", @@ -16788,7 +16788,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "serde_json", @@ -16800,7 +16800,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "gosyn", @@ -16812,7 +16812,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "lazy_static", @@ -16824,7 +16824,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "serde_json", @@ -16836,7 +16836,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "nu-parser", @@ -16847,7 +16847,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16858,7 +16858,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16870,7 +16870,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16881,7 +16881,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "async-recursion", @@ -16905,7 +16905,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "lazy_static", @@ -16919,7 +16919,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16936,7 +16936,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "lazy_static", @@ -16950,7 +16950,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "serde", @@ -16962,7 +16962,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "lazy_static", @@ -16980,7 +16980,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16996,7 +16996,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17012,7 +17012,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "serde", @@ -17023,7 +17023,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "async-recursion", @@ -17060,7 +17060,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "const_format", @@ -17098,7 +17098,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.658.0" +version = "1.659.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17109,7 +17109,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "async-recursion", @@ -17138,7 +17138,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -17161,7 +17161,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "async-trait", @@ -17194,7 +17194,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "async-trait", @@ -17214,7 +17214,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "async-trait", @@ -17248,7 +17248,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "async-trait", @@ -17283,7 +17283,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "async-trait", @@ -17306,7 +17306,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "async-trait", @@ -17330,7 +17330,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "async-nats", @@ -17354,7 +17354,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "async-trait", @@ -17389,7 +17389,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "async-trait", @@ -17417,7 +17417,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "async-trait", @@ -17440,7 +17440,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17458,7 +17458,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.658.0" +version = "1.659.0" dependencies = [ "anyhow", "async-once-cell", @@ -17565,7 +17565,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.658.0" +version = "1.659.0" dependencies = [ "bytes", "futures", @@ -18172,6 +18172,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" + [[package]] name = "winreg" version = "0.50.0" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 2cabf47d41..3cf78e7f6b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.658.0" +version = "1.659.0" authors.workspace = true edition.workspace = true @@ -82,7 +82,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal"] [workspace.package] -version = "1.658.0" +version = "1.659.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 4213eb39e7..f4cd1c9a0b 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.658.0 + version: 1.659.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 6e6459b2c9..86935f23be 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.658.0"; +export const VERSION = "v1.659.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 01d4040f4f..616a2dc4b3 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -68,7 +68,7 @@ export { workspaceAdd, }; -export const VERSION = "1.658.0"; +export const VERSION = "1.659.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 9f9a907e19..7500929aa4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.658.0", + "version": "1.659.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.658.0", + "version": "1.659.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -842,6 +842,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": { @@ -853,6 +854,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": { @@ -863,6 +865,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": { @@ -1352,6 +1355,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": { @@ -1508,6 +1512,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1524,6 +1529,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1540,6 +1546,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1556,6 +1563,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1572,6 +1580,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1588,6 +1597,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1604,6 +1614,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1620,6 +1631,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1636,6 +1648,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1652,6 +1665,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1668,6 +1682,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1684,6 +1699,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1700,6 +1716,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1716,6 +1733,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1732,6 +1750,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2037,6 +2056,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": { @@ -6845,7 +6865,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" @@ -7344,6 +7364,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7364,6 +7385,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7384,6 +7406,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7404,6 +7427,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7424,6 +7448,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7444,6 +7469,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7464,6 +7490,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7484,6 +7511,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7504,6 +7532,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7524,6 +7553,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7544,6 +7574,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12119,6 +12150,21 @@ } } }, + "node_modules/svelte-check/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "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", @@ -12849,7 +12895,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 854d85fa75..0246731c21 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.658.0", + "version": "1.659.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 6cace22096..ae5db6d86e 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.658.0" +wmill = ">=1.659.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 0e9a0a8214..290ebe484b 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.658.0 + version: 1.659.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index d3f906eccc..728d52d5b3 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.658.0' + ModuleVersion = '1.659.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index b0ce39509a..1b4cb18717 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.658.0" +version = "1.659.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 96450d49f6..0637a231ba 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.658.0", + "version": "1.659.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 1206bae22a..c8b1f7131e 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.999.21", + "version": "1.659.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index d460528676..aaa364516b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.658.0 +1.659.0 From 0f261695a3cb2c3a95d16390e54aa7a6ac3e11e7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 18 Mar 2026 07:49:04 +0000 Subject: [PATCH 100/204] fix: per-tab test panel in script editor for WAC v2 modules (#8422) When switching to a non-main module tab, the test panel now infers args from the module's code and runs the module's code on Test/Cmd+Enter. Per-module args and schema are persisted across tab switches. Co-authored-by: Claude Opus 4.6 (1M context) --- .../src/lib/components/ScriptEditor.svelte | 112 ++++++++++++++---- 1 file changed, 88 insertions(+), 24 deletions(-) diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 4324b45ba6..3da73fa132 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -187,6 +187,10 @@ // Module tab state let activeModuleTab: string | null = $state(null) + // Per-module test panel state (args + schema), persisted across tab switches + let moduleTestState: Record; schema: Schema }> = $state({}) + let testPanelArgs: Record = $state({}) + let testPanelSchema: Schema = $state(emptySchema()) // editorCode is what the editor shows; code always holds the main script content let editorCode: string = $state(code) // Sync editorCode when code changes externally (template reset, copilot, etc.) @@ -200,20 +204,31 @@ function switchToModule(modulePath: string) { if (activeModuleTab !== null && modules && activeModuleTab !== modulePath) { - // Switching from another module: save its content + // Switching from another module: save its content and test state modules[activeModuleTab] = { ...modules[activeModuleTab], content: editorCode } + moduleTestState[activeModuleTab] = { args: testPanelArgs, schema: testPanelSchema } } if (modules && modules[modulePath]) { activeModuleTab = modulePath editorCode = modules[modulePath].content editor?.setCode(editorCode) + // Restore or initialize test state for the new module + if (moduleTestState[modulePath]) { + testPanelArgs = moduleTestState[modulePath].args + testPanelSchema = moduleTestState[modulePath].schema + } else { + testPanelArgs = {} + testPanelSchema = emptySchema() + inferModuleSchema() + } } } function switchToMain() { if (activeModuleTab !== null && modules) { - // Save current module content + // Save current module content and test state modules[activeModuleTab] = { ...modules[activeModuleTab], content: editorCode } + moduleTestState[activeModuleTab] = { args: testPanelArgs, schema: testPanelSchema } } activeModuleTab = null editorCode = code @@ -386,6 +401,7 @@ switchToMain() } delete modules[modulePath] + delete moduleTestState[modulePath] modules = { ...modules } } @@ -423,6 +439,10 @@ delete modules[oldPath] modules[newPath] = { ...mod, language: newLang ?? mod.language } modules = { ...modules } + if (moduleTestState[oldPath]) { + moduleTestState[newPath] = moduleTestState[oldPath] + delete moduleTestState[oldPath] + } if (activeModuleTab === oldPath) { activeModuleTab = newPath } @@ -441,6 +461,7 @@ export function flushModuleState() { if (activeModuleTab !== null && modules) { flushModuleContent() + moduleTestState[activeModuleTab] = { args: testPanelArgs, schema: testPanelSchema } activeModuleTab = null editorCode = code } @@ -614,14 +635,22 @@ jobProgressBar?.reset() // Flush module edits back to modules map before running preview flushModuleContent() + + const testCode = activeModuleTab !== null ? editorCode : code + const testLang = activeModuleTab !== null ? effectiveLang : lang + const testArgs = + activeModuleTab !== null + ? testPanelArgs + : selectedTab === 'preprocessor' || kind === 'preprocessor' + ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...(args ?? {}) } + : (args ?? {}) + //@ts-ignore let job = await jobLoader.runPreview( path, - code, - lang, - selectedTab === 'preprocessor' || kind === 'preprocessor' - ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...(args ?? {}) } - : (args ?? {}), + testCode, + testLang, + testArgs, tag, undefined, undefined, @@ -642,7 +671,7 @@ } }, undefined, - modules + activeModuleTab !== null ? undefined : modules ) logPanel?.setFocusToLogs() return job @@ -718,6 +747,17 @@ } } + async function inferModuleSchema() { + if (activeModuleTab === null) return + try { + await inferArgs(effectiveLang, editorCode, testPanelSchema) + testPanelSchema = testPanelSchema + moduleTestState[activeModuleTab] = { args: testPanelArgs, schema: testPanelSchema } + } catch (e) { + // Module code may be in-progress; silently ignore + } + } + let gitRepoResourcePickerOpen = $state(false) let commitHashForGitRepo = $derived(ansibleAlternativeExecutionMode?.commit) @@ -1505,7 +1545,11 @@ { if (e.detail) { - args = e.detail + if (activeModuleTab !== null) { + testPanelArgs = e.detail + } else { + args = e.detail + } } }} updateOnBlur={false} @@ -1516,20 +1560,37 @@
    {#key argsRender} - + {#if activeModuleTab !== null} + + {:else} + + {/if} {/key}
    @@ -1554,7 +1615,7 @@ : testIsLoading} {editor} {diffEditor} - {args} + args={activeModuleTab !== null ? testPanelArgs : args} {showCaptures} customUi={customUi?.previewPanel} showCustomResultPanel={showDebugPanel} @@ -1926,6 +1987,7 @@ inferSchema(e.detail) } else { flushModuleContent() + inferModuleSchema() } // Refresh breakpoint positions when code changes (decorations track their lines) if (debugMode && breakpointDecorations.length > 0) { @@ -1937,6 +1999,8 @@ cmdEnterAction={async () => { if (activeModuleTab === null) { await inferSchema(editorCode) + } else { + await inferModuleSchema() } runTest() }} From f481ea4059b4e5cb01273cffeb53ff340e8bd5bd Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 18 Mar 2026 08:15:04 +0000 Subject: [PATCH 101/204] fix(frontend): fix output of resultnode + svelte5 nits (#8424) * fix(frontend): remove banned $bindable('') pattern from ClearableInput Switching format types in the flow input editor caused a props_invalid_value error because ClearableInput used value = $bindable(''), which conflicts with undefined bindings. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(frontend): restore flow result display in result node The fix in #8390 changed updateLastJob() to only use testJob when actively running/streaming, preferring flowStateStore for completed results. But the result node has moduleId='' and no flowStateStore entry, so the early return made it always show the empty state. Add !moduleId to the testJob condition so the result node (which has no flowStateStore entry) still uses testJob as its only data source. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../clearableInput/ClearableInput.svelte | 32 ++++++++----------- .../flows/propPicker/OutputPickerInner.svelte | 5 +-- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/frontend/src/lib/components/common/clearableInput/ClearableInput.svelte b/frontend/src/lib/components/common/clearableInput/ClearableInput.svelte index 5e5a28c5fc..3d9a0e9ad7 100644 --- a/frontend/src/lib/components/common/clearableInput/ClearableInput.svelte +++ b/frontend/src/lib/components/common/clearableInput/ClearableInput.svelte @@ -1,25 +1,25 @@ @@ -58,7 +54,7 @@ > {#if type === 'textarea'} + {:else if note} + +
    {}) : undefined} + > + +
    + {:else} + +
    {}) : undefined} + > + Double click to edit the note +
    + {/if} + + diff --git a/frontend/src/lib/components/graph/GroupOverlay.svelte b/frontend/src/lib/components/graph/GroupOverlay.svelte new file mode 100644 index 0000000000..c15095bfb4 --- /dev/null +++ b/frontend/src/lib/components/graph/GroupOverlay.svelte @@ -0,0 +1,90 @@ + + +{#each groups as group (groupKey(group))} + {@const bounds = groupBoundsMap[groupKey(group)]} + {#if bounds} + +
    +
    + {/if} +{/each} diff --git a/frontend/src/lib/components/graph/MiniFlowGraph.svelte b/frontend/src/lib/components/graph/MiniFlowGraph.svelte index fdb73745df..ab0c5042e8 100644 --- a/frontend/src/lib/components/graph/MiniFlowGraph.svelte +++ b/frontend/src/lib/components/graph/MiniFlowGraph.svelte @@ -1,6 +1,12 @@ - -{#if noteEditorContext?.noteEditor && selectedNodeIds.length > 1} - - {@render children()} - -{/if} diff --git a/frontend/src/lib/components/graph/NoteColorPicker.svelte b/frontend/src/lib/components/graph/NoteColorPicker.svelte index d17adb9317..ff3a1a276c 100644 --- a/frontend/src/lib/components/graph/NoteColorPicker.svelte +++ b/frontend/src/lib/components/graph/NoteColorPicker.svelte @@ -10,7 +10,11 @@ isOpen?: boolean } - let { selectedColor, onColorChange, isOpen = $bindable(false) }: Props = $props() + let { + selectedColor, + onColorChange, + isOpen = $bindable(false) + }: Props = $props() import { ViewportPortal, type Node } from '@xyflow/svelte' import { calculateNodesBoundsWithOffset } from './util' - import { StickyNote, Move, Copy, Trash2, EllipsisVertical } from 'lucide-svelte' + import { Move, Copy, Trash2, EllipsisVertical, Group } from 'lucide-svelte' import { Button } from '../common' import DropdownV2 from '../DropdownV2.svelte' - import { getNoteEditorContext } from './noteEditor.svelte' + import { getGroupEditorContext } from './groupEditor.svelte' import { getGraphContext } from './graphContext' import MoveHandleButton from './MoveHandleButton.svelte' import { tick } from 'svelte' @@ -36,18 +36,19 @@ let resolvedCount = $derived(resolvedModuleIds.length) - // Get NoteEditor context for group note creation - const noteEditorContext = getNoteEditorContext() + // Get GroupEditor context for group creation + const groupEditorContext = getGroupEditorContext() // Get Graph context for clearFlowSelection function and moveManager const graphContext = getGraphContext() const moveManager = graphContext?.moveManager - function handleAddGroupNote() { - if (selectedNodes.length > 0 && noteEditorContext?.noteEditor && graphContext) { - // Create the group note first - noteEditorContext.noteEditor.createGroupNote(selectedNodes) + let canCreateGroup = $derived(groupEditorContext?.canCreateGroup.val ?? false) + + function handleAddGroup() { + if (selectedNodes.length > 0 && groupEditorContext?.groupEditor && graphContext) { + const flowNodes = graphContext.getFlowNodes?.() ?? [] + groupEditorContext.groupEditor.createGroup(selectedNodes, flowNodes) - // Wait for next tick to ensure DOM updates tick().then(() => { graphContext?.clearFlowSelection?.() graphContext?.selectionManager.clearSelection() @@ -74,13 +75,13 @@ shortcut: isMac() ? '⌫' : 'Del', action: () => onDeleteSelected?.() }, - ...(noteEditorContext?.noteEditor + ...(groupEditorContext?.groupEditor ? [ { - displayName: 'Add note', - icon: StickyNote, - separatorTop: true, - action: handleAddGroupNote + displayName: 'Create group', + icon: Group, + action: handleAddGroup, + disabled: !canCreateGroup } ] : []) diff --git a/frontend/src/lib/components/graph/compoundLayout.ts b/frontend/src/lib/components/graph/compoundLayout.ts index 4bf3ddebda..c47aa62584 100644 --- a/frontend/src/lib/components/graph/compoundLayout.ts +++ b/frontend/src/lib/components/graph/compoundLayout.ts @@ -1,5 +1,6 @@ import { sugiyama, dagStratify, coordCenter, decrossTwoLayer, decrossOpt } from 'd3-dag' import { NODE } from './util' +import { GROUP_HEADER_HEIGHT } from './groupEditor.svelte' type LayoutNode = { id: string @@ -14,7 +15,7 @@ type LayoutConstants = { } type CompoundGroup = { - type: 'branch' | 'loop' + type: 'branch' | 'loop' | 'group' headId: string endId: string branches: { @@ -27,9 +28,12 @@ type LayoutResult = { positions: Map bbox: { width: number; height: number } contentMinX: number + groupDimensions?: Map } const LOOP_INDENT = 25 +export const GROUP_PADDING = 16 +export const GROUP_TOP_PADDING = 32 /** * Detect compound groups from a flat list of node IDs. @@ -83,6 +87,18 @@ function detectGroups( endId: id, branches: [{ labelId: `${baseId}-start`, innerIds }] }) + } else if (baseId.startsWith('group:')) { + // Group pattern: group:{groupId} head + group:{groupId}-end + // Body is everything reachable from head to end + const innerIds = findInnerIds(baseId, id, nodeIds, childrenMap) + if (innerIds.length > 0) { + groups.push({ + type: 'group', + headId: baseId, + endId: id, + branches: [{ labelId: innerIds[0], innerIds: innerIds.slice(1) }] + }) + } } } @@ -230,6 +246,29 @@ function runSugiyama( * 5. Run sugiyama on the simplified graph * 6. Expand wrapper positions back to absolute positions */ +/** + * Build nodeSizes map for sugiyama from nodeExtraSpace. + * Each node's effective height = top + NODE.height + bottom. + */ +function buildNodeSizes( + nodeIds: string[], + constants: LayoutConstants, + nodeExtraSpace?: Map +): Map | undefined { + if (!nodeExtraSpace || nodeExtraSpace.size === 0) return undefined + const sizes = new Map() + for (const id of nodeIds) { + const extra = nodeExtraSpace.get(id) + if (extra && (extra.top > 0 || extra.bottom > 0 || extra.left > 0 || extra.right > 0)) { + sizes.set(id, { + width: constants.nodeWidth + extra.left + extra.right, + height: constants.nodeHeight + extra.top + extra.bottom + }) + } + } + return sizes.size > 0 ? sizes : undefined +} + const MAX_RECURSION_DEPTH = 50 function layoutLevel( @@ -237,7 +276,8 @@ function layoutLevel( allNodes: Map, constants: LayoutConstants, childrenMap: Map, - depth: number = 0 + depth: number = 0, + nodeExtraSpace?: Map ): LayoutResult { const positions = new Map() const nodeIdSet = new Set(nodeIds) @@ -256,8 +296,15 @@ function layoutLevel( const n = allNodes.get(id)! return { id, parentIds: (n.parentIds ?? []).filter((pid) => nodeIdSet.has(pid)) } }) - const result = runSugiyama(flatNodes, constants) + const extraSizes = buildNodeSizes( + flatNodes.map((n) => n.id), + constants, + nodeExtraSpace + ) + const result = runSugiyama(flatNodes, constants, extraSizes) for (const [id, pos] of result.positions) { + const extra = nodeExtraSpace?.get(id) + if (extra) pos.y += extra.top positions.set(id, pos) } return { positions, bbox: { width: result.width, height: result.height }, contentMinX: 0 } @@ -322,7 +369,14 @@ function layoutLevel( const branchNodeIds = [branch.labelId, ...branch.innerIds] // Find sub-groups within this branch - const result = layoutLevel(branchNodeIds, allNodes, constants, childrenMap, depth + 1) + const result = layoutLevel( + branchNodeIds, + allNodes, + constants, + childrenMap, + depth + 1, + nodeExtraSpace + ) branchLayouts.push({ labelId: branch.labelId, @@ -349,6 +403,16 @@ function layoutLevel( maxBranchHeight = Math.max(0, ...branchLayouts.map((bl) => bl.bbox.height)) // head row + branch content + end row wrapperHeight = rowHeight + maxBranchHeight + rowHeight + } else if (group.type === 'group') { + // Group: body is centered with padding on all sides + const bodyWidth = branchLayouts[0]?.bbox.width ?? constants.nodeWidth + const bodyHeight = branchLayouts[0]?.bbox.height ?? 0 + wrapperWidth = Math.max(bodyWidth + GROUP_PADDING * 2, constants.nodeWidth) + maxBranchHeight = bodyHeight + const headExtra = nodeExtraSpace?.get(group.headId) + const groupHeadRow = GROUP_HEADER_HEIGHT + (headExtra?.bottom ?? 0) + GROUP_TOP_PADDING + // head row + body + bottom padding + wrapperHeight = groupHeadRow + bodyHeight + GROUP_PADDING } else { // Loop: body is indented const bodyWidth = branchLayouts[0]?.bbox.width ?? constants.nodeWidth @@ -395,13 +459,30 @@ function layoutLevel( } // Step 5: Run sugiyama on flattened nodes - const sugResult = runSugiyama(flatNodes, constants, wrapperSizes) + // Merge wrapperSizes with nodeExtraSpace-derived sizes for non-group nodes + const extraSizes = buildNodeSizes( + flatNodes.map((n) => n.id), + constants, + nodeExtraSpace + ) + const mergedSizes = new Map() + if (extraSizes) { + for (const [id, size] of extraSizes) mergedSizes.set(id, size) + } + for (const [id, size] of wrapperSizes) mergedSizes.set(id, size) + const sugResult = runSugiyama( + flatNodes, + constants, + mergedSizes.size > 0 ? mergedSizes : undefined + ) // Step 6: Resolve absolute positions // First, set positions for regular (non-group) nodes + // Apply per-node y-offset from nodeExtraSpace so decorations above have room for (const [nid, pos] of sugResult.positions) { if (groupByHeadId.has(nid)) continue // Handle groups separately - positions.set(nid, { x: pos.x, y: pos.y }) + const extra = nodeExtraSpace?.get(nid) + positions.set(nid, { x: pos.x, y: pos.y + (extra?.top ?? 0) }) } // Now expand group wrappers into absolute positions @@ -411,9 +492,15 @@ function layoutLevel( const rowHeight = constants.nodeHeight + constants.gapV const isBranch = gl.group.type === 'branch' + const isGroup = gl.group.type === 'group' // Position the head node at the top-center of the wrapper - positions.set(headId, { x: wrapperPos.x, y: wrapperPos.y }) + // Apply extra top padding so decorations above the head node have room + const headExtra = nodeExtraSpace?.get(headId) + positions.set(headId, { + x: wrapperPos.x, + y: wrapperPos.y + (headExtra?.top ?? 0) + }) if (isBranch) { // Reuse cached branchWidths and totalWidth @@ -441,6 +528,26 @@ function layoutLevel( x: wrapperPos.x, y: wrapperPos.y + rowHeight + maxBranchHeight + constants.gapV }) + } else if (isGroup) { + // Group: body is centered within wrapper (no x offset) + const headExtra = nodeExtraSpace?.get(gl.group.headId) + const groupHeadRow = GROUP_HEADER_HEIGHT + (headExtra?.bottom ?? 0) + GROUP_TOP_PADDING + const bl = gl.branchLayouts[0] + if (bl) { + for (const [innerNodeId, innerPos] of bl.result.positions) { + positions.set(innerNodeId, { + x: wrapperPos.x + innerPos.x, + y: wrapperPos.y + groupHeadRow + innerPos.y + }) + } + } + + // Position end node below body + const bodyHeight = bl?.bbox.height ?? 0 + positions.set(gl.group.endId, { + x: wrapperPos.x, + y: wrapperPos.y + groupHeadRow + bodyHeight + GROUP_PADDING + }) } else { // Loop: position start, body, and end const bl = gl.branchLayouts[0] @@ -463,16 +570,34 @@ function layoutLevel( } } + // Collect group dimensions from this level and child layouts + const groupDimensions = new Map() + for (const [headId, gl] of groupLayouts) { + groupDimensions.set(headId, { width: gl.wrapperWidth, height: gl.wrapperHeight }) + // Propagate child groupDimensions from recursive branch layouts + for (const bl of gl.branchLayouts) { + if (bl.result.groupDimensions) { + for (const [childId, dims] of bl.result.groupDimensions) { + groupDimensions.set(childId, dims) + } + } + } + } + // Compute overall bbox (nodes + group wrapper extents) let minX = Infinity let maxX = -Infinity let minY = Infinity let maxY = -Infinity - for (const pos of positions.values()) { - minX = Math.min(minX, pos.x - constants.nodeWidth / 2) - maxX = Math.max(maxX, pos.x + constants.nodeWidth / 2) - minY = Math.min(minY, pos.y) - maxY = Math.max(maxY, pos.y + constants.nodeHeight) + for (const [nid, pos] of positions) { + // Group end nodes are zero-height markers — skip them + if (nid.startsWith('group:') && nid.endsWith('-end')) continue + const extra = nodeExtraSpace?.get(nid) + minX = Math.min(minX, pos.x - constants.nodeWidth / 2 - (extra?.left ?? 0)) + maxX = Math.max(maxX, pos.x + constants.nodeWidth / 2 + (extra?.right ?? 0)) + // Account for top decoration space above the node + minY = Math.min(minY, pos.y - (extra?.top ?? 0)) + maxY = Math.max(maxY, pos.y + constants.nodeHeight + (extra?.bottom ?? 0)) } // Account for group wrapper extents in bbox (e.g. LOOP_INDENT makes wrappers wider than nodes) for (const [headId, gl] of groupLayouts) { @@ -492,7 +617,12 @@ function layoutLevel( width: Math.max(bboxWidth, constants.nodeWidth), height: Math.max(bboxHeight, 0) } - return { positions, bbox: finalBbox, contentMinX } + return { + positions, + bbox: finalBbox, + contentMinX, + groupDimensions: groupDimensions.size > 0 ? groupDimensions : undefined + } } /** @@ -500,10 +630,16 @@ function layoutLevel( * * Takes the flat list of nodes and edges from graphBuilder and produces * absolute positions that account for compound structure (branches, loops). + * + * nodeExtraSpace: per-node top/bottom/left/right padding that should be allocated in layout. + * After layout, each node's y is shifted down by its top padding so decorations + * (assets, AI tools, group headers) have room above. Left/right padding widens the + * column allocated to the node so neighbors are pushed further away. */ export function compoundLayout( nodes: { id: string; parentIds?: string[] }[], - constants?: Partial + constants?: Partial, + nodeExtraSpace?: Map ): LayoutResult { const c: LayoutConstants = { nodeWidth: constants?.nodeWidth ?? NODE.width, @@ -528,7 +664,7 @@ export function compoundLayout( } const nodeIds = nodes.map((n) => n.id) - const result = layoutLevel(nodeIds, allNodes, c, childrenMap) + const result = layoutLevel(nodeIds, allNodes, c, childrenMap, 0, nodeExtraSpace) // Shift positions so minX=0 (left-aligned). // FlowGraphV2 centers with: xCenter = viewport/2 - bbox.width/2 diff --git a/frontend/src/lib/components/graph/flowStructure.test.ts b/frontend/src/lib/components/graph/flowStructure.test.ts new file mode 100644 index 0000000000..fce5071e80 --- /dev/null +++ b/frontend/src/lib/components/graph/flowStructure.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, vi } from 'vitest' + +// Mock modules that transitively import CSS/Monaco +vi.mock('monaco-editor', () => ({})) +vi.mock('@xyflow/svelte', () => ({})) +vi.mock('./renderers/nodes/AssetNode.svelte', () => ({ + assetDisplaysAsOutputInFlowGraph: () => false +})) +vi.mock('../modulesTest.svelte', () => ({})) + +import type { GraphGroup } from './groupEditor.svelte' +import type { FlowModule } from '$lib/gen' +import { + buildStructureTree, + flattenStructureIds, + deriveGroupsFromStructure, + collectLeafIds, + findInStructure +} from './flowStructure' + +function makeModule(id: string): FlowModule { + return { + id, + value: { type: 'rawscript', content: '', language: 'python3' } as any + } as FlowModule +} + +function makeBranchAll(id: string, branchInnerIds: string[][]): FlowModule { + return { + id, + value: { + type: 'branchall', + branches: branchInnerIds.map((ids) => ({ modules: ids.map((iid) => makeModule(iid)) })) + } as any + } as FlowModule +} + +function makeForloop(id: string, innerIds: string[]): FlowModule { + return { + id, + value: { + type: 'forloopflow', + modules: innerIds.map((iid) => makeModule(iid)), + iterator: { type: 'javascript', expr: '' } + } as any + } as FlowModule +} + +function makeGroup( + id: string, + start_id: string, + end_id: string, + moduleIds: string[] = [] +): GraphGroup { + return { id, start_id, end_id, moduleIds } +} + +describe('buildStructureTree', () => { + const modules = [makeModule('a'), makeModule('b'), makeModule('c')] + + it('builds structure tree for a valid group', () => { + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])] + const result = buildStructureTree(modules, groups) + // Should have a group node + the remaining leaf 'c' + expect(result).toHaveLength(2) + expect(result[0].kind).toBe('group') + expect(result[0].id).toBe('g1') + expect(result[0].branches[0].children).toHaveLength(2) + expect(result[1].kind).toBe('leaf') + expect(result[1].id).toBe('c') + }) + + it('throws on duplicate group IDs', () => { + const groups = [makeGroup('g1', 'a', 'a', ['a']), makeGroup('g1', 'b', 'c', ['b', 'c'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/duplicate group id.*g1/i) + }) + + it('throws on inverted range (start_id after end_id)', () => { + const groups = [makeGroup('g1', 'c', 'a', ['a', 'b', 'c'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/inverted range/i) + }) + + it('throws on partially overlapping groups', () => { + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b']), makeGroup('g2', 'b', 'c', ['b', 'c'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/overlap without nesting/i) + }) + + it('throws when group start_id is a virtual node (Input)', () => { + const groups = [makeGroup('g1', 'Input', 'b', ['a', 'b'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/virtual node/i) + }) + + it('throws when group end_id is a virtual node (Result)', () => { + const groups = [makeGroup('g1', 'a', 'Result', ['a', 'b', 'c'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/virtual node/i) + }) + + it('throws when group references Trigger', () => { + const groups = [makeGroup('g1', 'Trigger', 'c', ['a', 'b', 'c'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/virtual node/i) + }) + + it('allows fully nested groups', () => { + const mods = [makeModule('a'), makeModule('b'), makeModule('c'), makeModule('d')] + const groups = [ + makeGroup('outer', 'a', 'd', ['a', 'b', 'c', 'd']), + makeGroup('inner', 'b', 'c', ['b', 'c']) + ] + const result = buildStructureTree(mods, groups) + expect(result).toHaveLength(1) // outer group contains everything + expect(result[0].kind).toBe('group') + // Inner group should be nested + const outerChildren = result[0].branches[0].children + expect(outerChildren).toHaveLength(3) // a, inner-group, d + expect(outerChildren[1].kind).toBe('group') + expect(outerChildren[1].id).toBe('inner') + }) + + it('handles empty modules', () => { + const result = buildStructureTree([], []) + expect(result).toHaveLength(0) + }) + + it('handles container modules (forloop)', () => { + const mods = [makeForloop('loop', ['x', 'y']), makeModule('c')] + const result = buildStructureTree(mods, []) + expect(result).toHaveLength(2) + expect(result[0].kind).toBe('forloopflow') + expect(result[0].branches).toHaveLength(1) + expect(result[0].branches[0].children).toHaveLength(2) + expect(result[0].branches[0].children[0].id).toBe('x') + }) + + it('handles groups inside containers', () => { + const mods = [makeForloop('loop', ['x', 'y', 'z'])] + const groups = [makeGroup('g1', 'x', 'y', ['x', 'y'])] + const result = buildStructureTree(mods, groups) + expect(result).toHaveLength(1) + expect(result[0].kind).toBe('forloopflow') + const innerChildren = result[0].branches[0].children + expect(innerChildren).toHaveLength(2) // group + z + expect(innerChildren[0].kind).toBe('group') + expect(innerChildren[0].id).toBe('g1') + }) + + it('throws when group spans parallel branches (branchall)', () => { + const mods = [ + makeModule('a'), + makeBranchAll('ba', [ + ['x', 'y'], + ['p', 'q'] + ]), + makeModule('c') + ] + const groups = [makeGroup('g1', 'x', 'q', ['x', 'q'])] + expect(() => buildStructureTree(mods, groups)).toThrow(/could not be resolved/) + }) +}) + +describe('flattenStructureIds', () => { + it('flattens a simple tree', () => { + const modules = [makeModule('a'), makeModule('b'), makeModule('c')] + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])] + const tree = buildStructureTree(modules, groups) + const ids = flattenStructureIds(tree) + expect(ids).toEqual(['a', 'b', 'c']) + }) + + it('flattens nested groups', () => { + const mods = [makeModule('a'), makeModule('b'), makeModule('c'), makeModule('d')] + const groups = [ + makeGroup('outer', 'a', 'd', ['a', 'b', 'c', 'd']), + makeGroup('inner', 'b', 'c', ['b', 'c']) + ] + const tree = buildStructureTree(mods, groups) + const ids = flattenStructureIds(tree) + expect(ids).toEqual(['a', 'b', 'c', 'd']) + }) +}) + +describe('deriveGroupsFromStructure', () => { + it('derives group definitions with correct start/end', () => { + const modules = [makeModule('a'), makeModule('b'), makeModule('c')] + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])] + const tree = buildStructureTree(modules, groups) + const derived = deriveGroupsFromStructure(tree) + expect(derived).toHaveLength(1) + expect(derived[0].start_id).toBe('a') + expect(derived[0].end_id).toBe('b') + }) + + it('derives nested groups', () => { + const mods = [makeModule('a'), makeModule('b'), makeModule('c'), makeModule('d')] + const groups = [ + makeGroup('outer', 'a', 'd', ['a', 'b', 'c', 'd']), + makeGroup('inner', 'b', 'c', ['b', 'c']) + ] + const tree = buildStructureTree(mods, groups) + const derived = deriveGroupsFromStructure(tree) + expect(derived).toHaveLength(2) + expect(derived[0].start_id).toBe('a') + expect(derived[0].end_id).toBe('d') + expect(derived[1].start_id).toBe('b') + expect(derived[1].end_id).toBe('c') + }) +}) + +describe('findInStructure', () => { + it('finds a leaf node', () => { + const modules = [makeModule('a'), makeModule('b')] + const tree = buildStructureTree(modules, []) + const found = findInStructure(tree, 'b') + expect(found).toBeDefined() + expect(found!.index).toBe(1) + }) + + it('finds a node inside a group', () => { + const modules = [makeModule('a'), makeModule('b'), makeModule('c')] + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])] + const tree = buildStructureTree(modules, groups) + const found = findInStructure(tree, 'b') + expect(found).toBeDefined() + expect(found!.index).toBe(1) + // parentChildren should be the group's branch children + expect(found!.parentChildren).toHaveLength(2) + }) + + it('finds a group node by group id', () => { + const modules = [makeModule('a'), makeModule('b')] + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])] + const tree = buildStructureTree(modules, groups) + const found = findInStructure(tree, 'g1') + expect(found).toBeDefined() + expect(found!.index).toBe(0) + }) +}) + +describe('collectLeafIds', () => { + it('collects all leaf module IDs including inside containers', () => { + const mods = [makeForloop('loop', ['x', 'y']), makeModule('c')] + const tree = buildStructureTree(mods, []) + const ids = collectLeafIds(tree) + expect(ids).toEqual(['loop', 'x', 'y', 'c']) + }) +}) diff --git a/frontend/src/lib/components/graph/flowStructure.ts b/frontend/src/lib/components/graph/flowStructure.ts new file mode 100644 index 0000000000..e2a725bc85 --- /dev/null +++ b/frontend/src/lib/components/graph/flowStructure.ts @@ -0,0 +1,498 @@ +import type { FlowModule } from '$lib/gen' + +import type { FlowGroup, GraphGroup } from './groupEditor.svelte' +import { getContainerInnerArrays } from './groupEditor.svelte' +import { VIRTUAL_NODE_IDS } from './groupDetectionUtils' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ContainerKind = 'forloopflow' | 'whileloopflow' | 'branchone' | 'branchall' + +export type StructureBranch = { + label?: string + children: FlowStructureNode[] +} + +export type FlowStructureNode = { + /** FlowModule.id for modules, groupKey(g) for groups */ + id: string + kind: 'leaf' | 'group' | ContainerKind + /** Only present when kind === 'group' */ + group?: FlowGroup + /** Only present when kind === 'group' — flat module IDs for step count */ + moduleIds?: string[] + /** Child branches. leaf=[], group=[{children}], container=[{children}, ...] */ + branches: StructureBranch[] +} + +// --------------------------------------------------------------------------- +// Type guards +// --------------------------------------------------------------------------- +// Building the structure tree +// --------------------------------------------------------------------------- + +export function buildStructureTree( + modules: FlowModule[], + groups: GraphGroup[] +): FlowStructureNode[] { + const { items, consumed } = buildStructureTreeRecurse(modules, groups) + const unconsumed = groups.filter((g) => !consumed.has(g.id)) + if (unconsumed.length > 0) { + throw new Error( + `Group(s) ${unconsumed.map((g) => `'${g.id}'`).join(', ')} could not be resolved: ` + + `their start/end nodes do not belong to the same branch` + ) + } + return items +} + +export function moduleToStructureNode(mod: FlowModule): FlowStructureNode { + const innerArrays = getContainerInnerArrays(mod) + if (innerArrays.length === 0) { + return { id: mod.id, kind: 'leaf', branches: [] } + } + + const kind = (mod.value as any).type as ContainerKind + const branches: StructureBranch[] = innerArrays.map(({ get, label }) => ({ + label, + children: [] // filled later by recursion + })) + + return { id: mod.id, kind, branches } +} + +function buildStructureTreeRecurse( + modules: FlowModule[], + groups: GraphGroup[] +): { items: FlowStructureNode[]; consumed: Set } { + if (modules.length === 0) { + return { items: [], consumed: new Set() } + } + + const indexMap = new Map() + for (let i = 0; i < modules.length; i++) { + indexMap.set(modules[i].id, i) + } + + // Reject duplicate group IDs + const seenGroupIds = new Set() + for (const g of groups) { + if (seenGroupIds.has(g.id)) { + throw new Error(`Duplicate group id: '${g.id}'`) + } + seenGroupIds.add(g.id) + } + + // Reject groups referencing virtual nodes + for (const g of groups) { + if (VIRTUAL_NODE_IDS.has(g.start_id) || VIRTUAL_NODE_IDS.has(g.end_id)) { + throw new Error( + `Group '${g.id}' references virtual node: groups cannot include Input, Result, or Trigger` + ) + } + } + + // Partition: groups for this level vs rest + const levelGroups: GraphGroup[] = [] + const otherGroups: GraphGroup[] = [] + for (const g of groups) { + if (indexMap.has(g.start_id) && indexMap.has(g.end_id)) { + const s = indexMap.get(g.start_id)! + const e = indexMap.get(g.end_id)! + if (s > e) { + throw new Error( + `Group '${g.id}' has inverted range: start_id='${g.start_id}' (index ${s}) > end_id='${g.end_id}' (index ${e})` + ) + } + levelGroups.push(g) + } else { + otherGroups.push(g) + } + } + + // Validate no partial overlaps + for (let i = 0; i < levelGroups.length; i++) { + for (let j = i + 1; j < levelGroups.length; j++) { + const a = levelGroups[i] + const b = levelGroups[j] + const aStart = indexMap.get(a.start_id)! + const aEnd = indexMap.get(a.end_id)! + const bStart = indexMap.get(b.start_id)! + const bEnd = indexMap.get(b.end_id)! + + if (aEnd < bStart || bEnd < aStart) continue + if (aStart <= bStart && bEnd <= aEnd) continue + if (bStart <= aStart && aEnd <= bEnd) continue + + throw new Error(`Groups '${a.id}' and '${b.id}' overlap without nesting`) + } + } + + // Build grouped structure for this level + function build( + startIdx: number, + endIdx: number, + availableGroups: GraphGroup[] + ): FlowStructureNode[] { + const result: FlowStructureNode[] = [] + let i = startIdx + while (i <= endIdx) { + const candidates = availableGroups.filter((g) => { + const gStart = indexMap.get(g.start_id)! + const gEnd = indexMap.get(g.end_id)! + return gStart === i && gEnd <= endIdx + }) + candidates.sort((a, b) => { + const spanA = indexMap.get(a.end_id)! - indexMap.get(a.start_id)! + const spanB = indexMap.get(b.end_id)! - indexMap.get(b.start_id)! + return spanB - spanA + }) + + const group = candidates[0] + if (group) { + const gEnd = indexMap.get(group.end_id)! + const remaining = availableGroups.filter((g) => g.id !== group.id) + const innerNodes = build(i, gEnd, remaining) + + const moduleIds: string[] = [] + for (let k = i; k <= gEnd; k++) { + moduleIds.push(modules[k].id) + } + + result.push({ + id: group.id, + kind: 'group', + group: { + summary: group.summary, + note: group.note, + color: group.color, + autocollapse: group.autocollapse, + start_id: group.start_id, + end_id: group.end_id + }, + moduleIds, + branches: [{ children: innerNodes }] + }) + i = gEnd + 1 + } else { + result.push(moduleToStructureNode(modules[i])) + i++ + } + } + return result + } + + const result = build(0, modules.length - 1, levelGroups) + + // Recurse into containers with remaining unconsumed groups + const consumed = new Set(levelGroups.map((g) => g.id)) + let remaining = otherGroups + + function recurseIntoContainers(items: FlowStructureNode[]): void { + for (const item of items) { + if (item.kind === 'group') { + recurseIntoContainers(item.branches[0].children) + continue + } + if (item.branches.length === 0) continue + + // This is a container module — get inner FlowModule arrays and recurse + const modIdx = indexMap.get(item.id) + if (modIdx === undefined) continue + const mod = modules[modIdx] + + const innerArrays = getContainerInnerArrays(mod) + for (let bi = 0; bi < innerArrays.length; bi++) { + const inner = buildStructureTreeRecurse(innerArrays[bi].get(), remaining) + item.branches[bi] = { + label: item.branches[bi]?.label, + children: inner.items + } + for (const id of inner.consumed) consumed.add(id) + remaining = remaining.filter((g) => !inner.consumed.has(g.id)) + } + } + } + recurseIntoContainers(result) + + return { items: result, consumed } +} + +// --------------------------------------------------------------------------- +// Traversal utilities +// --------------------------------------------------------------------------- + +/** Generic DFS over the structure tree */ +export function dfsStructure( + nodes: FlowStructureNode[], + fn: (node: FlowStructureNode, parentArray: FlowStructureNode[]) => void +): void { + for (const node of nodes) { + fn(node, nodes) + for (const branch of node.branches) { + dfsStructure(branch.children, fn) + } + } +} + +/** Flatten to ordered module IDs (groups are transparent) */ +export function flattenStructureIds(nodes: FlowStructureNode[]): string[] { + const ids: string[] = [] + for (const node of nodes) { + if (node.kind === 'group') { + ids.push(...flattenStructureIds(node.branches[0].children)) + } else { + ids.push(node.id) + } + } + return ids +} + +/** Collect leaf module IDs recursively (including inside containers) */ +export function collectLeafIds(nodes: FlowStructureNode[]): string[] { + const ids: string[] = [] + for (const node of nodes) { + if (node.kind === 'group') { + ids.push(...collectLeafIds(node.branches[0].children)) + } else { + ids.push(node.id) + for (const branch of node.branches) { + ids.push(...collectLeafIds(branch.children)) + } + } + } + return ids +} + +// --------------------------------------------------------------------------- +// Finding nodes in the tree +// --------------------------------------------------------------------------- + +export type FindResult = { parentChildren: FlowStructureNode[]; index: number } + +export function findInStructure(nodes: FlowStructureNode[], id: string): FindResult | undefined { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i] + if (node.id === id) return { parentChildren: nodes, index: i } + for (const branch of node.branches) { + const found = findInStructure(branch.children, id) + if (found) return found + } + } + return undefined +} + +/** + * Match a structure node against a graph node ID. + * Handles group head/end IDs (group:X, group:X-end) and collapsed-group:X. + */ +export function matchStructureNode(node: FlowStructureNode, nodeId: string): boolean { + if (node.id === nodeId) return true + if (node.kind === 'group') { + return ( + nodeId === `group:${node.id}` || + nodeId === `group:${node.id}-end` || + nodeId === `collapsed-group:${node.id}` + ) + } + return false +} + +/** + * Find insert index using graph node IDs (handles group:X-end etc.). + * Returns the index OF the matched item (insert before it). + * For group-end nodes, returns index AFTER the group (insert after it). + */ +export function findInsertIndexByNodeId(items: FlowStructureNode[], targetNodeId: string): number { + // group-end: insert after the group + if (targetNodeId.startsWith('group:') && targetNodeId.endsWith('-end')) { + const groupId = targetNodeId.slice('group:'.length, -'-end'.length) + const idx = items.findIndex((n) => n.kind === 'group' && n.id === groupId) + return idx >= 0 ? idx + 1 : items.length + } + // Everything else: insert at the matched item's position + for (let i = 0; i < items.length; i++) { + if (matchStructureNode(items[i], targetNodeId)) return i + } + return items.length +} + +// --------------------------------------------------------------------------- +// Deriving groups from the structure tree +// --------------------------------------------------------------------------- + +export function deriveGroupsFromStructure(nodes: FlowStructureNode[]): FlowGroup[] { + const groups: FlowGroup[] = [] + for (const node of nodes) { + if (node.kind === 'group' && node.group) { + const flatIds = flattenStructureIds(node.branches[0].children) + if (flatIds.length === 0) { + console.warn(`deriveGroupsFromStructure: skipping empty group "${node.id}"`) + continue + } + groups.push({ + ...node.group, + start_id: flatIds[0], + end_id: flatIds[flatIds.length - 1] + }) + // Recurse for nested groups + groups.push(...deriveGroupsFromStructure(node.branches[0].children)) + } else { + for (const branch of node.branches) { + groups.push(...deriveGroupsFromStructure(branch.children)) + } + } + } + return groups +} + +// --------------------------------------------------------------------------- +// Syncing structure back to FlowModule[] +// --------------------------------------------------------------------------- + +/** + * Reconstruct a FlowModule[] from the structure tree, looking up originals + * from moduleMap and patching container inner arrays to match the tree ordering. + */ +export function applyStructureToModules( + nodes: FlowStructureNode[], + moduleMap: Map +): FlowModule[] { + const result: FlowModule[] = [] + for (const node of nodes) { + if (node.kind === 'group') { + // Groups are transparent — splice their children into this level + result.push(...applyStructureToModules(node.branches[0].children, moduleMap)) + } else { + const mod = moduleMap.get(node.id) + if (!mod) continue + + // Patch container inner arrays + if (node.branches.length > 0) { + const innerArrays = getContainerInnerArrays(mod) + for (let bi = 0; bi < innerArrays.length && bi < node.branches.length; bi++) { + innerArrays[bi].set(applyStructureToModules(node.branches[bi].children, moduleMap)) + } + } + + result.push(mod) + } + } + return result +} + +// --------------------------------------------------------------------------- +// Empty groups cleanup +// --------------------------------------------------------------------------- + +/** + * Walk the tree, remove group nodes that have no leaf modules, and return + * the removed groups. Mutates the input array in-place. + * Recurses depth-first so inner groups are cleaned before checking outer ones. + */ +export function removeEmptyGroups(nodes: FlowStructureNode[]): FlowGroup[] { + const removed: FlowGroup[] = [] + for (let i = nodes.length - 1; i >= 0; i--) { + const node = nodes[i] + if (node.kind === 'group' && node.group) { + // Recurse first — inner groups may become empty too + removed.push(...removeEmptyGroups(node.branches[0].children)) + if (flattenStructureIds(node.branches[0].children).length === 0) { + removed.push(node.group) + nodes.splice(i, 1) + } + } else { + for (const branch of node.branches) { + removed.push(...removeEmptyGroups(branch.children)) + } + } + } + return removed +} + +/** Walk the structure tree to compute nesting depth for each group (O(n)). */ +export function computeGroupDepths(tree: FlowStructureNode[]): Record { + const depths: Record = {} + function walk(nodes: FlowStructureNode[], groupDepth: number): void { + for (const node of nodes) { + if (node.kind === 'group') { + depths[node.id] = groupDepth + for (const branch of node.branches) { + walk(branch.children, groupDepth + 1) + } + } else { + for (const branch of node.branches) { + walk(branch.children, groupDepth) + } + } + } + } + walk(tree, 0) + return depths +} + +/** + * Find duplicate groups in the structure tree (same start_id:end_id after mutation). + * Returns the groups that should be removed (keeps the first, removes subsequent duplicates). + */ +export function findDuplicateGroups(nodes: FlowStructureNode[]): FlowGroup[] { + const duplicates: FlowGroup[] = [] + const seen = new Set() + + function walk(items: FlowStructureNode[]): void { + for (const node of items) { + if (node.kind === 'group' && node.group) { + const flatIds = flattenStructureIds(node.branches[0].children) + if (flatIds.length > 0) { + const key = `${flatIds[0]}:${flatIds[flatIds.length - 1]}` + if (seen.has(key)) { + duplicates.push(node.group) + } else { + seen.add(key) + } + } + walk(node.branches[0].children) + } else { + for (const branch of node.branches) { + walk(branch.children) + } + } + } + } + walk(nodes) + return duplicates +} + +/** Remove duplicate groups from the structure tree (keeps first occurrence). */ +export function removeDuplicateGroups(nodes: FlowStructureNode[]): FlowGroup[] { + const removed: FlowGroup[] = [] + const seen = new Set() + + function walk(items: FlowStructureNode[]): void { + for (let i = items.length - 1; i >= 0; i--) { + const node = items[i] + if (node.kind === 'group' && node.group) { + walk(node.branches[0].children) + const flatIds = flattenStructureIds(node.branches[0].children) + if (flatIds.length > 0) { + const key = `${flatIds[0]}:${flatIds[flatIds.length - 1]}` + if (seen.has(key)) { + // Replace group node with its children (ungroup) + removed.push(node.group) + items.splice(i, 1, ...node.branches[0].children) + } else { + seen.add(key) + } + } + } else { + for (const branch of node.branches) { + walk(branch.children) + } + } + } + } + walk(nodes) + return removed +} diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index 192b5c0c34..4fd7641096 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -8,6 +8,14 @@ import { getFlowModuleAssets, type AssetWithAltAccessType } from '../assets/lib' import { assetDisplaysAsOutputInFlowGraph } from './renderers/nodes/AssetNode.svelte' import type { ModulesTestStates, ModuleTestState } from '../modulesTest.svelte' import type { ModuleActionInfo } from '$lib/components/flows/flowDiff' +import { + type FlowStructureNode, + collectLeafIds, + findInsertIndexByNodeId, + buildStructureTree +} from './flowStructure' +import { groupKey, type FlowGroup } from './groupEditor.svelte' +import { computeGroupModuleIds } from './groupDetectionUtils' export type InsertKind = | 'script' @@ -62,6 +70,7 @@ export type GraphEventHandlers = { simplifyFlow: (b: boolean) => void expandSubflow: (id: string, path: string) => void minimizeSubflow: (id: string) => void + expandGroup: (groupId: string) => void updateMock: (detail: { mock: FlowModule['mock']; id: string }) => void testUpTo: (id: string) => void editInput: (moduleId: string, key: string) => void @@ -111,6 +120,9 @@ export type FlowNode = | AssetsOverflowedN | AiToolN | NewAiToolN + | CollapsedGroupN + | GroupHeadN + | GroupEndN export type InputN = { type: 'input2' @@ -316,6 +328,48 @@ export type NewAiToolN = { } } +export type CollapsedGroupN = { + type: 'collapsedGroup' + data: { + groupId: string + summary: string | undefined + note: string | undefined + color: string | undefined + autocollapse: boolean | undefined + stepCount: number + modules: FlowModule[] + flowModuleStates: Record | undefined + flowJob: Job | undefined + isOwner: boolean + suspendStatus: Record + showNotes: boolean + editMode: boolean + eventHandlers: GraphEventHandlers + } +} + +export type GroupHeadN = { + type: 'groupHead' + data: { + groupId: string + summary: string | undefined + note: string | undefined + color: string | undefined + autocollapse: boolean | undefined + editMode: boolean + showNotes: boolean + eventHandlers: GraphEventHandlers + wrapperWidth?: number + } +} + +export type GroupEndN = { + type: 'groupEnd' + data: { + groupId: string + } +} + export function topologicalSort( nodes: { id: string; parentIds?: string[] }[] ): { id: string; parentIds?: string[] }[] { @@ -336,22 +390,8 @@ export function topologicalSort( return result.reverse() } -// input2: InputNode, -// module: ModuleNode, -// branchAllStart: BranchAllStart, -// branchAllEnd: BranchAllEndNode, -// forLoopEnd: ForLoopEndNode, -// forLoopStart: ForLoopStartNode, -// result: ResultNode, -// whileLoopStart: ForLoopStartNode, -// whileLoopEnd: ForLoopEndNode, -// branchOneStart: BranchOneStart, -// branchOneEnd: BranchOneEndNode, -// subflowBound: SubflowBound, -// noBranch: NoBranchNode, -// trigger: TriggersNode - export function graphBuilder( + structureTree: FlowStructureNode[], modules: FlowModule[] | undefined, extra: { disableAi: boolean @@ -383,11 +423,9 @@ export function graphBuilder( selectedId: string | undefined, simplifiableFlow: SimplifiableFlow | undefined, flowPathForTriggerNode: string | undefined, - expandedSubflows: Record - // triggerProps?: { - // path?: string - // flowIsSimplifiable?: boolean - // } + expandedSubflows: Record, + showNotes: boolean, + collapsedGroupIds: Set ): { nodes: { [key: string]: NodeLayout } edges: Edge[] @@ -403,7 +441,13 @@ export function graphBuilder( const nodes: NodeLayout[] = [] const edges: Edge[] = [] - function addNode(module: FlowModule) { + // Lookup map from module ID to the original reactive FlowModule objects. + const moduleMap = new Map() + for (const m of getAllModules(modules, failureModule)) { + moduleMap.set(m.id, m) + } + + function addNode(module: FlowModule, extraData?: Record) { const duplicated = nodes.find((n) => n.id === module.id) if (duplicated) { console.log('Duplicated node detected: ', module, duplicated) @@ -424,7 +468,8 @@ export function graphBuilder( isOwner: extra.isOwner, flowJob: extra.flowJob, assets: getFlowModuleAssets(module, extra.additionalAssetsMap), - moduleAction: extra.moduleActions?.[module.id] + moduleAction: extra.moduleActions?.[module.id], + ...extraData }, type: 'module', selectable: true @@ -483,14 +528,20 @@ export function graphBuilder( customId?: string type?: string subModules?: FlowModule[] + currentItems?: FlowStructureNode[] disableMoveIds?: string[] } ) { parents[targetId] = [...(parents[targetId] ?? []), sourceId] - const mods = options?.subModules ?? modules - - let index = mods?.findIndex((m) => m.id === targetId) ?? -1 + let index: number + if (options?.currentItems) { + index = findInsertIndexByNodeId(options.currentItems, targetId) + } else { + const mods = options?.subModules ?? modules + const found = mods?.findIndex((m) => m.id === targetId) ?? -1 + index = found >= 0 ? found : (mods?.length ?? 0) + } const visited = new Set() const recStack = new Set() @@ -514,8 +565,7 @@ export function graphBuilder( simplifiedTriggerView: simplifiableFlow?.simplifiedFlow, disableMoveIds: options?.disableMoveIds, enableTrigger: sourceId === 'Input', - // If the index is -1, it means that the target module is not in the modules array, so we set it to the length of the array - index: index >= 0 ? index : (mods?.length ?? 0), + index, ...extra, insertable: extra.insertable && !options?.disableInsert && prefix == undefined, shouldOffsetInsertBtnDueToAssetNode: nodeIdsWithOutputAssets.has(sourceId) @@ -591,7 +641,7 @@ export function graphBuilder( } function processModules( - modules: FlowModule[], + items: FlowStructureNode[], branch: { rootId: string; branch: number } | undefined, beforeNode: NodeLayout, nextNode: NodeLayout | undefined, @@ -600,31 +650,166 @@ export function graphBuilder( disableMoveIds: string[] = [], parentIndex?: string ) { + // For subflow prefix rewriting, clone modules into moduleMap with prefixed IDs + // (avoid mutating reactive originals which would trigger state_unsafe_mutation in $derived) if (prefix != undefined) { - modules.forEach((m) => { - if (!m['oid']) { - m['oid'] = m.id + items.forEach((item) => { + if (item.kind === 'group') return + const m = moduleMap.get(item.id) + if (m) { + const oid = m['oid'] ?? m.id + const newId = 'subflow:' + prefix + oid + const clone = { ...m, id: newId, oid } as FlowModule & { oid: string } + clone['oid'] = oid + moduleMap.set(newId, clone) + item.id = newId } - m.id = 'subflow:' + prefix + m['oid'] }) } let previousId: string | undefined = undefined - if (modules.length === 0) { + if (items.length === 0) { if (nextNode) { addEdge(beforeNode.id, nextNode.id, branch, prefix, { - subModules: modules, + currentItems: items, disableMoveIds }) } } else { - modules.forEach((module, index) => { + items.forEach((item, index) => { + // --- Group items --- + if (item.kind === 'group') { + const g = item.group! + const gId = item.id + + if (collapsedGroupIds.has(gId)) { + // Collapsed group: single node + const nodeId = `collapsed-group:${gId}` + const leafIds = collectLeafIds(item.branches[0].children) + nodes.push({ + id: nodeId, + data: { + groupId: gId, + summary: g.summary, + note: g.note, + color: g.color, + autocollapse: g.autocollapse, + stepCount: item.moduleIds?.length ?? 0, + modules: leafIds + .map((id) => moduleMap.get(id)) + .filter((m): m is FlowModule => !!m), + flowModuleStates: extra.flowModuleStates, + flowJob: extra.flowJob, + isOwner: extra.isOwner, + suspendStatus: extra.suspendStatus, + showNotes, + editMode: prefix == undefined && extra.editMode, + eventHandlers + }, + type: 'collapsedGroup', + selectable: false + }) + + // Wire: previous → collapsedGroup + if (index > 0 && previousId) { + addEdge(previousId, nodeId, branch, prefix, { + currentItems: items, + disableMoveIds + }) + } + + previousId = nodeId + } else { + // Expanded group: head → recurse → end + const headId = `group:${gId}` + const endId = `group:${gId}-end` + const localDisableMoveIds = [...disableMoveIds, headId] + + const headNode: NodeLayout = { + id: headId, + data: { + groupId: gId, + summary: g.summary, + note: g.note, + color: g.color, + autocollapse: g.autocollapse, + editMode: prefix == undefined && extra.editMode, + showNotes, + eventHandlers + }, + type: 'groupHead', + selectable: false + } + + const endNode: NodeLayout = { + id: endId, + data: { + groupId: gId + }, + type: 'groupEnd', + selectable: false + } + + nodes.push(headNode) + nodes.push(endNode) + + // Wire: previous → headNode + if (index > 0 && previousId) { + addEdge(previousId, headId, branch, prefix, { + currentItems: items, + disableMoveIds + }) + } + + // Recurse inner modules + processModules( + item.branches[0].children, + { rootId: headId, branch: 0 }, + headNode, + endNode, + simplifiedTriggerView, + prefix, + localDisableMoveIds, + parentIndex + ) + + previousId = endId + } + + // Shared first/last edge wiring for groups + if (index === 0) { + addEdge( + beforeNode.id, + collapsedGroupIds.has(gId) ? `collapsed-group:${gId}` : `group:${gId}`, + undefined, + prefix, + { + currentItems: items, + disableMoveIds, + disableInsert: simplifiedTriggerView + } + ) + } + + if (index === items.length - 1 && previousId && nextNode) { + addEdge(previousId, nextNode.id, branch, prefix, { + currentItems: items, + disableMoveIds + }) + } + + return + } + + // --- Regular FlowModule items --- + const module = moduleMap.get(item.id) + if (!module) return const localDisableMoveIds = [...disableMoveIds, module.id] - // Add the edge between the previous node and the current one + // Inter-module edge: connect previous → current (expanded subflows handle their own) if (index > 0 && previousId && expandedSubflows[module.id] == undefined) { addEdge(previousId, module.id, branch, prefix, { - subModules: modules, + currentItems: items, disableMoveIds }) } @@ -700,7 +885,7 @@ export function graphBuilder( ) processModules( - branch.modules, + item.branches[branchIndex]?.children ?? [], { rootId: module.id, branch: branchIndex }, startNode, endNode, @@ -722,7 +907,7 @@ export function graphBuilder( id: `${module.id}-start`, data: { id: module.id, - module: module, + module: moduleMap.get(module.id) ?? module, simplifiedTriggerView, eventHandlers: eventHandlers, editMode: extra.editMode, @@ -759,7 +944,7 @@ export function graphBuilder( const selectedIterIndex = extra.flowModuleStates?.[module.id]?.selectedForloopIndex processModules( - module.value.modules, + item.branches[0]?.children ?? [], { rootId: module.id, branch: 0 }, startNode, endNode, @@ -798,7 +983,7 @@ export function graphBuilder( const selectedIterIndex = extra.flowModuleStates?.[module.id]?.selectedForloopIndex processModules( - module.value.modules, + item.branches[0]?.children ?? [], { rootId: module.id, branch: 0 }, startNode, endNode, @@ -825,21 +1010,6 @@ export function graphBuilder( } nodes.push(endNode) - // // Add default branch - // const defaultBranch: NodeLayout = { - // id: `${module.id}-default`, - // data: { - // offset: 0, - // label: 'Default', - // id: module.id, - // branchIndex: -1, - // eventHandlers: eventHandlers, - // branchOne: true, - // ...extra - // }, - // type: 'noBranch' - // } - const defaultBranch: NodeLayout = { id: `${module.id}-branch-default`, data: { @@ -863,7 +1033,7 @@ export function graphBuilder( }) processModules( - module.value.default, + item.branches[0]?.children ?? [], { rootId: module.id, branch: 0 }, defaultBranch, endNode, @@ -899,7 +1069,7 @@ export function graphBuilder( }) processModules( - branch.modules, + item.branches[branchIndex + 1]?.children ?? [], { rootId: module.id, branch: branchIndex + 1 }, startNode, endNode, @@ -912,9 +1082,9 @@ export function graphBuilder( previousId = endNode.id } else { - let expanded = expandedSubflows[module.id] - if (expanded) { - expanded = $state.snapshot(expanded) + const expandedData = expandedSubflows[module.id] + if (expandedData) { + const expandedMods = $state.snapshot(expandedData.modules) as FlowModule[] const startId = `${module.id}` const idWithoutPrefix = module.id.startsWith('subflow:') ? module.id.substring(8) @@ -936,12 +1106,12 @@ export function graphBuilder( if (previousId) { addEdge(previousId, startNode.id, branch, prefix, { - subModules: modules, + currentItems: items, disableMoveIds }) } else { addEdge(beforeNode.id, startNode.id, undefined, prefix, { - subModules: modules, + currentItems: items, disableMoveIds }) } @@ -962,8 +1132,20 @@ export function graphBuilder( nodes.push(endNode) + // Register expanded subflow modules so prefix rewriting finds + // the inner modules (not the parent flow's modules with same IDs) + for (const em of getAllModules(expandedMods)) { + moduleMap.set(em.id, em) + } + + const expandedGroups = (expandedData.groups ?? []).map((g) => ({ + ...g, + id: groupKey(g), + moduleIds: computeGroupModuleIds(g.start_id, g.end_id, getAllModules(expandedMods)) + })) + processModules( - expanded, + buildStructureTree(expandedMods, expandedGroups), undefined, startNode, endNode, @@ -981,15 +1163,15 @@ export function graphBuilder( if (index === 0 && expandedSubflows[module.id] == undefined) { addEdge(beforeNode.id, module.id, undefined, prefix, { - subModules: modules, + currentItems: items, disableMoveIds, disableInsert: simplifiedTriggerView }) } - if (index === modules.length - 1 && previousId && nextNode) { + if (index === items.length - 1 && previousId && nextNode) { addEdge(previousId, nextNode.id, branch, prefix, { - subModules: modules, + currentItems: items, disableMoveIds }) } @@ -997,10 +1179,12 @@ export function graphBuilder( } } + const topLevelItems = structureTree + if (simplifiableFlow?.simplifiedFlow === true && triggerNode) { - processModules(modules, undefined, triggerNode, undefined, true, undefined) + processModules(topLevelItems, undefined, triggerNode, undefined, true, undefined) } else { - processModules(modules, undefined, inputNode, resultNode, false, undefined) + processModules(topLevelItems, undefined, inputNode, resultNode, false, undefined) } if (failureModule) { diff --git a/frontend/src/lib/components/graph/graphContext.ts b/frontend/src/lib/components/graph/graphContext.ts index 8540982366..a6245769cf 100644 --- a/frontend/src/lib/components/graph/graphContext.ts +++ b/frontend/src/lib/components/graph/graphContext.ts @@ -4,6 +4,7 @@ import type { NoteManager } from './noteManager.svelte' import type { MoveManager } from './moveManager.svelte' import type { Writable } from 'svelte/store' import type { FlowDiffManager } from '../flows/flowDiffManager.svelte' +import type { GroupDisplayState } from './groupEditor.svelte' export type GraphContext = { selectionManager: SelectionManager @@ -14,6 +15,9 @@ export type GraphContext = { clearFlowSelection?: () => void yOffset?: number diffManager: FlowDiffManager + /** Current flow nodes for group validation (set by FlowGraphV2) */ + getFlowNodes?: () => { id: string; parentIds?: string[] }[] + groupDisplayState?: GroupDisplayState } const graphContextKey = 'FlowGraphContext' diff --git a/frontend/src/lib/components/graph/groupDetectionUtils.ts b/frontend/src/lib/components/graph/groupDetectionUtils.ts index e2c81c4d98..8dc6e3d8c2 100644 --- a/frontend/src/lib/components/graph/groupDetectionUtils.ts +++ b/frontend/src/lib/components/graph/groupDetectionUtils.ts @@ -1,7 +1,127 @@ +import { topologicalSort } from './graphBuilder.svelte' + +/** Node IDs synthesized by graphBuilder that are not real FlowModules */ +export const VIRTUAL_NODE_IDS = new Set(['Input', 'Result', 'Trigger']) + type FlowNode = { id: string; parentIds?: string[] } /** - * Use a simple algorithm to complete a group and split it into connected components + * Compute the set of module IDs that belong to a group defined by start_id and end_id. + * Uses the flattened module list (from getAllModules) and slices between start and end. + * Used for collapsed group icons, step count, and moduleToCollapsedGroup mapping. + */ +export function computeGroupModuleIds( + startId: string, + endId: string, + allModules: { id: string }[] +): string[] { + if (startId === endId) { + return allModules.some((m) => m.id === startId) ? [startId] : [] + } + + const startIdx = allModules.findIndex((m) => m.id === startId) + const endIdx = allModules.findIndex((m) => m.id === endId) + + if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) { + if (startIdx > endIdx) { + console.warn( + `computeGroupModuleIds: inverted range for group ${startId}→${endId} (${startIdx} > ${endIdx})` + ) + } + return [] + } + + return allModules.slice(startIdx, endIdx + 1).map((m) => m.id) +} + +/** + * Check whether a set of selected node IDs can form a valid group. + * Normalizes marker IDs (branch/forloop) to parent module IDs, + * then uses topologicalSort to derive start and end boundaries. + */ +export function canFormValidGroup( + selectedIds: string[], + flowNodes: FlowNode[], + excludeIds?: Set +): { valid: true; startId: string; endId: string } | { valid: false } { + if (selectedIds.length === 0) return { valid: false } + + // Normalize marker IDs to parent module IDs. + // -start (forloop head) → parent ID. -end/-branch-* → skip if parent covered, else reject. + const rawSet = new Set(selectedIds) + const normalizedIds: string[] = [] + + for (const id of selectedIds) { + const parentId = id.replace(/-(end|start|branch-.*)$/, '') + if (parentId === id) { + normalizedIds.push(id) + continue + } + if (id.endsWith('-start')) { + normalizedIds.push(parentId) + continue + } + // -end or -branch-*: parent must be covered (directly or via -start) + if (!rawSet.has(parentId) && !rawSet.has(`${parentId}-start`)) { + return { valid: false } + } + } + + if (normalizedIds.length === 0) return { valid: false } + const normalizedSet = new Set(normalizedIds) + + // Topo sort full graph, filter to normalized selection. + // Include raw matches plus all markers (-start, -end, -branch-*) whose parent is selected. + const sorted = topologicalSort(flowNodes) + const selectedSorted = sorted.filter((n) => { + if (normalizedSet.has(n.id)) return true + const parentId = n.id.replace(/-(end|start|branch-.*)$/, '') + return parentId !== n.id && normalizedSet.has(parentId) + }) + + if (selectedSorted.length === 0) return { valid: false } + + // Reject virtual or excluded nodes + if (selectedSorted.some((n) => VIRTUAL_NODE_IDS.has(n.id) || excludeIds?.has(n.id))) { + return { valid: false } + } + + // Topo order is bottom-first: first = bottom (end), last = top (start). + // Use raw IDs for BFS traversal, normalize for the returned group boundaries. + const rawStartId = selectedSorted[selectedSorted.length - 1].id + const rawEndId = selectedSorted[0].id + const startId = rawStartId.replace(/-(end|start|branch-.*)$/, '') + const endId = rawEndId.replace(/-(end|start|branch-.*)$/, '') + + // Verify all selected nodes lie between start and end in the DAG. + // BFS backward from rawEndId to rawStartId to collect reachable nodes. + // Normalize collected IDs so container markers map to their parent module. + const between = new Set() + const queue = [rawEndId] + const visited = new Set() + const parentMap = new Map(flowNodes.map((n) => [n.id, n.parentIds ?? []])) + while (queue.length > 0) { + const cur = queue.shift()! + if (visited.has(cur)) continue + visited.add(cur) + const normalized = cur.replace(/-(end|start|branch-.*)$/, '') + between.add(cur) + between.add(normalized) + if (cur === rawStartId) continue + for (const p of parentMap.get(cur) ?? []) { + queue.push(p) + } + } + if (!normalizedIds.every((id) => between.has(id))) { + return { valid: false } + } + + return { valid: true, startId, endId } +} + +/** + * Legacy utility: complete a group and split it into connected components. + * Still used by NoteEditor for FlowNote group notes (contained_node_ids). */ export function completeAndSplitGroup(groupNodes: string[], flowNodes: FlowNode[]): string[][] { if (groupNodes.length <= 1) { diff --git a/frontend/src/lib/components/graph/groupEditor.svelte.ts b/frontend/src/lib/components/graph/groupEditor.svelte.ts new file mode 100644 index 0000000000..8405390b96 --- /dev/null +++ b/frontend/src/lib/components/graph/groupEditor.svelte.ts @@ -0,0 +1,325 @@ +import type { FlowModule } from '$lib/gen' +import type { StateStore } from '$lib/utils' +import type { ExtendedOpenFlow } from '../flows/types' + +import { canFormValidGroup } from './groupDetectionUtils' +import type { NoteColor } from './noteColors' +import { DEFAULT_GROUP_NOTE_COLOR, getNextAvailableColor } from './noteColors' +import { getContext, setContext } from 'svelte' + +/** + * Type for a flow group (matches the generated type from OpenAPI). + * Members are computed dynamically from all nodes on paths between start_id and end_id. + */ +export type FlowGroup = { + summary?: string + note?: string + autocollapse?: boolean + start_id: string + end_id: string + color?: string +} + +/** Derive a stable key from a group's boundaries. Used as ephemeral ID for graph nodes, runtime state, etc. */ +export function groupKey(g: { start_id: string; end_id: string }): string { + return `${g.start_id}:${g.end_id}` +} + +/** + * Display state for flow groups inside the graph. + * Handles runtime collapse state and note height tracking. + * Similar to NoteManager — instantiated inside FlowGraphV2. + */ +export class GroupDisplayState { + #getGroups: () => FlowGroup[] + #runtimeCollapsedIds = $state>(new Set()) + #runtimeInitialized = $state(false) + #noteHeights = $state>({}) + renderCount = $state(0) + + constructor(getGroups: () => FlowGroup[]) { + this.#getGroups = getGroups + } + + /** Initialize runtime state from autocollapse. Safe to call from event handlers. */ + private ensureRuntimeInitialized(): void { + if (this.#runtimeInitialized) return + const groups = this.#getGroups() + this.#runtimeCollapsedIds = new Set( + groups.filter((g) => g.autocollapse).map((g) => groupKey(g)) + ) + this.#runtimeInitialized = true + } + + /** Check if a group is currently collapsed (runtime). Safe to call from $derived. */ + isRuntimeCollapsed(groupId: string): boolean { + if (!this.#runtimeInitialized) { + return this.#getGroups().find((g) => groupKey(g) === groupId)?.autocollapse ?? false + } + return this.#runtimeCollapsedIds.has(groupId) + } + + /** Toggle runtime collapse (Minimize2 button) */ + toggleRuntimeCollapse(groupId: string): void { + this.ensureRuntimeInitialized() + const next = new Set(this.#runtimeCollapsedIds) + if (next.has(groupId)) next.delete(groupId) + else next.add(groupId) + this.#runtimeCollapsedIds = next + this.render() + } + + /** Expand a group at runtime (CollapsedGroupNode click) */ + expandGroup(groupId: string): void { + this.ensureRuntimeInitialized() + const next = new Set(this.#runtimeCollapsedIds) + next.delete(groupId) + this.#runtimeCollapsedIds = next + this.render() + } + + /** Set note height for a group (used for layout spacing) */ + setNoteHeight(groupId: string, height: number): void { + if (this.#noteHeights[groupId] !== height) { + this.#noteHeights[groupId] = height + this.render() + } + } + + /** Get all note heights */ + getNoteHeights(): Record { + return this.#noteHeights + } + + /** Bump render counter to trigger re-layout */ + render(): void { + this.renderCount++ + } + + /** Remap runtime state when a group's boundaries (and thus its key) change */ + remapGroupKey(oldKey: string, newKey: string): void { + if (this.#runtimeCollapsedIds.has(oldKey)) { + const next = new Set(this.#runtimeCollapsedIds) + next.delete(oldKey) + next.add(newKey) + this.#runtimeCollapsedIds = next + } + if (oldKey in this.#noteHeights) { + this.#noteHeights[newKey] = this.#noteHeights[oldKey] + delete this.#noteHeights[oldKey] + } + } + + /** Get currently collapsed groups for graph builder. Safe to call from $derived. */ + getCollapsedGroups(): FlowGroup[] { + if (!this.#runtimeInitialized) { + return this.#getGroups().filter((g) => g.autocollapse) + } + return this.#getGroups().filter((g) => this.#runtimeCollapsedIds.has(groupKey(g))) + } +} + +/** + * Utility class for editing flow groups via direct flowStore mutations. + * Follows the same pattern as NoteEditor. + */ +export class GroupEditor { + private flowStore: StateStore + + constructor(flowStore: StateStore) { + this.flowStore = flowStore + } + + getGroups(): FlowGroup[] { + return this.flowStore.val.value?.groups || [] + } + + private setGroups(groups: FlowGroup[]): void { + if (this.flowStore.val.value) { + this.flowStore.val.value.groups = groups + } + } + + /** IDs that cannot be part of a group (preprocessor, failure module) */ + getExcludeIds(): Set { + const excludeIds = new Set() + const pp = this.flowStore.val.value?.preprocessor_module?.id + if (pp) excludeIds.add(pp) + const fm = this.flowStore.val.value?.failure_module?.id + if (fm) excludeIds.add(fm) + return excludeIds + } + + /** Check whether the given selection can form a valid group */ + canCreateGroup( + selectedIds: string[], + flowNodes: { id: string; parentIds?: string[] }[] + ): boolean { + const result = canFormValidGroup(selectedIds, flowNodes, this.getExcludeIds()) + if (!result.valid) return false + // Reject if a group with the same boundaries already exists + return !this.getGroups().some((g) => g.start_id === result.startId && g.end_id === result.endId) + } + + /** + * Create a new group from selected node IDs. + * Uses canFormValidGroup to determine start_id and end_id. + * Returns the generated group ID. + */ + createGroup( + moduleIds: string[], + flowNodes: { id: string; parentIds?: string[] }[] + ): string | undefined { + // Filter subflow node IDs (same logic as NoteEditor.createGroupNote) + let filteredIds = [...moduleIds] + const subflowIds: string[] = [] + for (const id of moduleIds) { + if (id.startsWith('subflow:')) { + const match = id.match(/^subflow:([^:]+)/) + if (match) { + subflowIds.push(match[1]) + } + } + } + if (subflowIds.length > 0) { + filteredIds = filteredIds.filter((id) => !subflowIds.includes(id)) + filteredIds = [...filteredIds, ...subflowIds] + } + + const result = canFormValidGroup(filteredIds, flowNodes, this.getExcludeIds()) + if (!result.valid) return undefined + + const groups = this.getGroups() + + // Reject duplicate: a group with the same boundaries already exists + if (groups.some((g) => g.start_id === result.startId && g.end_id === result.endId)) { + return undefined + } + const usedColors = new Set() + for (const group of groups) { + if (group.color) { + usedColors.add(group.color as NoteColor) + } + } + const color = usedColors.size > 0 ? getNextAvailableColor(usedColors) : DEFAULT_GROUP_NOTE_COLOR + + const newGroup: FlowGroup = { + start_id: result.startId, + end_id: result.endId, + color + } + this.setGroups([...groups, newGroup]) + return groupKey(newGroup) + } + + deleteGroup(groupId: string): void { + const groups = this.getGroups() + this.setGroups(groups.filter((g) => groupKey(g) !== groupId)) + } + + updateColor(groupId: string, color: NoteColor): void { + const groups = this.getGroups() + this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, color } : g))) + } + + updateSummary(groupId: string, summary: string): void { + const groups = this.getGroups() + this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, summary } : g))) + } + + updateNote(groupId: string, note: string | undefined): void { + const groups = this.getGroups() + this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, note } : g))) + } + + /** Add a note to a group (sets note to empty string to trigger the placeholder UI) */ + addNote(groupId: string): void { + this.updateNote(groupId, '') + } + + /** Remove a note from a group */ + removeNote(groupId: string): void { + this.updateNote(groupId, undefined) + } + + updateAutocollapse(groupId: string, autocollapse: boolean): void { + const groups = this.getGroups() + this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, autocollapse } : g))) + } +} + +export type GroupEditorContext = { + groupEditor: GroupEditor + canCreateGroup: StateStore +} + +const CONTEXT_KEY = 'GroupEditorContext' + +export function setGroupEditorContext( + groupEditor: GroupEditor, + canCreateGroup: StateStore +): void { + setContext(CONTEXT_KEY, { groupEditor, canCreateGroup }) +} + +export function getGroupEditorContext(): GroupEditorContext | undefined { + return getContext(CONTEXT_KEY) +} + +/** Height of the group header bar */ +export const GROUP_HEADER_HEIGHT = 22 + +/** Extra margin between the header and the first node */ +export const GROUP_TOP_MARGIN = 30 + +export type GraphGroup = FlowGroup & { + id: string + moduleIds: string[] +} + +export type ContainerInnerArray = { + get: () => FlowModule[] + set: (v: any) => void + label?: string +} + +/** Get inner arrays from a container FlowModule with direct get/set accessors. */ +export function getContainerInnerArrays(mod: FlowModule): ContainerInnerArray[] { + const val = mod.value as any + if (val.type === 'forloopflow' || val.type === 'whileloopflow') { + return [ + { + get: () => val.modules, + set: (v) => { + val.modules = v + } + } + ] + } else if (val.type === 'branchone') { + return [ + { + get: () => val.default, + set: (v) => { + val.default = v + }, + label: 'Default' + }, + ...val.branches.map((b: any, i: number) => ({ + get: () => b.modules, + set: (v: any) => { + b.modules = v + }, + label: b.summary || `Branch ${i + 1}` + })) + ] + } else if (val.type === 'branchall') { + return val.branches.map((b: any, i: number) => ({ + get: () => b.modules, + set: (v: any) => { + b.modules = v + }, + label: b.summary || `Branch ${i + 1}` + })) + } + return [] +} diff --git a/frontend/src/lib/components/graph/groupedModulesProxy.svelte.ts b/frontend/src/lib/components/graph/groupedModulesProxy.svelte.ts new file mode 100644 index 0000000000..6965baf531 --- /dev/null +++ b/frontend/src/lib/components/graph/groupedModulesProxy.svelte.ts @@ -0,0 +1,181 @@ +import { untrack } from 'svelte' +import type { FlowModule } from '$lib/gen' +import { type FlowGroup, type GraphGroup, groupKey } from './groupEditor.svelte' +import type { StateStore } from '$lib/utils' +import { getAllModules } from '../flows/flowExplorer' +import { computeGroupModuleIds } from './groupDetectionUtils' +import { stateSnapshot } from '$lib/svelte5Utils.svelte' +import { + buildStructureTree, + deriveGroupsFromStructure, + applyStructureToModules, + removeEmptyGroups, + findDuplicateGroups, + removeDuplicateGroups, + flattenStructureIds, + type FlowStructureNode +} from './flowStructure' + +export type ExtendedOpenFlow = { + value: { + modules: FlowModule[] + groups?: FlowGroup[] + [key: string]: any + } + [key: string]: any +} + +/** + * Reactive read-only view of the flow structure tree. + * The tree is always derived from flowStore (single source of truth). + * Mutations go through prepareMutation: snapshot → mutate → clean empty groups → commit. + */ +export class GroupedModulesProxy { + #items = $state([]) + #error = $state(undefined) + #flowStore: StateStore + + constructor(flowStore: StateStore) { + this.#flowStore = flowStore + this.rebuild() + + // Rebuild tree whenever store changes (undo/load/mutation) + $effect(() => { + void flowStore.val.value.modules + void flowStore.val.value.groups + untrack(() => this.rebuild()) + }) + } + + /** Reactive access to the structure tree (read-only view) */ + get items(): FlowStructureNode[] { + return this.#items + } + + /** Reactive access to build errors */ + get error(): unknown { + return this.#error + } + + /** + * Prepare a structural mutation without writing to the store yet. + * Returns the list of groups that became empty (already removed from the snapshot) + * and a `commit` function that writes the result to the store. + * + * If no groups were emptied, the caller can commit immediately. + * If groups were emptied, the caller should show a confirmation modal + * and call commit() only on user confirmation. + */ + prepareMutation( + mutate: (tree: FlowStructureNode[]) => void, + opts?: { + extraModules?: FlowModule[] + displayState?: import('./groupEditor.svelte').GroupDisplayState + } + ): { + emptiedGroups: FlowGroup[] + duplicateGroups: FlowGroup[] + commit: (commitOpts?: { removeDuplicates?: boolean }) => void + } { + const snapshot = $state.snapshot(this.#items) as FlowStructureNode[] + mutate(snapshot) + + // Clean up empty groups and collect which ones were removed + const emptiedGroups = removeEmptyGroups(snapshot) + // Detect groups that became duplicates after the mutation + const duplicateGroups = findDuplicateGroups(snapshot) + + const commit = (commitOpts?: { removeDuplicates?: boolean }) => { + if (commitOpts?.removeDuplicates && duplicateGroups.length > 0) { + removeDuplicateGroups(snapshot) + } + + // Remap runtime state for groups whose boundaries shifted + if (opts?.displayState) { + this.#remapChangedGroupKeys(snapshot, opts.displayState) + } + + // Build moduleMap lazily at commit time so it reflects the latest store state + const moduleMap = new Map() + for (const m of getAllModules(this.#flowStore.val.value.modules)) { + moduleMap.set(m.id, m) + } + if (opts?.extraModules) { + for (const m of opts.extraModules) { + moduleMap.set(m.id, m) + } + } + this.#flowStore.val.value.modules = applyStructureToModules(snapshot, moduleMap) + this.#flowStore.val.value.groups = deriveGroupsFromStructure(snapshot) + } + + return { emptiedGroups, duplicateGroups, commit } + } + + /** + * Convenience: prepare + auto-commit. Only use for mutations that cannot + * empty groups (e.g. inserts). Throws if groups are unexpectedly emptied. + * For mutations that may empty groups, use prepareMutation() directly. + */ + applyTreeMutation( + mutate: (tree: FlowStructureNode[]) => void, + opts?: { + extraModules?: FlowModule[] + displayState?: import('./groupEditor.svelte').GroupDisplayState + } + ): void { + const { emptiedGroups, duplicateGroups, commit } = this.prepareMutation(mutate, opts) + if (emptiedGroups.length > 0) { + console.error('applyTreeMutation: unexpected empty groups', emptiedGroups) + } + if (duplicateGroups.length > 0) { + console.error('applyTreeMutation: unexpected duplicate groups', duplicateGroups) + } + commit() + } + + /** Remap runtime state for group nodes whose boundaries shifted after a mutation. */ + #remapChangedGroupKeys( + snapshot: FlowStructureNode[], + displayState: import('./groupEditor.svelte').GroupDisplayState + ): void { + const walk = (nodes: FlowStructureNode[]) => { + for (const node of nodes) { + if (node.kind === 'group') { + const oldKey = node.id + const flatIds = flattenStructureIds(node.branches[0].children) + const newKey = flatIds.length > 0 ? `${flatIds[0]}:${flatIds[flatIds.length - 1]}` : null + if (newKey && oldKey !== newKey) { + displayState.remapGroupKey(oldKey, newKey) + } + walk(node.branches[0].children) + } else { + for (const branch of node.branches) { + walk(branch.children) + } + } + } + } + walk(snapshot) + } + + /** Rebuild from flowStore */ + private rebuild(): void { + const modules = stateSnapshot(this.#flowStore.val.value.modules) as FlowModule[] + const allGroups = this.#flowStore.val.value.groups ?? [] + const allModules = getAllModules(modules) + const graphGroups: GraphGroup[] = allGroups.map((g) => ({ + ...g, + id: groupKey(g), + moduleIds: computeGroupModuleIds(g.start_id, g.end_id, allModules) + })) + try { + this.#items = buildStructureTree(modules, graphGroups) + this.#error = undefined + } catch (e) { + // Intentionally preserve last-known-good #items so the graph + // can still render while the error is surfaced to the user. + this.#error = e + } + } +} diff --git a/frontend/src/lib/components/graph/moveManager.svelte.ts b/frontend/src/lib/components/graph/moveManager.svelte.ts index 605c4b849a..1539cc0802 100644 --- a/frontend/src/lib/components/graph/moveManager.svelte.ts +++ b/frontend/src/lib/components/graph/moveManager.svelte.ts @@ -204,9 +204,6 @@ export class MoveManager { for (const [edgeId, zone] of this.#registeredDropZones) { if (zone.disableMoveIds.includes(draggedId)) continue - // Skip edges adjacent to the dragged node (no-op move) - if (zone.sourceId === draggedId || zone.targetId === draggedId) continue - const dx = Math.abs(flowPos.x - zone.centerX) const dy = Math.abs(flowPos.y - zone.centerY) diff --git a/frontend/src/lib/components/graph/nodeExtraSpace.ts b/frontend/src/lib/components/graph/nodeExtraSpace.ts new file mode 100644 index 0000000000..f51309a60b --- /dev/null +++ b/frontend/src/lib/components/graph/nodeExtraSpace.ts @@ -0,0 +1,153 @@ +import type { FlowNote } from '../../gen' +import type { AssetWithAltAccessType } from '../assets/lib' +import { + assetDisplaysAsInputInFlowGraph, + assetDisplaysAsOutputInFlowGraph, + NODE_WITH_READ_ASSET_Y_OFFSET, + NODE_WITH_WRITE_ASSET_Y_OFFSET +} from './renderers/nodes/AssetNode.svelte' +import { + AI_TOOL_BASE_OFFSET, + AI_TOOL_ROW_OFFSET, + BELOW_ADDITIONAL_OFFSET +} from './renderers/nodes/AIToolNode.svelte' +import { topologicalSort } from './graphBuilder.svelte' +import { GROUP_HEADER_HEIGHT } from './groupEditor.svelte' +import type { GroupDisplayState } from './groupEditor.svelte' +import type { GraphModuleState } from '.' + +type NodeDep = { + id: string + parentIds?: string[] + data?: { assets?: AssetWithAltAccessType[]; module?: any } +} + +type ExtraSpace = { top: number; bottom: number; left: number; right: number } + +const MAX_TOOLS_PER_ROW = 2 + +/** + * Pre-compute extra top/bottom space each node needs for decorations + * (assets, AI tools, group headers, group notes). + */ +export function computeNodeExtraSpace( + graphNodes: NodeDep[], + opts: { + showAssets: boolean + showNotes: boolean + notes: FlowNote[] | undefined + noteTextHeights: Record + groupDisplayState: GroupDisplayState + insertable: boolean + flowModuleStates: Record | undefined + } +): Map | undefined { + const extraSpace = new Map() + + // 1. Assets + if (opts.showAssets) { + for (const node of graphNodes) { + const assets = node.data?.assets ?? [] + if (!assets.length) continue + const hasRead = assets.some(assetDisplaysAsInputInFlowGraph) + const hasWrite = assets.some(assetDisplaysAsOutputInFlowGraph) + if (hasRead || hasWrite) { + const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 } + extraSpace.set(node.id, { + ...prev, + top: prev.top + (hasRead ? NODE_WITH_READ_ASSET_Y_OFFSET : 0), + bottom: prev.bottom + (hasWrite ? NODE_WITH_WRITE_ASSET_Y_OFFSET : 0) + }) + } + } + } + + // 2. AI tools + for (const node of graphNodes) { + const mod = node.data?.module + if (!mod || mod.value?.type !== 'aiagent') continue + + const agentActions = !opts.insertable && opts.flowModuleStates?.[node.id]?.agent_actions + + if (agentActions) { + // Execution mode: tools below + const totalRows = Math.ceil(agentActions.length / MAX_TOOLS_PER_ROW) + const space = AI_TOOL_BASE_OFFSET + AI_TOOL_ROW_OFFSET * totalRows + BELOW_ADDITIONAL_OFFSET + const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 } + extraSpace.set(node.id, { ...prev, bottom: prev.bottom + space }) + } else { + // Edit mode: tools above + const tools = mod.value.tools ?? [] + const totalRows = Math.ceil(tools.length / MAX_TOOLS_PER_ROW) + (opts.insertable ? 1 : 0) + const space = AI_TOOL_BASE_OFFSET + AI_TOOL_ROW_OFFSET * totalRows + const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 } + extraSpace.set(node.id, { ...prev, top: prev.top + space }) + } + } + + // Topological sort (reversed: top-of-graph first) — shared by group notes and group headers + const sortedNodes = topologicalSort(graphNodes).reverse() + + // 3. Group notes (text above topmost node in each group note) + if (opts.showNotes) { + const groupNotes = (opts.notes ?? []).filter((n) => n.type === 'group') + if (groupNotes.length > 0) { + for (const groupNote of groupNotes) { + if (!groupNote.contained_node_ids?.length) continue + const topmostNodeId = sortedNodes.find((node) => + groupNote.contained_node_ids?.includes(node.id) + )?.id + if (topmostNodeId) { + const textHeight = opts.noteTextHeights[groupNote.id] || 60 + const spacing = textHeight + 16 // padding + const prev = extraSpace.get(topmostNodeId) ?? { + top: 0, + bottom: 0, + left: 0, + right: 0 + } + extraSpace.set(topmostNodeId, { + ...prev, + top: Math.max(prev.top, spacing + prev.top) + }) + } + } + } + } + + // 4. Collapsed group nodes are taller than regular nodes (header + module icons) + for (const node of graphNodes) { + if (node.id.startsWith('collapsed-group:')) { + const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 } + extraSpace.set(node.id, { + ...prev, + bottom: prev.bottom + GROUP_HEADER_HEIGHT + }) + } + } + + // 5. Group nodes (expanded heads and collapsed) with notes need extra height + if (opts.showNotes) { + const noteHeights = opts.groupDisplayState.getNoteHeights() + for (const node of graphNodes) { + let groupId: string | undefined + if (node.id.startsWith('group:') && !node.id.endsWith('-end')) { + groupId = node.id.slice('group:'.length) + } else if (node.id.startsWith('collapsed-group:')) { + groupId = node.id.slice('collapsed-group:'.length) + } + if (groupId) { + const noteHeight = noteHeights[groupId] + if (noteHeight && noteHeight > 0) { + const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 } + extraSpace.set(node.id, { + ...prev, + bottom: prev.bottom + noteHeight + }) + } + } + } + } + + return extraSpace.size > 0 ? extraSpace : undefined +} diff --git a/frontend/src/lib/components/graph/noteColors.ts b/frontend/src/lib/components/graph/noteColors.ts index f9024adf7e..2a82ed1f40 100644 --- a/frontend/src/lib/components/graph/noteColors.ts +++ b/frontend/src/lib/components/graph/noteColors.ts @@ -14,6 +14,7 @@ export enum NoteColor { export interface NoteColorConfig { background: string + backgroundLight: string outline: string outlineHover: string text: string @@ -24,70 +25,80 @@ export interface NoteColorConfig { export const NOTE_COLORS: Record = { [NoteColor.YELLOW]: { background: 'bg-yellow-200 dark:bg-yellow-900', - outline: 'outline-yellow-300 dark:outline-yellow-600', + backgroundLight: 'bg-yellow-400/5 dark:bg-yellow-600/5', + outline: 'outline-yellow-200 dark:outline-yellow-900', outlineHover: 'outline-yellow-300/60 dark:outline-yellow-600/60', text: 'text-yellow-900 dark:text-yellow-100', hover: 'hover:bg-yellow-200 dark:hover:bg-yellow-800' }, [NoteColor.BLUE]: { background: 'bg-blue-100 dark:bg-blue-950', - outline: 'outline-blue-300 dark:outline-blue-600', + backgroundLight: 'bg-blue-400/5 dark:bg-blue-600/5', + outline: 'outline-blue-100 dark:outline-blue-950', outlineHover: 'outline-blue-300/60 dark:outline-blue-600/60', text: 'text-blue-900 dark:text-blue-100', hover: 'hover:bg-blue-200 dark:hover:bg-blue-800' }, [NoteColor.GREEN]: { background: 'bg-green-200 dark:bg-green-900', - outline: 'outline-green-300 dark:outline-green-600', + backgroundLight: 'bg-green-400/5 dark:bg-green-600/5', + outline: 'outline-green-200 dark:outline-green-900', outlineHover: 'outline-green-300/60 dark:outline-green-600/60', text: 'text-green-900 dark:text-green-100', hover: 'hover:bg-green-200 dark:hover:bg-green-800' }, [NoteColor.PURPLE]: { background: 'bg-purple-200 dark:bg-purple-900', - outline: 'outline-purple-300 dark:outline-purple-600', + backgroundLight: 'bg-purple-400/5 dark:bg-purple-600/5', + outline: 'outline-purple-200 dark:outline-purple-900', outlineHover: 'outline-purple-300/60 dark:outline-purple-600/60', text: 'text-purple-900 dark:text-purple-100', hover: 'hover:bg-purple-200 dark:hover:bg-purple-800' }, [NoteColor.PINK]: { background: 'bg-pink-200 dark:bg-pink-900', - outline: 'outline-pink-300 dark:outline-pink-600', + backgroundLight: 'bg-pink-400/5 dark:bg-pink-600/5', + outline: 'outline-pink-200 dark:outline-pink-900', outlineHover: 'outline-pink-300/60 dark:outline-pink-600/60', text: 'text-pink-900 dark:text-pink-100', hover: 'hover:bg-pink-200 dark:hover:bg-pink-800' }, [NoteColor.ORANGE]: { background: 'bg-orange-200 dark:bg-orange-900', - outline: 'outline-orange-300 dark:outline-orange-600', + backgroundLight: 'bg-orange-400/5 dark:bg-orange-600/5', + outline: 'outline-orange-200 dark:outline-orange-900', outlineHover: 'outline-orange-300/60 dark:outline-orange-600/60', text: 'text-orange-900 dark:text-orange-100', hover: 'hover:bg-orange-200 dark:hover:bg-orange-800' }, [NoteColor.RED]: { background: 'bg-red-200 dark:bg-red-900', - outline: 'outline-red-300 dark:outline-red-600', + backgroundLight: 'bg-red-400/5 dark:bg-red-600/5', + outline: 'outline-red-200 dark:outline-red-900', outlineHover: 'outline-red-300/60 dark:outline-red-600/60', text: 'text-red-900 dark:text-red-100', hover: 'hover:bg-red-200 dark:hover:bg-red-800' }, [NoteColor.CYAN]: { background: 'bg-cyan-200 dark:bg-cyan-900', - outline: 'outline-cyan-300 dark:outline-cyan-600', + backgroundLight: 'bg-cyan-400/5 dark:bg-cyan-600/5', + outline: 'outline-cyan-200 dark:outline-cyan-900', outlineHover: 'outline-cyan-300/60 dark:outline-cyan-600/60', text: 'text-cyan-900 dark:text-cyan-100', hover: 'hover:bg-cyan-200 dark:hover:bg-cyan-800' }, [NoteColor.LIME]: { background: 'bg-lime-200 dark:bg-lime-900', - outline: 'outline-lime-300 dark:outline-lime-600', + backgroundLight: 'bg-lime-400/5 dark:bg-lime-600/5', + outline: 'outline-lime-200 dark:outline-lime-900', outlineHover: 'outline-lime-300/60 dark:outline-lime-600/60', text: 'text-lime-900 dark:text-lime-100', hover: 'hover:bg-lime-200 dark:hover:bg-lime-800' }, [NoteColor.GRAY]: { background: 'bg-gray-200 dark:bg-gray-800', - outline: 'outline-gray-300 dark:outline-gray-600', + backgroundLight: 'bg-gray-400/5 dark:bg-gray-600/5', + outline: 'outline-gray-200 dark:outline-gray-800', outlineHover: 'outline-gray-300/60 dark:outline-gray-600/60', text: 'text-gray-900 dark:text-gray-100', hover: 'hover:bg-gray-200 dark:hover:bg-gray-700' diff --git a/frontend/src/lib/components/graph/noteUtils.svelte.ts b/frontend/src/lib/components/graph/noteUtils.svelte.ts index 3350e07ce8..bd7fc2ce05 100644 --- a/frontend/src/lib/components/graph/noteUtils.svelte.ts +++ b/frontend/src/lib/components/graph/noteUtils.svelte.ts @@ -19,20 +19,6 @@ export type NodeDep = { export type NoteComputeResult = { noteNodes: (Node & NodeLayout)[] - newNodePositions: Record -} - -export type AIToolSpacingInfo = { - toolNodes: (Node & NodeLayout)[] - toolEdges: any[] - newNodePositions: Record -} - -export interface GroupNoteBounds { - x: number - y: number - width: number - height: number } let computeNoteNodesCache: @@ -283,14 +269,9 @@ export function computeNoteNodes( const allNoteNodes: (Node & NodeLayout)[] = [] - // Build a map of Y positions that need extra spacing for group notes - const yPosMap: Record = {} // Y position -> spacing needed - - // Group notes that need spacing + // Find topmost node per group note for layout calculation const groupNotes = notes.filter((n) => n.type === 'group') - const topMostNodesMap: Record = {} - const sortedNodes = topologicalSort(nodes).reverse() for (const groupNote of groupNotes) { @@ -298,47 +279,12 @@ export function computeNoteNodes( const topmostNodeId = sortedNodes.find((node) => groupNote.contained_node_ids?.includes(node.id) )?.id - const topmostNode = nodes.find((node) => node.id === topmostNodeId) - if (topmostNode) { - const textHeight = noteTextHeights[groupNote.id] || 60 - const spacing = textHeight + 16 // padding - // Mark this Y position as needing spacing - yPosMap[topmostNode.position.y] = Math.max(yPosMap[topmostNode.position.y] || 0, spacing) - topMostNodesMap[groupNote.id] = topmostNode.id + if (topmostNodeId) { + topMostNodesMap[groupNote.id] = topmostNodeId } } } - // Calculate new positions for nodes (offset by group notes) - const sortedNewNodes = nodes - .map((n) => ({ position: { ...n.position }, id: n.id })) - .sort((a, b) => a.position.y - b.position.y) - - let currentYOffset = 0 - let prevYPos = NaN - - for (const node of sortedNewNodes) { - if (node.position.y !== prevYPos) { - // Add spacing for group notes at this Y level - if (yPosMap[node.position.y]) { - currentYOffset += yPosMap[node.position.y] - } - prevYPos = node.position.y - } - node.position.y += currentYOffset - } - - // Create note nodes AFTER calculating adjusted node positions - // For group notes, we need to use the adjusted node positions - const adjustedNodes = sortedNewNodes.map((n) => { - const origNode = nodes.find((orig) => orig.id === n.id) - return { - ...n, - data: origNode?.data, - type: origNode?.type - } - }) - // Calculate all z-indexes at once using hierarchy information const noteZIndexes = calculateAllNoteZIndexes(notes, nodes) @@ -346,11 +292,11 @@ export function computeNoteNodes( const isGroupNote = note.type === 'group' const zIndex = noteZIndexes[note.id] - // Calculate position and size using adjusted node positions for group notes + // Calculate position and size using node positions for group notes const { position, size } = isGroupNote ? calculateGroupNoteLayout( note, - adjustedNodes, + nodes, noteTextHeights[note.id] || 60, topMostNodesMap[note.id] ) @@ -375,13 +321,8 @@ export function computeNoteNodes( allNoteNodes.push(noteNode) } - const newNodePositions: Record = Object.fromEntries( - sortedNewNodes.map((n) => [n.id, n.position]) - ) - const result: NoteComputeResult = { - noteNodes: allNoteNodes, - newNodePositions + noteNodes: allNoteNodes } // Cache the result diff --git a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte index 8f1ca0017f..5fbf084374 100644 --- a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte +++ b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte @@ -12,11 +12,14 @@ import type { GraphModuleState } from '../../model' import InsertModuleButton from '$lib/components/flows/map/InsertModuleButton.svelte' import { getGraphContext } from '../../graphContext' + import { GROUP_TOP_PADDING } from '$lib/components/graph/compoundLayout' const { useDataflow, showAssets, moveManager } = getGraphContext() let { id, + source, + target, sourceX, sourceY, sourcePosition, @@ -45,6 +48,13 @@ } } = $props() + // Derive group boundary from source/target node IDs + let groupBoundary: 'top' | 'bottom' | undefined = $derived.by(() => { + if (source.startsWith('group:') && !source.endsWith('-end')) return 'top' + if (target.startsWith('group:') && target.endsWith('-end')) return 'bottom' + return undefined + }) + let [edgePath] = $derived( getBezierPath({ sourceX, @@ -75,9 +85,15 @@ ) let centerY = $derived( - sourceY + - 32 + - (data.shouldOffsetInsertBtnDueToAssetNode && $showAssets ? NODE_WITH_WRITE_ASSET_Y_OFFSET : 0) + groupBoundary === 'bottom' + ? targetY + : groupBoundary === 'top' + ? sourceY + GROUP_TOP_PADDING / 2 + : sourceY + + 32 + + (data.shouldOffsetInsertBtnDueToAssetNode && $showAssets + ? NODE_WITH_WRITE_ASSET_Y_OFFSET + : 0) ) let isDragging = $derived(!!moveManager?.dragging) @@ -87,13 +103,13 @@ data?.insertable && draggedId !== undefined && !data.disableMoveIds?.includes(draggedId) && - data.sourceId !== draggedId && - data.targetId !== draggedId + source !== draggedId && + target !== draggedId ) - let isNearestDrop = $derived(isValidDropTarget && moveManager?.nearestDropZone?.edgeId === id) - let isAdjacentToDragged = $derived( - isDragging && (data?.sourceId === draggedId || data?.targetId === draggedId) + let isNearestDrop = $derived( + isValidDropTarget && moveManager?.nearestDropZone?.edgeId === id ? true : false ) + let isAdjacentToDragged = $derived(isDragging && (source === draggedId || target === draggedId)) // Register this edge's drop zone position with the drag manager so proximity // detection uses the actual xyflow-computed position rather than re-deriving it. @@ -161,7 +177,7 @@ {@render dropTargetIndicator(isNearestDrop)} - {:else if data?.insertable && !$useDataflow && !moveManager?.movingModuleId && !isDragging} + {:else if data?.insertable && !groupBoundary && !$useDataflow && !moveManager?.movingModuleId && !isDragging}
    - {#if !(moveManager.movingIds ?? [moveManager.movingModuleId]).some((id) => data.disableMoveIds?.includes(id))} + {#if !(moveManager.movingIds ?? [moveManager.movingModuleId]).some( (id) => data.disableMoveIds?.includes(id) )} - {/if} -
    + + {/if} {/snippet} diff --git a/frontend/src/lib/components/graph/renderers/triggers/TriggersWrapper.svelte b/frontend/src/lib/components/graph/renderers/triggers/TriggersWrapper.svelte index 2ffa6a8d49..ccc5643981 100644 --- a/frontend/src/lib/components/graph/renderers/triggers/TriggersWrapper.svelte +++ b/frontend/src/lib/components/graph/renderers/triggers/TriggersWrapper.svelte @@ -53,7 +53,7 @@
    +
    + {#if hubSyncStatus === 'success'} +
    + + {hubSyncMessage} + +
    + {:else if hubSyncStatus === 'error'} +
    + + {hubSyncMessage} + +
    + {/if} + {/if} + + +{/if} diff --git a/frontend/src/lib/components/settings/AIPromptsModal.svelte b/frontend/src/lib/components/settings/AIPromptsModal.svelte index a0ea762c8d..b9c126d19c 100644 --- a/frontend/src/lib/components/settings/AIPromptsModal.svelte +++ b/frontend/src/lib/components/settings/AIPromptsModal.svelte @@ -13,7 +13,7 @@ onSave?: () => void onReset: () => void hasChanges: boolean - isWorkspaceSettings?: boolean + scope?: 'user' | 'workspace' | 'instance' } let { @@ -22,7 +22,7 @@ onSave, onReset, hasChanges, - isWorkspaceSettings = false + scope = 'user' }: Props = $props() const placeholders: Record = { @@ -63,9 +63,12 @@
    - {#if isWorkspaceSettings} + {#if scope === 'workspace'} Customize the system prompts for each AI mode. These prompts apply to all workspace members. + {:else if scope === 'instance'} + Customize the system prompts for each AI mode. These prompts apply to workspaces using + instance AI defaults. {:else} Customize the system prompts for each AI mode. These prompts are stored locally in your browser and apply in addition to workspace-level prompts. diff --git a/frontend/src/lib/components/workspaceSettings/AISettings.svelte b/frontend/src/lib/components/workspaceSettings/AISettings.svelte index 2ed3432a3c..dd3cc6e0d0 100644 --- a/frontend/src/lib/components/workspaceSettings/AISettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/AISettings.svelte @@ -1,5 +1,12 @@ - +
    - -
    - {#each Object.entries(AI_PROVIDERS) as [provider, details]} -
    -
    - { - if (e.detail) { - aiProviders = { - ...aiProviders, - [provider]: { - resource_path: '', - models: - availableAiModels[provider].length > 0 - ? [availableAiModels[provider][0]] - : [] - } - } - - if (availableAiModels[provider].length > 0 && !defaultModel) { - defaultModel = availableAiModels[provider][0] - } - } else { - aiProviders = Object.fromEntries( - Object.entries(aiProviders).filter(([key]) => key !== provider) - ) - if (defaultModel) { - const currentDefaultModel = Object.values(aiProviders).find( - (p) => defaultModel && p.models.includes(defaultModel) - ) - if (!currentDefaultModel) { - defaultModel = undefined - } - } - if (codeCompletionModel) { - const currentCodeCompletionModel = Object.values(aiProviders).find( - (p) => codeCompletionModel && p.models.includes(codeCompletionModel) - ) - if (!currentCodeCompletionModel) { - codeCompletionModel = undefined - } - } - } - }} - /> - {#if provider === 'anthropic'} - - Recommended - - Anthropic models handle tool calls better than other providers, which makes them a - better choice for AI chat. - - - {/if} -
    - - {#if aiProviders[provider]} -
    -
    - {/if} -
    + + {#key Object.keys(aiProviders).length} + + +
    {/if}
    - + + + + +
    + + {#if promptCount > 0} + ({promptCount} configured) + {/if} + {#if hasPromptsChanges} + Unsaved changes + {/if} +
    +
    + {/if}
    - onDiscard?.()} - saveLabel="Save AI settings" - disabled={!Object.values(aiProviders).every((p) => p.resource_path) || - (codeCompletionModel != undefined && codeCompletionModel.length === 0) || - (Object.keys(aiProviders).length > 0 && !defaultModel)} -/> +{#if showWorkspaceOverrideEditor} + +{/if} diff --git a/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte b/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte index 7f4b4e0f67..e02facb38e 100644 --- a/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte +++ b/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte @@ -11,7 +11,8 @@ VariableService, WorkspaceService, type AIProvider, - type CompletedJob + type CompletedJob, + type GetCopilotInfoResponse } from '$lib/gen' import { validateUsername } from '$lib/utils' import { logoutWithRedirect } from '$lib/logoutKit' @@ -52,6 +53,10 @@ let aiKey = $state('') let codeCompletionEnabled = $state(true) let checking = $state(false) + let createLoading = $state(false) + let aiSetupLoading = $state(false) + let creationStep = $state<'details' | 'ai'>('details') + let createdWorkspaceId: string | undefined = $state(undefined) let workspaceColor: string | undefined = $state(undefined) let colorEnabled = $state(false) @@ -85,6 +90,64 @@ let errorMsgs: string[] = $state([]) let failedSyncJobs: string[] = $state([]) + function getErrorMessage(error: any): string { + return ( + error?.body?.error?.message || + error?.body?.message || + (typeof error?.body === 'string' ? error.body : null) || + error?.message || + 'Unknown error' + ) + } + + function hasEffectiveAi(copilotInfo: GetCopilotInfoResponse): boolean { + return Object.keys(copilotInfo.providers ?? {}).length > 0 + } + + async function finishWorkspaceSetup(workspaceId: string): Promise { + usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) + switchWorkspace(workspaceId) + goto(rd ?? '/') + } + + async function getWorkspaceUsername(workspaceId: string): Promise { + if (!automateUsernameCreation) { + return username + } + + const user = await UserService.whoami({ + workspace: workspaceId + }) + return user.username + } + + async function maybeShowAiSetupStep(workspaceId: string): Promise { + try { + const copilotInfo = await WorkspaceService.getCopilotInfo({ + workspace: workspaceId + }) + + if (hasEffectiveAi(copilotInfo)) { + await finishWorkspaceSetup(workspaceId) + return + } + } catch (error) { + console.error('Failed to check effective AI configuration for new workspace', error) + sendUserToast( + 'Workspace created, but Windmill AI availability could not be verified. You can configure it later in Workspace settings.', + true + ) + await finishWorkspaceSetup(workspaceId) + return + } + + createdWorkspaceId = workspaceId + creationStep = 'ai' + aiKey = '' + codeCompletionEnabled = true + selected = 'openai' + } + async function fetchFailedSyncJobs(jobs: string[]): Promise { let ret: CompletedJob[] = [] for (const job of jobs) { @@ -188,20 +251,22 @@ forkCreationLoading = false sendUserToast(`Successfully forked workspace ${$workspaceStore} as: wm-fork-${id}`) + await finishWorkspaceSetup(prefixed_id) } else { sendUserToast('No workspace selected, cannot fork non-existent workspace', true) } } else { - await createWorkspace() + createLoading = true + try { + const workspaceId = await createWorkspace() + await maybeShowAiSetupStep(workspaceId) + } finally { + createLoading = false + } } - - usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) - switchWorkspace(isFork ? prefixed_id : id) - - goto(rd ?? '/') } - async function createWorkspace(): Promise { + async function createWorkspace(): Promise { await WorkspaceService.createWorkspace({ requestBody: { id, @@ -216,17 +281,23 @@ requestBody: { operator: operatorOnly, invite_all: !isCloudHosted(), auto_add: autoAdd } }) } - if (aiKey != '') { - let actualUsername = username - if (automateUsernameCreation) { - const user = await UserService.whoami({ - workspace: id - }) - actualUsername = user.username - } - let path = `u/${actualUsername}/${selected}_windmill_codegen` + + sendUserToast(`Created workspace id: ${id}`) + return id + } + + async function saveWorkspaceAiSetup(): Promise { + if (!createdWorkspaceId || !aiKey) { + return + } + + aiSetupLoading = true + try { + const actualUsername = await getWorkspaceUsername(createdWorkspaceId) + const path = `u/${actualUsername}/${selected}_windmill_codegen` + await VariableService.createVariable({ - workspace: id, + workspace: createdWorkspaceId, requestBody: { path, value: aiKey, @@ -235,7 +306,7 @@ } }) await ResourceService.createResource({ - workspace: id, + workspace: createdWorkspaceId, requestBody: { path, value: { @@ -245,40 +316,46 @@ } }) await WorkspaceService.editCopilotConfig({ - workspace: id, - requestBody: aiKey - ? { - providers: { - [selected]: { - resource_path: path, - models: [AI_PROVIDERS[selected].defaultModels[0]] - } - }, - default_model: { - model: AI_PROVIDERS[selected].defaultModels[0], - provider: selected - }, - code_completion_model: codeCompletionEnabled - ? { model: AI_PROVIDERS[selected].defaultModels[0], provider: selected } - : undefined + workspace: createdWorkspaceId, + requestBody: { + providers: { + [selected]: { + resource_path: path, + models: [AI_PROVIDERS[selected].defaultModels[0]] } - : {} + }, + default_model: { + model: AI_PROVIDERS[selected].defaultModels[0], + provider: selected + }, + code_completion_model: codeCompletionEnabled + ? { model: AI_PROVIDERS[selected].defaultModels[0], provider: selected } + : undefined + } }) + + sendUserToast('Windmill AI configured') + await finishWorkspaceSetup(createdWorkspaceId) + } catch (error) { + sendUserToast(`Failed to configure Windmill AI: ${getErrorMessage(error)}`, true) + } finally { + aiSetupLoading = false } - - sendUserToast(`Created workspace id: ${id}`) - - usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) - switchWorkspace(id) - - goto(rd ?? '/') } - function handleKeyUp(event: KeyboardEvent) { + function handleCreateKeyUp(event: KeyboardEvent) { const key = event.key if (key === 'Enter') { event.preventDefault() - createWorkspace() + createOrForkWorkspace() + } + } + + function handleAiKeyUp(event: KeyboardEvent) { + const key = event.key + if (key === 'Enter' && aiKey) { + event.preventDefault() + saveWorkspaceAiSetup() } } @@ -329,6 +406,9 @@ let operatorOnly = $state(false) let autoAdd = $state(true) let selected: Exclude = $state('openai') + let modalTitle = $derived( + isFork ? 'Fork Workspace' : creationStep === 'ai' ? 'Set up Windmill AI' : 'New Workspace' + ) run(() => { id = name.toLowerCase().replace(/\s/gi, '-') }) @@ -344,7 +424,7 @@ let domain = $derived($usersWorkspaceStore?.email.split('@')[1]) - +
    {#if isFork}
    @@ -410,88 +490,184 @@ {/if} {/if} - - - - {#if !automateUsernameCreation} + {#if isFork || creationStep === 'details'} + - {/if} - {#if !isFork} -
    + + {#if !automateUsernameCreation} + + {/if} + {#if !isFork} +
    + + + {#if isCloudHosted() && isDomainAllowed == false} +
    {domain} domain not allowed for auto-invite
    + {/if} + + {#if auto_invite} +
    + + {#if isCloudHosted()} + + {/if} + + +
    + {/if} +
    + {/if} + +
    + + {#if !forkCreationLoading} + + {:else} + + {/if} +
    + {:else} +
    - (optional but recommended) + + Windmill AI powers the chat, code generation, flow creation, and code completion. Set + it up now or configure it later in Workspace settings. + + Learn more + + - + {#snippet children({ item })} @@ -517,7 +704,7 @@ type="password" autocomplete="new-password" bind:value={aiKey} - onkeyup={handleKeyUp} + onkeyup={handleAiKeyUp} /> {#if aiKey} -
    +
    {/if}
    -
    - +
    - {/if} -
    - - {#if !forkCreationLoading} + Skip for now + - {:else} - - {/if} -
    +
    + {/if}
    diff --git a/frontend/src/lib/components/workspaceSettings/InstanceFallbackSettings.svelte b/frontend/src/lib/components/workspaceSettings/InstanceFallbackSettings.svelte new file mode 100644 index 0000000000..a44981ccbd --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/InstanceFallbackSettings.svelte @@ -0,0 +1,93 @@ + + +{#if instanceAiSummary} + +
    +

    + This workspace is currently using the instance AI defaults shown below. +

    + +
    + {#each sortedInstanceProviders as providerSummary} +
    +
    + + {getProviderLabel(providerSummary.provider)} + + Instance +
    +
    + {#each providerSummary.models as model} + {model} + {/each} +
    +
    + {/each} +
    + + {#if instanceAiSummary.default_model} +
    + Default chat model: + {instanceAiSummary.default_model.model} + + ({getProviderLabel(instanceAiSummary.default_model.provider)}) + +
    + {/if} + + {#if instanceAiSummary.code_completion_model} +
    + Code completion model: + + {instanceAiSummary.code_completion_model.model} + + + ({getProviderLabel(instanceAiSummary.code_completion_model.provider)}) + +
    + {/if} +
    +
    +{/if} + + +
    +

    + Create workspace-specific AI settings only if this workspace needs to override the active + instance defaults. +

    +
    + +
    +
    +
    diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte index 66d9568c09..36f9be8faa 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte @@ -25,13 +25,20 @@ import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte' import SettingCard from '$lib/components/instanceSettings/SettingCard.svelte' import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' + import InstanceAISettings from '$lib/components/instanceSettings/InstanceAISettings.svelte' const settingsSteps = [ { id: 'Core', label: 'Core' }, { id: 'Auth/OAuth/SAML', label: 'Authentication' } ] as const - const wizardStepLabels = [...settingsSteps.map((s) => s.label), 'Root login & Resource Types'] + const AI_STEP_INDEX = settingsSteps.length + + const wizardStepLabels = [ + ...settingsSteps.map((s) => s.label), + 'AI', + 'Root login & Resource Types' + ] const fullStepLabels = ['Settings', 'Root login & Resource Types'] @@ -67,6 +74,7 @@ }) let instanceSettings: InstanceSettings | undefined = $state() + let instanceAiSettings: InstanceAISettings | undefined = $state() function isSettingsStep(step: number): boolean { return step < settingsSteps.length @@ -148,6 +156,9 @@ let passwordValid = $derived(newPassword.length >= 2) let accountFormValid = $derived(emailValid && passwordValid) + // --- AI step state --- + let aiHasUnsavedChanges = $state(false) + // --- EE license key warning --- let showLicenseKeyWarning = $state(false) let pendingNextCallback: (() => void) | undefined = $state(undefined) @@ -168,9 +179,20 @@ let authSubTab: 'sso' | 'oauth' | 'scim' = $derived(tabToAuthSubTab[fullTab] ?? 'sso') let yamlMode = $state(false) - function handleNavigate(newTab: string) { - if (newTab === fullTab) return + function isAiStepActive(): boolean { + return ( + (mode === 'wizard' && wizardStep === AI_STEP_INDEX) || + (mode === 'full' && fullStep === 0 && fullTab === 'ai' && !yamlMode) + ) + } + + async function handleNavigate(newTab: string): Promise { + if (newTab === fullTab) return true + if (isAiStepActive() && !((await instanceAiSettings?.persistBeforeExit()) ?? true)) { + return false + } fullTab = newTab + return true } // --- Settings search (full mode) --- @@ -180,7 +202,10 @@ let highlightTimeout: ReturnType | undefined async function handleSearchSelect(item: SearchableSettingItem) { - handleNavigate(item.tabId) + const didNavigate = await handleNavigate(item.tabId) + if (!didNavigate) { + return + } if (item.settingKey) { clearTimeout(scrollTimeout) clearTimeout(highlightTimeout) @@ -202,7 +227,7 @@ }) /** Check if we need to warn about missing EE license key before proceeding */ - function proceedFromCore(callback: () => void) { + async function proceedFromCore(callback: () => void) { const leavingSettings = (mode === 'wizard' && wizardStep === 0) || (mode === 'full' && fullStep === 0) if (leavingSettings && isEeImage() && isLicenseKeyEmpty()) { @@ -210,12 +235,16 @@ showLicenseKeyWarning = true return } - saveAndProceed(callback) + await saveAndProceed(callback) } /** Auto-save dirty settings, then run the callback */ async function saveAndProceed(callback: () => void) { - if (yamlMode) { + if (isAiStepActive()) { + if (!((await instanceAiSettings?.persistBeforeExit()) ?? true)) { + return + } + } else if (yamlMode) { // In YAML mode, sync editor → form, then bulk-save everything if (!instanceSettings?.syncBeforeDiff()) return await instanceSettings.saveSettings() @@ -231,11 +260,14 @@ callback() } - function switchToFullMode() { + async function switchToFullMode() { mode = 'full' } - function switchToWizardMode() { + async function switchToWizardMode() { + if (isAiStepActive() && !((await instanceAiSettings?.persistBeforeExit()) ?? true)) { + return + } yamlMode = false fullStep = 0 mode = 'wizard' @@ -461,6 +493,13 @@ tab={settingsSteps[wizardStep].id} /> {/key} + {:else if wizardStep === AI_STEP_INDEX} + {:else} {@render accountSetupContent()} {/if} @@ -505,19 +544,28 @@ {/if}
    - { - const targetTab = categoryToTabMap[category] - if (targetTab) { - handleNavigate(targetTab) - } - }} - /> + {#if fullTab === 'ai' && !yamlMode} + + {:else} + { + const targetTab = categoryToTabMap[category] + if (targetTab) { + handleNavigate(targetTab) + } + }} + /> + {/if}
    {:else} diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index d481346af2..db3a140c0b 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -19,10 +19,12 @@ import { OauthService, WorkspaceService, - ResourceService, SettingService, type AIConfig, - type ErrorHandler + type ErrorHandler, + type GetCopilotSettingsStateResponse, + type InstanceAISummary, + type GetSettingsResponse } from '$lib/gen' import { enterpriseLicense, @@ -60,7 +62,6 @@ convertDucklakeSettingsFromBackend, type DucklakeSettingsType } from '$lib/components/workspaceSettings/DucklakeSettings.svelte' - import { AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte' import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import CollapseLink from '$lib/components/CollapseLink.svelte' @@ -112,19 +113,12 @@ let publicAppRateLimitPerMinute: number | undefined = $state(undefined) let initialPublicAppRateLimitPerMinute: number | undefined = $state(undefined) - let aiProviders: Exclude = $state({}) - let codeCompletionModel: string | undefined = $state(undefined) - let defaultModel: string | undefined = $state(undefined) - let customPrompts: Record = $state({}) - let maxTokensPerModel: Record = $state({}) - - // Track initial AI config for unsaved changes detection - let initialAiProviders: Exclude = $state({}) - let initialCodeCompletionModel: string | undefined = $state(undefined) - let initialDefaultModel: string | undefined = $state(undefined) - let initialCustomPrompts: Record = $state({}) - let initialMaxTokensPerModel: Record = $state({}) - + let hasInstanceAiConfig = $state(false) + let usesInstanceAiConfig = $state(false) + let instanceAiSummary: InstanceAISummary | undefined = $state(undefined) + let aiInitialConfig: AIConfig | undefined = $state(undefined) + let aiSettingsComponent: AISettings | undefined = $state(undefined) + let hasAiSettingsChanges = $state(false) // Track initial deploy settings for unsaved changes detection let initialWorkspaceToDeployTo: string | undefined = $state(undefined) let initialDeployUiSettings: { @@ -227,14 +221,6 @@ return currentValue !== initialValue }) - // Derived state for checking unsaved changes in AI settings - let hasAiSettingsChanges = $derived.by(() => { - if (tab !== 'ai') return false - const changes = getAiSettingsInitialAndModifiedValues() - if (!changes.savedValue || !changes.modifiedValue) return false - return hasUnsavedChanges(changes.savedValue, changes.modifiedValue) - }) - // Derived state for checking unsaved changes in deployment settings let hasDeploySettingsChanges = $derived.by(() => { if (tab !== 'deploy_to') return false @@ -320,8 +306,6 @@ $page.url.searchParams.get('tab') === 'teams' ? 'teams_commands' : 'slack_commands' ) - let usingOpenaiClientCredentialsOauth = $state(false) - let loadedSettings = $state(false) let oauths: Record = $state({}) @@ -489,7 +473,17 @@ } async function loadSettings(): Promise { - const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! }) + const [settings, copilotSettingsState]: [ + GetSettingsResponse, + GetCopilotSettingsStateResponse + ] = await Promise.all([ + WorkspaceService.getSettings({ + workspace: $workspaceStore! + }), + WorkspaceService.getCopilotSettingsState({ + workspace: $workspaceStore! + }) + ]) slack_team_name = settings.slack_name teams_team_id = settings.teams_team_id teams_team_name = settings.teams_team_name @@ -508,23 +502,10 @@ workspaceToDeployTo = settings.deploy_to webhook = settings.webhook - aiProviders = settings.ai_config?.providers ?? {} - defaultModel = settings.ai_config?.default_model?.model - codeCompletionModel = settings.ai_config?.code_completion_model?.model - customPrompts = settings.ai_config?.custom_prompts ?? {} - maxTokensPerModel = settings.ai_config?.max_tokens_per_model ?? {} - for (const mode of Object.values(AIMode)) { - if (!(mode in customPrompts)) { - customPrompts[mode] = '' - } - } - - // Store initial AI config state for unsaved changes detection - initialAiProviders = clone(aiProviders) - initialDefaultModel = defaultModel - initialCodeCompletionModel = codeCompletionModel - initialCustomPrompts = clone(customPrompts) - initialMaxTokensPerModel = clone(maxTokensPerModel) + aiInitialConfig = settings.ai_config ?? {} + hasInstanceAiConfig = copilotSettingsState.has_instance_ai_config + usesInstanceAiConfig = copilotSettingsState.uses_instance_ai_config + instanceAiSummary = copilotSettingsState.instance_ai_summary const errorHandler = settings.error_handler as | { path?: string; extra_args?: any; muted_on_cancel?: boolean; muted_on_user_path?: boolean } | undefined @@ -600,12 +581,6 @@ // Store initial success handler state for unsaved changes detection initialSuccessHandlerScriptPath = successHandlerScriptPath - // check openai_client_credentials_oauth - usingOpenaiClientCredentialsOauth = await ResourceService.existsResourceType({ - workspace: $workspaceStore!, - path: 'openai_client_credentials_oauth' - }) - loadedSettings = true } @@ -816,36 +791,6 @@ ) } - // Function to check if there are unsaved changes in AI settings - function getAiSettingsInitialAndModifiedValues() { - const savedValue = { - aiProviders: initialAiProviders, - defaultModel: initialDefaultModel, - codeCompletionModel: initialCodeCompletionModel, - customPrompts: initialCustomPrompts, - maxTokensPerModel: initialMaxTokensPerModel - } - - const modifiedValue = { - aiProviders: aiProviders, - defaultModel: defaultModel, - codeCompletionModel: codeCompletionModel, - customPrompts: customPrompts, - maxTokensPerModel: maxTokensPerModel - } - - return { savedValue, modifiedValue } - } - - // Function to discard unsaved AI settings changes - function discardAiSettingsChanges() { - aiProviders = clone(initialAiProviders) - defaultModel = initialDefaultModel - codeCompletionModel = initialCodeCompletionModel - customPrompts = clone(initialCustomPrompts) - maxTokensPerModel = clone(initialMaxTokensPerModel) - } - // Function to check if there are unsaved changes in storage settings function getStorageSettingsInitialAndModifiedValues() { return { @@ -1017,7 +962,9 @@ case 'windmill_data_tables': return dataTableSettingsComponent?.unsavedChanges() ?? { savedValue: {}, modifiedValue: {} } case 'ai': - return getAiSettingsInitialAndModifiedValues() + return hasAiSettingsChanges + ? { savedValue: { changed: false }, modifiedValue: { changed: true } } + : { savedValue: {}, modifiedValue: {} } case 'windmill_lfs': return getStorageSettingsInitialAndModifiedValues() case 'volume_storage': @@ -1059,7 +1006,7 @@ function discardAllChanges() { switch (tab) { case 'ai': - discardAiSettingsChanges() + aiSettingsComponent?.discard() break case 'windmill_lfs': discardStorageSettingsChanges() @@ -1830,21 +1777,19 @@ export async function main( /> {:else if tab == 'ai'} { - // Update initial state after successful save - initialAiProviders = clone(aiProviders) - initialDefaultModel = defaultModel - initialCodeCompletionModel = codeCompletionModel - initialCustomPrompts = clone(customPrompts) - initialMaxTokensPerModel = clone(maxTokensPerModel) + bind:this={aiSettingsComponent} + initialConfig={aiInitialConfig} + bind:hasUnsavedChanges={hasAiSettingsChanges} + {hasInstanceAiConfig} + {usesInstanceAiConfig} + {instanceAiSummary} + onSave={(copilotSettingsState) => { + if (!copilotSettingsState) { + return + } + hasInstanceAiConfig = copilotSettingsState.has_instance_ai_config + usesInstanceAiConfig = copilotSettingsState.uses_instance_ai_config + instanceAiSummary = copilotSettingsState.instance_ai_summary }} /> {:else if tab == 'windmill_data_tables'} From d578e40101a838d3dffda14157cf72ee4d5a93c0 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 24 Mar 2026 21:22:35 +0000 Subject: [PATCH 170/204] feat: add selfApproval option to WAC + inline approval buttons (#8440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add selfApproval option to WAC waitForApproval + inline approval buttons Add self-approval configuration to WAC workflows and inline approve/reject buttons in WorkflowTimeline. - TS SDK: add selfApproval option to waitForApproval() - Python SDK: add self_approval param to wait_for_approval() - Backend: store approval_conditions in flow_status for WAC, enforce self-approval checks on resume endpoints - Frontend: show Approve/Reject buttons in timeline with form support (EE), gated by user permissions Co-Authored-By: Claude Opus 4.6 (1M context) * fix: revert sqlx query change + regenerate system prompts - Revert get_suspended_flow_info to use original sqlx::query_as! with COALESCE to avoid sqlx offline cache mismatch in CI - Detect WAC by checking if FlowStatus parsing fails + suspend > 0 - Re-fetch flow_status column separately for WAC approval conditions - Regenerate auto-generated system prompt files for SDK changes Co-Authored-By: Claude Opus 4.6 (1M context) * feat: use resume URLs for WAC inline approval buttons - Backend generates HMAC-signed resume/cancel URLs when creating WAC approval, stores them in timeline entry and approval meta - Frontend uses anonymous resume endpoint (like classic flows) with fallback to resumeSuspendedFlowAsOwner for admins - Buttons show for everyone when URLs are present; server-side self_approval_disabled check enforces restrictions - Show warning for admins/owners when self-approval is disabled - selfApproval: false requires EE (errors at dispatch on CE) - self_approval_disabled check moved outside user_auth_required gate so it works independently - WAC detection no longer requires task import Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add resume_suspended and approval_info endpoints - New approval_token DB table for token-based approval access - New POST /jobs_u/flow/resume_suspended/{job_id} endpoint: - OptAuthed: works with login or approval_token - Checks approval_conditions (self_approval, groups, auth) - Admins/owners bypass rules - New GET /jobs_u/flow/approval_info/{job_id} endpoint: - Returns form, rules, can_approve status - HMAC anonymous endpoint now bypasses all approval_conditions (secret = full capability) - getResumeUrls approvalPage URL now uses token format - WAC approval dispatch generates and stores approval tokens - Mark resumeSuspendedFlowAsOwner as legacy Co-Authored-By: Claude Opus 4.6 (1M context) * feat: simplify frontend to use resume_suspended endpoint - OpenAPI spec updated with resume_suspended and approval_info endpoints - WorkflowTimeline: removed URL parsing, now calls single resumeSuspended endpoint for both approve and reject - Buttons show for any logged-in user viewing the job (backend enforces authorization rules) - Kept self-approval warning for admins Co-Authored-By: Claude Opus 4.6 (1M context) * feat: stateless approval tokens, new approval page, FlowStatusWaitingForEvents update - Replace DB-stored approval tokens with stateless HMAC derivation: token = HMAC(workspace_key, job_id + "approval_token") Verifiable without DB lookup, not reversible to resume secret - Drop approval_token migration (no DB table needed) - FlowStatusWaitingForEvents: use resumeSuspended endpoint instead of URL parsing + resumeSuspendedFlowAsOwner - New approval page route /approve/{ws}/{job}?token= that uses approval_info and resume_suspended endpoints - Old approval page route kept for back-compat Co-Authored-By: Claude Opus 4.6 (1M context) * feat: match old approval page content in new approval page - Add FlowMetadata, JobArgs, FlowGraphV2, DisplayResult - Add approvers with tooltips, flow arguments section - Add admin self-approval bypass warning - Add "Open run details" link - Fetch full job alongside approval_info for all UI data Co-Authored-By: Claude Opus 4.6 (1M context) * fix: filter _MODULES from args, show 'workflow' for WAC approvals Co-Authored-By: Claude Opus 4.6 (1M context) * chore: remove deno template from approval/prompt SuspendDrawer Co-Authored-By: Claude Opus 4.6 (1M context) * fix: approval page form display + hide deno from approval script picker - Fix form schema rendering on new approval page by wrapping flat WAC form schemas in { properties, order } for SchemaForm - Hide deno from the approval step language picker in flow editor Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove deno from canHaveApproval in script_helpers.ts The insert menu uses canHaveApproval() from script_helpers.ts via FlowInputsQuick, not the displayLang function in FlowInputs.svelte. Revert the unnecessary FlowInputs.svelte change. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: return form schema and description in approval_info for classic flows The approval_info endpoint was returning None for form_schema on classic flows. Now fetches raw_flow to get suspend.resume_form schema, hide_cancel, and the step's completed result for description. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: inline Login component on approval page instead of redirect Show the Login component directly on the approval page when authentication is required. On successful login, reloads user and approval info without navigating away. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: show resume buttons for all users, not just owners The resume_suspended endpoint handles authorization server-side, so the frontend should always show the buttons. Remove isOwner gate and the "cannot resume" message. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: prevent layout shift on resume by removing spinner from cancel button Co-Authored-By: Claude Opus 4.6 (1M context) * fix: prevent resume button expansion by using disabled instead of loading The loading prop adds a Loader2 spinner that expands the button width. Use disabled={loading} instead to prevent layout shift. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: approval page login redirects back with full page reload Set rd to the full URL (starts with http) so Login.redirectUser() uses window.location.href instead of goto(), triggering a full page reload after login. This ensures the approval page re-fetches data as an authenticated user. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: fetch flow definition from flow_version when raw_flow is null Deployed flows don't store raw_flow on the job. Fall back to flow_version table using runnable_id to get suspend settings (form schema, hide_cancel) for the approval_info endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: show specific reasons when user cannot approve Display whether denial is due to self-approval being disabled, required group membership, or both. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: support both nested and flat form schema in waitForApproval Users can now pass either: waitForApproval({ form: { schema: { name: { type: "string" } } } }) or: waitForApproval({ form: { name: { type: "string" } } }) Both WorkflowTimeline and approval page handle both formats. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: convert sqlx query macros to non-macro for CI offline cache Replace sqlx::query! and sqlx::query_scalar! with sqlx::query and sqlx::query_as to avoid SQLX_OFFLINE cache misses in CI. Also remove unused LogIn import from approval page. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: suppress dead code warning + unused isOwner variable - Add #[allow(dead_code)] to without_flow method (CI -D warnings) - Rename isOwner to _isOwner in FlowStatusWaitingForEvents (unused) Co-Authored-By: Claude Opus 4.6 (1M context) * fix: security and robustness fixes from PR review - Add workspace_id verification in resume_suspended to prevent cross-workspace approval (#3) - Fix token leakage: use relative path for login redirect instead of full URL with token (#4) - Handle getJob failure independently from approval_info so the page works for unauthenticated users (#7) - Clear error state on successful data load (#13) Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address review feedback — shared token gen, rand resume_id, UX - Move generate_approval_token to windmill-common::variables (shared between windmill-api and windmill-worker, eliminates duplicate HMAC) - Use rand::random::() for resume_id instead of DefaultHasher - Stop polling after approve/reject on approval page - Add cancelLoading state to WorkflowTimeline Reject button Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- backend/Cargo.lock | 1 + backend/windmill-api/openapi.yaml | 123 ++++ backend/windmill-api/src/jobs.rs | 530 ++++++++++++++++-- backend/windmill-common/src/variables.rs | 18 + backend/windmill-worker/Cargo.toml | 1 + backend/windmill-worker/src/bun_executor.rs | 89 ++- backend/windmill-worker/src/wac_executor.rs | 24 +- cli/src/guidance/skills.ts | 15 +- .../components/FlowStatusViewerInner.svelte | 1 + .../FlowStatusWaitingForEvents.svelte | 150 ++--- .../lib/components/WorkflowTimeline.svelte | 145 ++++- .../flows/content/SuspendDrawer.svelte | 18 - .../lib/components/runs/JobRunsPreview.svelte | 1 + .../components/scriptEditor/LogPanel.svelte | 1 + frontend/src/lib/script_helpers.ts | 2 +- .../(root)/(logged)/run/[...run]/+page.svelte | 1 + .../approve/[workspace]/[job]/+page.svelte | 359 ++++++++++++ python-client/wmill/wmill/client.py | 11 +- system_prompts/auto-generated/prompts.ts | 9 +- system_prompts/auto-generated/script.md | 9 +- system_prompts/auto-generated/sdks/python.md | 7 +- .../auto-generated/sdks/typescript.md | 2 +- .../skills/write-script-bun/SKILL.md | 2 +- .../skills/write-script-bunnative/SKILL.md | 2 +- .../skills/write-script-deno/SKILL.md | 2 +- .../skills/write-script-nativets/SKILL.md | 2 +- .../skills/write-script-python3/SKILL.md | 7 +- typescript-client/client.ts | 3 + 28 files changed, 1313 insertions(+), 222 deletions(-) create mode 100644 frontend/src/routes/approve/[workspace]/[job]/+page.svelte diff --git a/backend/Cargo.lock b/backend/Cargo.lock index f044478b25..30138085c9 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -17500,6 +17500,7 @@ dependencies = [ "gcp_auth", "git-version", "hex", + "hmac", "hudsucker", "hyper-http-proxy", "hyper-tls", diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d55596bc72..6576633319 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -11088,6 +11088,129 @@ paths: "200": description: Interactive slack approval message sent successfully + /w/{workspace}/jobs_u/flow/resume_suspended/{job_id}: + post: + summary: resume or cancel a suspended flow/WAC job + description: > + Resume or cancel a suspended flow/WAC job. Uses approval rules to + determine authorization. Either a valid approval_token or an + authenticated session is required. + operationId: resumeSuspended + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: job_id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + payload: + description: payload to send to the resumed job + approval_token: + type: string + description: approval token for unauthenticated access + approved: + type: boolean + description: whether to approve (true) or cancel (false) the job + default: true + responses: + "201": + description: job resumed + content: + text/plain: + schema: + type: string + + /w/{workspace}/jobs_u/flow/approval_info/{job_id}: + get: + summary: get approval info for a suspended flow/WAC job + description: > + Get approval info for a suspended flow/WAC job. Returns form schema, + approval rules, and whether the current user can approve. Either a + valid token query parameter or an authenticated session is required. + operationId: getApprovalInfo + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: job_id + in: path + required: true + schema: + type: string + format: uuid + - name: token + in: query + required: false + schema: + type: string + description: approval token for unauthenticated access + responses: + "200": + description: approval info + content: + application/json: + schema: + type: object + required: + - flow_id + - can_approve + - user_auth_required + - approvers + properties: + flow_id: + type: string + format: uuid + form_schema: + description: form schema for the approval step + description: + description: description of the approval step + approval_conditions: + type: object + properties: + user_auth_required: + type: boolean + user_groups_required: + type: array + items: + type: string + self_approval_disabled: + type: boolean + required: + - user_auth_required + - user_groups_required + - self_approval_disabled + can_approve: + type: boolean + description: whether the current user/token holder can approve + user_auth_required: + type: boolean + description: whether user authentication is required to approve + hide_cancel: + type: boolean + description: whether to hide the cancel button in the UI + approvers: + type: array + items: + type: object + required: + - resume_id + - approver + properties: + resume_id: + type: integer + approver: + type: string + /w/{workspace}/jobs_u/resume/{id}/{resume_id}/{signature}: get: summary: resume a job for a suspended flow diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index a99b2737a8..bde3b81a14 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -103,7 +103,7 @@ use windmill_common::{ cache, db::UserDB, error::{self, to_anyhow, Error}, - flow_status::{Approval, FlowStatus, FlowStatusModule}, + flow_status::{Approval, ApprovalConditions, FlowStatus, FlowStatusModule}, flows::{add_virtual_items_if_necessary, resolve_maybe_value, FlowValue}, jobs::{script_path_to_payload, CompletedJob, JobKind, JobPayload, QueuedJob, RawCode}, oauth2::HmacSha256, @@ -401,6 +401,8 @@ pub fn workspace_unauthed_service() -> Router { post(cancel_persistent_script_api), ) .route("/queue/force_cancel/:id", post(force_cancel)) + .route("/flow/resume_suspended/:job_id", post(resume_suspended)) + .route("/flow/approval_info/:job_id", get(get_approval_info)) } pub fn global_root_service() -> Router { @@ -1058,6 +1060,7 @@ impl<'a> GetQuery<'a> { Self { with_code: false, ..self } } + #[allow(dead_code)] fn without_flow(self) -> Self { Self { with_flow: false, ..self } } @@ -2181,7 +2184,7 @@ pub async fn resume_suspended_flow_as_owner( ) -> error::Result { let mut tx = db.begin().await?; - let (flow, job_id) = get_suspended_flow_info(flow_id, &mut tx).await?; + let (flow, job_id, is_wac) = get_suspended_flow_info(flow_id, &mut tx).await?; let flow_path = flow.script_path.as_deref().unwrap_or_else(|| ""); require_owner_of_path(&authed, flow_path)?; @@ -2189,10 +2192,17 @@ pub async fn resume_suspended_flow_as_owner( // Check approval conditions (self-approval, required groups, etc.) if let Some(ref flow_status_value) = flow.flow_status { - if let Ok(flow_status) = serde_json::from_value::(flow_status_value.clone()) { - let trigger_email = flow.email.as_deref().unwrap_or(""); - conditionally_require_authed_user(Some(authed.clone()), flow_status, trigger_email)?; - } + let trigger_email = flow.email.as_deref().unwrap_or(""); + let ac = serde_json::from_value::(flow_status_value.clone()) + .ok() + .and_then(|fs| fs.approval_conditions) + .or_else(|| { + // WAC flows store approval_conditions directly in flow_status JSONB + flow_status_value + .get("approval_conditions") + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + }); + conditionally_require_authed_user(Some(authed.clone()), ac, trigger_email)?; } let value = value.unwrap_or(serde_json::Value::Null); @@ -2208,12 +2218,426 @@ pub async fn resume_suspended_flow_as_owner( ) .await?; - resume_immediately_if_relevant(flow, job_id, &mut tx).await?; + if is_wac { + // WAC: directly decrement suspend counter + if flow.suspend > 0 { + sqlx::query!( + "UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1", + flow.id, + ) + .execute(&mut *tx) + .await?; + } + } else { + resume_immediately_if_relevant(flow, job_id, &mut tx).await?; + } tx.commit().await?; Ok(StatusCode::CREATED) } +// --- New approval system endpoints --- + +use windmill_common::variables::generate_approval_token; + +/// Verify an approval token against the workspace key + job_id. +async fn validate_approval_token( + db: &DB, + token: &str, + job_id: Uuid, + workspace_id: &str, +) -> error::Result<()> { + let expected = generate_approval_token(workspace_id, job_id, db).await?; + if token != expected { + return Err(Error::NotAuthorized("Invalid approval token".to_string())); + } + Ok(()) +} + +#[derive(Deserialize)] +struct ResumeSuspendedBody { + payload: Option, + approval_token: Option, + approved: Option, +} + +async fn resume_suspended( + OptAuthed(opt_authed): OptAuthed, + Extension(db): Extension, + Path((w_id, job_id)): Path<(String, Uuid)>, + Json(body): Json, +) -> error::Result { + let approved = body.approved.unwrap_or(true); + let value = body.payload.unwrap_or(serde_json::Value::Null); + + // Determine if we have a valid authed user or token + let has_token = if let Some(ref token) = body.approval_token { + validate_approval_token(&db, token, job_id, &w_id) + .await + .is_ok() + } else { + false + }; + + if opt_authed.is_none() && !has_token { + return Err(Error::NotAuthorized( + "Must be logged in or provide a valid approval token".to_string(), + )); + } + + let mut tx = db.begin().await?; + + // Resolve the suspended flow (works for both WAC and classic flows) + let (flow, resume_job_id, is_wac) = get_suspended_flow_info(job_id, &mut tx).await?; + + // Verify the job belongs to this workspace + let job_workspace: Option = + sqlx::query_scalar("SELECT workspace_id FROM v2_job WHERE id = $1") + .bind(&flow.id) + .fetch_optional(&mut *tx) + .await?; + if job_workspace.as_deref() != Some(w_id.as_str()) { + return Err(Error::NotFound( + "Job not found in this workspace".to_string(), + )); + } + + // Check approval conditions + let approval_conditions = if is_wac { + flow.flow_status + .as_ref() + .and_then(|v| v.get("approval_conditions")) + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + } else { + flow.flow_status + .as_ref() + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + .and_then(|fs| fs.approval_conditions) + }; + + if let Some(ref ac) = approval_conditions { + if ac.user_auth_required && opt_authed.is_none() { + return Err(Error::NotAuthorized( + "This approval requires a logged-in user. Please sign in.".to_string(), + )); + } + } + + // If logged in, check authorization rules + if let Some(ref authed) = opt_authed { + let is_admin = authed.is_admin; + let is_owner = flow + .script_path + .as_deref() + .map(|p| require_owner_of_path(authed, p).is_ok()) + .unwrap_or(false); + + if !is_admin && !is_owner { + let trigger_email = flow.email.as_deref().unwrap_or(""); + conditionally_require_authed_user( + Some(authed.clone()), + approval_conditions.clone(), + trigger_email, + )?; + } + } else if !has_token { + return Err(Error::NotAuthorized( + "Must be logged in or provide a valid approval token".to_string(), + )); + } + + // Generate a unique resume_id + let resume_id: u32 = rand::random(); + + // Check for duplicate + let exists: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM resume_job WHERE id = $1)") + .bind(Uuid::from_u128(resume_job_id.as_u128() ^ resume_id as u128)) + .fetch_one(&mut *tx) + .await?; + + if exists { + return Err(Error::BadRequest("Resume request already sent".to_string())); + } + + let approver_value = opt_authed.as_ref().map(|a| a.username.clone()); + + insert_resume_job( + resume_id, + resume_job_id, + &flow, + value, + approver_value.clone(), + approved, + &mut tx, + ) + .await?; + + if !approved { + sqlx::query("UPDATE v2_job_queue SET suspend = 0 WHERE id = $1") + .bind(&flow.id) + .execute(&mut *tx) + .await?; + } else if is_wac { + if flow.suspend > 0 { + sqlx::query("UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1") + .bind(&flow.id) + .execute(&mut *tx) + .await?; + } + } else { + resume_immediately_if_relevant(flow, resume_job_id, &mut tx).await?; + } + + let approver = approver_value.unwrap_or_else(|| "anonymous".to_string()); + let audit_author = if let Some(ref authed) = opt_authed { + AuditAuthor::from(authed) + } else { + AuditAuthor { + email: approver.clone(), + username: approver.clone(), + username_override: None, + token_prefix: None, + } + }; + + audit_log( + &mut *tx, + &audit_author, + "jobs.suspend_resume", + ActionKind::Update, + &w_id, + Some( + &serde_json::json!({ + "approved": approved, + "job_id": job_id, + "details": if approved { + format!("Approved by {}", &approver) + } else { + format!("Cancelled by {}", &approver) + } + }) + .to_string(), + ), + None, + ) + .await?; + + tx.commit().await?; + Ok(StatusCode::CREATED) +} + +#[derive(Deserialize)] +struct ApprovalInfoQuery { + token: Option, +} + +#[derive(Serialize)] +struct ApprovalInfo { + flow_id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + form_schema: Option, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + approval_conditions: Option, + can_approve: bool, + user_auth_required: bool, + #[serde(skip_serializing_if = "Option::is_none")] + hide_cancel: Option, + approvers: Vec, +} + +async fn get_approval_info( + OptAuthed(opt_authed): OptAuthed, + Extension(db): Extension, + Path((w_id, job_id)): Path<(String, Uuid)>, + Query(query): Query, +) -> error::Result> { + // Validate access: either logged in or valid token + let has_token = if let Some(ref token) = query.token { + validate_approval_token(&db, token, job_id, &w_id) + .await + .is_ok() + } else { + false + }; + + if opt_authed.is_none() && !has_token { + return Err(Error::NotAuthorized( + "Must be logged in or provide a valid approval token".to_string(), + )); + } + + // Fetch job info + #[derive(sqlx::FromRow)] + struct ApprovalJobRow { + id: Uuid, + script_path: Option, + email: String, + flow_status: Option, + workflow_as_code_status: Option, + } + let row = sqlx::query_as::<_, ApprovalJobRow>( + "SELECT j.id, j.runnable_path as script_path, j.permissioned_as_email as email, + s.flow_status, s.workflow_as_code_status + FROM v2_job j + LEFT JOIN v2_job_status s ON s.id = j.id + WHERE j.id = $1 AND j.workspace_id = $2", + ) + .bind(&job_id) + .bind(&w_id) + .fetch_optional(&db) + .await? + .ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?; + + let is_wac = row.workflow_as_code_status.is_some(); + + // Extract approval info based on WAC vs classic flow + let (form_schema, description, approval_conditions, hide_cancel) = if is_wac { + let approval_meta = row + .workflow_as_code_status + .as_ref() + .and_then(|v| v.get("_approval")); + let form = approval_meta.and_then(|m| m.get("form").cloned()); + let ac = row + .flow_status + .as_ref() + .and_then(|v| v.get("approval_conditions")) + .and_then(|v| serde_json::from_value::(v.clone()).ok()); + (form, None, ac, None) + } else { + let fs = row + .flow_status + .as_ref() + .and_then(|v| serde_json::from_value::(v.clone()).ok()); + let ac = fs.as_ref().and_then(|s| s.approval_conditions.clone()); + + // For classic flows, form/description come from the flow definition and step result + let approval_step = fs.as_ref().map(|s| (s.step as usize).saturating_sub(1)); + + // Fetch flow definition to get suspend settings (form schema, hide_cancel). + // Try raw_flow on the job first, fall back to flow_version for deployed flows. + let raw_flow: Option = { + let from_job: Option = sqlx::query_scalar( + "SELECT raw_flow FROM v2_job WHERE id = $1 AND workspace_id = $2", + ) + .bind(&job_id) + .bind(&w_id) + .fetch_optional(&db) + .await? + .flatten(); + + if let Some(v) = from_job { + serde_json::from_value(v).ok() + } else { + // Deployed flow: fetch from flow_version using runnable_id + let from_version: Option = sqlx::query_scalar( + "SELECT fv.value FROM v2_job j JOIN flow_version fv ON fv.id = j.runnable_id \ + WHERE j.id = $1 AND j.workspace_id = $2", + ) + .bind(&job_id) + .bind(&w_id) + .fetch_optional(&db) + .await? + .flatten(); + from_version.and_then(|v| serde_json::from_value(v).ok()) + } + }; + + let suspend_module = raw_flow + .as_ref() + .and_then(|rf| approval_step.and_then(|s| rf.modules.get(s))); + let suspend_settings = suspend_module.and_then(|m| m.suspend.as_ref()); + + let form = suspend_settings + .and_then(|s| s.resume_form.as_ref()) + .map(|rf| serde_json::json!(rf)); + let hc = suspend_settings.map(|s| s.hide_cancel.unwrap_or(false)); + + // Fetch description and default_args from the step's completed job result + let step_job_id = fs + .as_ref() + .and_then(|s| approval_step.and_then(|step| s.modules.get(step))) + .and_then(|m| m.job()); + let (desc, _default_args) = if let Some(sjid) = step_job_id { + let result: Option = sqlx::query_scalar( + "SELECT result FROM v2_job_completed WHERE id = $1 AND workspace_id = $2", + ) + .bind(sjid) + .bind(&w_id) + .fetch_optional(&db) + .await? + .flatten(); + let desc = result.as_ref().and_then(|r| r.get("description").cloned()); + let da = result.as_ref().and_then(|r| r.get("default_args").cloned()); + (desc, da) + } else { + (None, None) + }; + + (form, desc, ac, hc) + }; + + let user_auth_required = approval_conditions + .as_ref() + .map(|ac| ac.user_auth_required) + .unwrap_or(false); + + // Determine if current user can approve + let can_approve = if let Some(ref authed) = opt_authed { + if authed.is_admin { + true + } else { + let is_owner = row + .script_path + .as_deref() + .map(|p| require_owner_of_path(authed, p).is_ok()) + .unwrap_or(false); + if is_owner { + true + } else { + let trigger_email = row.email.as_str(); + conditionally_require_authed_user( + Some(authed.clone()), + approval_conditions.clone(), + trigger_email, + ) + .is_ok() + } + } + } else { + // Not logged in — can approve only if no auth required + !user_auth_required + }; + + // Get existing approvers + let approvers: Vec = sqlx::query_as::<_, (i32, Option)>( + "SELECT resume_id, approver FROM resume_job WHERE flow = $1", + ) + .bind(&job_id) + .fetch_all(&db) + .await? + .into_iter() + .map(|(rid, approver)| Approval { + resume_id: rid as u16, + approver: approver.unwrap_or_else(|| "anonymous".to_string()), + }) + .collect(); + + Ok(Json(ApprovalInfo { + flow_id: row.id, + form_schema, + description, + approval_conditions, + can_approve, + user_auth_required, + hide_cancel, + approvers, + })) +} + +// --- End new approval system endpoints --- + pub async fn resume_suspended_job( authed: Option, opt_tokened: OptTokened, @@ -2255,26 +2679,8 @@ async fn resume_suspended_job_internal( // Get flow info - works for step-level, flow-level, and WAC approval let (flow_info, is_flow_level, is_wac) = get_flow_info_for_resume(job_id, &db).await?; - // For step-level resumes, verify user auth and flow status - // For flow-level resumes (pre-approvals), the flow might not be at a suspended step yet - // For WAC approvals, skip flow status checks (there is no flow) - if !is_flow_level && !is_wac { - let parent_flow = GetQuery::new() - .without_logs() - .without_code() - .without_flow() - .fetch(&db, &flow_info.id, &w_id) - .await?; - let flow_status = parent_flow - .flow_status() - .ok_or_else(|| anyhow::anyhow!("unable to find the flow status in the flow job"))?; - - let trigger_email = match &parent_flow { - Job::CompletedJob(job) => &job.email, - Job::QueuedJob(job) => &job.email, - }; - conditionally_require_authed_user(authed.clone(), flow_status, trigger_email)?; - } + // HMAC secret = full capability. Skip approval_conditions checks. + // Authorization rules are enforced by the new resume_suspended endpoint instead. let exists = sqlx::query_scalar!( r#" @@ -2540,7 +2946,7 @@ async fn get_flow_info_for_resume(job_id: Uuid, db: &DB) -> error::Result<(FlowI async fn get_suspended_flow_info<'c>( job_id: Uuid, tx: &mut Transaction<'c, Postgres>, -) -> error::Result<(FlowInfo, Uuid)> { +) -> error::Result<(FlowInfo, Uuid, bool)> { let flow = sqlx::query_as!( FlowInfo, r#" @@ -2553,7 +2959,9 @@ async fn get_suspended_flow_info<'c>( .fetch_optional(&mut **tx) .await? .ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?; - let job_id = flow + + // Try to extract step job_id from FlowStatus modules (classic flow path) + let step_job_id = flow .flow_status .as_ref() .and_then(|v| serde_json::from_value::(v.clone()).ok()) @@ -2562,8 +2970,31 @@ async fn get_suspended_flow_info<'c>( _ => None, }); - if let Some(job_id) = job_id { - Ok((flow, job_id)) + if let Some(step_job_id) = step_job_id { + // Classic flow + Ok((flow, step_job_id, false)) + } else if flow.suspend > 0 { + // WAC approval: no FlowStatus modules, but the job is suspended + // The flow_status here comes from COALESCE(flow_status, workflow_as_code_status), + // so for WAC it may contain approval_conditions from flow_status column + // or the WAC checkpoint from workflow_as_code_status column. + // We need the approval_conditions which are in flow_status column. + // Re-fetch just flow_status (without COALESCE fallback) for the auth check. + let flow_status_only: Option = + sqlx::query_scalar("SELECT flow_status FROM v2_job_status WHERE id = $1") + .bind(&job_id) + .fetch_optional(&mut **tx) + .await? + .flatten(); + + let flow = FlowInfo { + id: flow.id, + flow_status: flow_status_only, + suspend: flow.suspend, + script_path: flow.script_path, + email: flow.email, + }; + Ok((flow, job_id, true)) } else { Err(anyhow::anyhow!("the flow is not in a suspended state anymore").into()) } @@ -2640,7 +3071,11 @@ pub async fn get_suspended_job_flow( Job::CompletedJob(job) => &job.email, Job::QueuedJob(job) => &job.email, }; - conditionally_require_authed_user(authed.clone(), flow_status.clone(), trigger_email)?; + conditionally_require_authed_user( + authed.clone(), + flow_status.approval_conditions.clone(), + trigger_email, + )?; let approvers_from_status = match flow_module_status { FlowStatusModule::Success { approvers, .. } => approvers.to_owned(), @@ -2681,16 +3116,25 @@ pub async fn get_suspended_job_flow( fn conditionally_require_authed_user( _authed: Option, - flow_status: FlowStatus, + approval_conditions_opt: Option, _trigger_email: &str, ) -> error::Result<()> { - let approval_conditions_opt = flow_status.approval_conditions; - if approval_conditions_opt.is_none() { return Ok(()); } let approval_conditions = approval_conditions_opt.unwrap(); + // Check self-approval independently of user_auth_required + if approval_conditions.self_approval_disabled { + if let Some(ref authed) = _authed { + if !authed.is_admin && authed.email.eq(_trigger_email) { + return Err(Error::PermissionDenied( + "Self-approval is disabled for this flow step".to_string(), + )); + } + } + } + if approval_conditions.user_auth_required { { #[cfg(not(feature = "enterprise"))] @@ -2708,13 +3152,6 @@ fn conditionally_require_authed_user( let authed = _authed.unwrap(); if !authed.is_admin { - if approval_conditions.self_approval_disabled && authed.email.eq(_trigger_email) - { - return Err(Error::PermissionDenied( - "Self-approval is disabled for this flow step".to_string(), - )); - } - if !approval_conditions.user_groups_required.is_empty() { #[cfg(feature = "enterprise")] { @@ -2860,11 +3297,18 @@ pub async fn get_resume_urls_internal( .map(|x| format!("?approver={}", encode(x))) .unwrap_or_else(String::new); + // Generate approval token for the new approval page URL. + // The token targets the parent flow/WAC job for proper resolution. + let approval_target_id = get_flow_id_for_job(&db, job_id) + .await + .unwrap_or(target_job_id); + let approval_token = generate_approval_token(&w_id, approval_target_id, &db).await?; + let base_url_str = BASE_URL.read().await.clone(); let base_url = base_url_str.as_str(); let res = ResumeUrls { approvalPage: format!( - "{base_url}/approve/{w_id}/{target_job_id}/{resume_id}/{signature}{approver_query}" + "{base_url}/approve/{w_id}/{approval_target_id}?token={approval_token}" ), cancel: build_resume_url( "cancel", diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index 8129f39b3f..e7595f9d1b 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -140,6 +140,24 @@ pub async fn get_workspace_key(w_id: &str, db: &DB) -> crate::error::Result crate::error::Result { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + let key = get_workspace_key(w_id, db).await?; + let mut mac = Hmac::::new_from_slice(key.as_bytes()) + .map_err(|e| crate::Error::internal_err(format!("HMAC key error: {e}")))?; + mac.update(job_id.as_bytes()); + mac.update(b"approval_token"); + Ok(hex::encode(mac.finalize().into_bytes())) +} + pub async fn get_secret_value_as_admin( db: &DB, w_id: &str, diff --git a/backend/windmill-worker/Cargo.toml b/backend/windmill-worker/Cargo.toml index ed82298038..c1e2a4927a 100644 --- a/backend/windmill-worker/Cargo.toml +++ b/backend/windmill-worker/Cargo.toml @@ -110,6 +110,7 @@ gcp_auth = { workspace = true, optional = true } rust_decimal.workspace = true jsonwebtoken.workspace = true sha2.workspace = true +hmac.workspace = true pem = { workspace = true, optional = true } urlencoding.workspace = true nix.workspace = true diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index f0ed8793a7..7dbaa6723d 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -1504,7 +1504,7 @@ async function run() {{ return {{ type: "inline_checkpoint", key: dispatch.key, result: dispatch.result ?? null, started_at: dispatch.started_at, duration_ms: dispatch.duration_ms }}; }} if (dispatch.mode === "approval") {{ - return {{ type: "approval", key: dispatch.key, timeout: dispatch.timeout, form: dispatch.form }}; + return {{ type: "approval", key: dispatch.key, timeout: dispatch.timeout, form: dispatch.form, self_approval_disabled: dispatch.self_approval_disabled }}; }} if (dispatch.mode === "sleep") {{ return {{ type: "sleep", key: dispatch.key, seconds: dispatch.seconds }}; @@ -2634,7 +2634,7 @@ pub async fn handle_wac_v2_output( job.id, num_steps ))) } - WacOutput::Approval { key, timeout, form } => { + WacOutput::Approval { key, timeout, form, self_approval_disabled } => { let db = match conn { Connection::Sql(db) => db, _ => { @@ -2676,11 +2676,91 @@ pub async fn handle_wac_v2_output( .await .map_err(|e| error::Error::internal_err(format!("Failed to save checkpoint: {e}")))?; + // Store approval_conditions in flow_status for resume endpoint auth checks + let sad = self_approval_disabled.unwrap_or(false); + if sad { + #[cfg(not(feature = "enterprise"))] + return Err(error::Error::ExecutionErr( + "Disabling self-approval is an enterprise only feature".to_string(), + )); + + #[cfg(feature = "enterprise")] + { + use windmill_common::flow_status::ApprovalConditions; + let approval_conditions = ApprovalConditions { + user_auth_required: true, + user_groups_required: vec![], + self_approval_disabled: true, + }; + sqlx::query( + "UPDATE v2_job_status SET flow_status = JSONB_SET( + COALESCE(flow_status, '{}'::jsonb), + '{approval_conditions}', + $2::jsonb + ) WHERE id = $1", + ) + .bind(&job.id) + .bind(&serde_json::json!(approval_conditions)) + .execute(&mut *tx) + .await + .map_err(|e| { + error::Error::internal_err(format!( + "Failed to save approval conditions: {e}" + )) + })?; + } + } + + // Generate resume URLs for the inline approval buttons. + // Use a hash of the step key as resume_id so each waitForApproval() + // in the same workflow gets a unique resume_job record. + let resume_id: u32 = { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + key.hash(&mut hasher); + (hasher.finish() & 0xFFFF_FFFF) as u32 + }; + // Generate stateless approval token using shared utility + let approval_token = + windmill_common::variables::generate_approval_token(&job.workspace_id, job.id, db) + .await?; + + let (resume_url, cancel_url, approval_page_url) = { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + use windmill_common::variables::get_workspace_key; + + let wkey = get_workspace_key(&job.workspace_id, db).await?; + let mut mac = Hmac::::new_from_slice(wkey.as_bytes()) + .map_err(|e| error::Error::internal_err(format!("HMAC key error: {e}")))?; + mac.update(job.id.as_bytes()); + mac.update(resume_id.to_be_bytes().as_ref()); + let signature = hex::encode(mac.finalize().into_bytes()); + + let base_url = windmill_common::BASE_URL.read().await.clone(); + let w_id = &job.workspace_id; + let job_id = &job.id; + + let resume = format!( + "{base_url}/api/w/{w_id}/jobs_u/resume/{job_id}/{resume_id}/{signature}" + ); + let cancel = format!( + "{base_url}/api/w/{w_id}/jobs_u/cancel/{job_id}/{resume_id}/{signature}" + ); + let approval_page = + format!("{base_url}/approve/{w_id}/{job_id}?token={approval_token}"); + (resume, cancel, approval_page) + }; + // Store approval form metadata for the approval page endpoint let approval_meta = serde_json::json!({ "key": key, "form": form, "timeout": timeout_secs as u32, + "self_approval_disabled": sad, + "resume": resume_url, + "cancel": cancel_url, + "approvalPage": approval_page_url, }); sqlx::query( "UPDATE v2_job_status SET workflow_as_code_status = jsonb_set( @@ -2705,6 +2785,11 @@ pub async fn handle_wac_v2_output( "started_at": &now_str, "name": key, "approval": true, + "self_approval_disabled": sad, + "form": form, + "resume": &resume_url, + "cancel": &cancel_url, + "approvalPage": &approval_page_url, }); let step_timeline_key = format!("_step/{}", key); sqlx::query( diff --git a/backend/windmill-worker/src/wac_executor.rs b/backend/windmill-worker/src/wac_executor.rs index 208012ccce..9b4ba3d92a 100644 --- a/backend/windmill-worker/src/wac_executor.rs +++ b/backend/windmill-worker/src/wac_executor.rs @@ -59,7 +59,13 @@ pub enum WacOutput { /// No child job is dispatched — the parent suspends directly and resumes /// when a user hits the resume/cancel endpoint. #[serde(rename = "approval")] - Approval { key: String, timeout: Option, form: Option }, + Approval { + key: String, + timeout: Option, + form: Option, + #[serde(default)] + self_approval_disabled: Option, + }, /// Server-side sleep — suspend the workflow for a duration without holding a worker. #[serde(rename = "sleep")] Sleep { key: String, seconds: u32 }, @@ -306,15 +312,13 @@ pub async fn prepare_checkpoint_for_resume( } /// Detect WAC v2 patterns in TypeScript/Bun code. -/// Checks for `import ... from "windmill-client"` containing workflow/task, +/// Checks for `import ... from "windmill-client"` containing workflow, /// skipping comment lines. Handles both single-line and multi-line imports. pub fn is_wac_v2_ts(code: &str) -> bool { let mut has_wac_import = false; let mut has_workflow = false; - let mut has_task = false; let mut in_import_block = false; let mut import_block_has_workflow = false; - let mut import_block_has_task = false; for line in code.lines() { let trimmed = line.trim(); if trimmed.starts_with("//") { @@ -328,34 +332,24 @@ pub fn is_wac_v2_ts(code: &str) -> bool { if trimmed.contains("workflow") { has_workflow = true; } - if trimmed.contains("task") { - has_task = true; - } in_import_block = false; } // Start of multi-line import: import { else if trimmed.starts_with("import") && trimmed.contains("{") && !trimmed.contains("}") { in_import_block = true; import_block_has_workflow = trimmed.contains("workflow"); - import_block_has_task = trimmed.contains("task"); } // Inside multi-line import block else if in_import_block { if trimmed.contains("workflow") { import_block_has_workflow = true; } - if trimmed.contains("task") { - import_block_has_task = true; - } // End of multi-line import: } from "windmill-client" if trimmed.contains("windmill-client") { has_wac_import = true; if import_block_has_workflow { has_workflow = true; } - if import_block_has_task { - has_task = true; - } in_import_block = false; } // End of import block but not windmill-client @@ -367,7 +361,7 @@ pub fn is_wac_v2_ts(code: &str) -> bool { has_workflow = true; } } - has_wac_import && has_workflow && has_task + has_wac_import && has_workflow } /// Detect WAC v2 patterns in Python code. diff --git a/cli/src/guidance/skills.ts b/cli/src/guidance/skills.ts index e1354302e6..662c55eb8e 100644 --- a/cli/src/guidance/skills.ts +++ b/cli/src/guidance/skills.ts @@ -740,7 +740,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. @@ -1403,7 +1403,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. @@ -2129,7 +2129,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. @@ -3069,7 +3069,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. @@ -4078,12 +4078,17 @@ async def sleep(seconds: int) # # Returns a dict with \`\`value\`\` (form data), \`\`approver\`\`, and \`\`approved\`\`. # +# Args: +# timeout: Approval timeout in seconds (default 1800). +# form: Optional form schema for the approval page. +# self_approval: Whether the user who triggered the flow can approve it (default True). +# # Example:: # # urls = await step("urls", lambda: get_resume_urls()) # await step("notify", lambda: send_email(urls["approvalPage"])) # result = await wait_for_approval(timeout=3600) -async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict +async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict # Process items in parallel with optional concurrency control. # diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index e9f2361629..2c580bb02b 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -2084,6 +2084,7 @@ stepResults={getStepResults(node.workflow_as_code_status)} result={node.result} success={node.type === 'Success'} + jobId={node.job_id} />
    {/if} diff --git a/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte b/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte index 60f30b1adc..195a1d6fad 100644 --- a/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte +++ b/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte @@ -18,11 +18,9 @@ light?: boolean } - let { isOwner, workspaceId, job, light = false }: Props = $props() + let { isOwner: _isOwner, workspaceId, job, light = false }: Props = $props() let default_payload: object = $state({}) - let resumeUrl: string | undefined = $state(undefined) - let cancelUrl: string | undefined = $state(undefined) let description: any = $state(undefined) let hide_cancel = $state(false) @@ -49,8 +47,6 @@ defaultValues = JSON.parse(JSON.stringify(args)) default_payload = args - resumeUrl = job_result?.['resume'] - cancelUrl = job_result?.['cancel'] hide_cancel = job?.raw_flow?.modules?.[approvalStep]?.suspend?.hide_cancel ?? false schema = mergeSchema( job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema ?? {}, @@ -61,61 +57,19 @@ let loading = $state(false) async function continu(approve: boolean) { loading = true - if ((resumeUrl && approve) || (cancelUrl && !approve)) { - let split = (approve ? resumeUrl : cancelUrl)!.split('/') - let signatureUrl = split.pop() ?? '' - const regex = /([^?]+)(?:\?[^=]+=(\w+))?/ - - const matches = signatureUrl.match(regex) - - const signature = matches?.[1] - if (!signature) { - sendUserToast(`Could not parse signature: ${signatureUrl}`, true) - return - } - const approver = matches?.[2] || undefined - - let resumeId = -1 - let parsedResumeId = split.pop() ?? '' - try { - resumeId = new Number(parsedResumeId).valueOf() - } catch (e) { - console.error(`Could not parse resume id: ${parsedResumeId}`) - } - let jobId = split.pop() ?? '' - if (approve) { - await JobService.resumeSuspendedJobPost({ - workspace: workspaceId ?? $workspaceStore ?? '', - id: jobId, - requestBody: default_payload as any, - resumeId, - signature, - approver - }) - } else { - await JobService.cancelSuspendedJobPost({ - workspace: workspaceId ?? $workspaceStore ?? '', - id: jobId, - resumeId, - signature, - approver, - requestBody: {} - }) - } - } else { - if (approve) { - await JobService.resumeSuspendedFlowAsOwner({ - workspace: workspaceId ?? $workspaceStore ?? '', - id: job?.id ?? '', - requestBody: default_payload as any - }) - } else { - await JobService.cancelQueuedJob({ - workspace: workspaceId ?? $workspaceStore ?? '', - id: job?.id ?? '', - requestBody: {} - }) - } + try { + await JobService.resumeSuspended({ + workspace: workspaceId ?? $workspaceStore ?? '', + jobId: job?.id ?? '', + requestBody: { + payload: approve ? (default_payload as any) : undefined, + approved: approve + } + }) + } catch (e: any) { + sendUserToast(e?.body ?? e?.message ?? 'Failed', true) + } finally { + loading = false } } let approvalStep = $derived((job?.flow_status?.step ?? 1) - 1) @@ -130,51 +84,41 @@
    {/if}
    - {#if isOwner || resumeUrl} -
    - {#if !hide_cancel} -
    -
    - {/if} +
    + {#if !hide_cancel}
    - +
    - - {#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema} -
    - -
    - - The payload is optional, it is passed to the following step through the `resume` - variable - - {/if} + {/if} +
    +
    - {:else} - You cannot resume the flow yourself without receiving the resume secret since you are not an - owner of {job.script_path} and the approval step did not contain the resume url at key `resume` - {/if} + + {#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema} +
    + +
    + + The payload is optional, it is passed to the following step through the `resume` variable + + {/if} +
    diff --git a/frontend/src/lib/components/WorkflowTimeline.svelte b/frontend/src/lib/components/WorkflowTimeline.svelte index 3dea7279d6..2ea5ebba50 100644 --- a/frontend/src/lib/components/WorkflowTimeline.svelte +++ b/frontend/src/lib/components/WorkflowTimeline.svelte @@ -1,6 +1,6 @@ {#if flow_status} @@ -167,22 +216,74 @@ sleep ({(v as any).sleep_duration_s}s)
    {:else if isApproval} -
    -
    - - - {v.name ?? stepKey(k)} - - {#if !isDone} - - - waiting + {@const selfApprovalDisabled = (v as any).self_approval_disabled === true} + {@const formSchema = (v as any).form?.schema ?? (v as any).form} + {@const hasForm = + formSchema && typeof formSchema === 'object' && Object.keys(formSchema).length > 0} + {@const canApprove = !isDone && jobId} +
    +
    +
    + + + {v.name ?? stepKey(k)} - {:else} - {msToSec(v.duration_ms ?? 0)}s + {#if !isDone} + + + waiting + + {#if canApprove} +
    + + +
    + {/if} + {:else} + {msToSec(v.duration_ms ?? 0)}s + {/if} +
    + {#if canApprove && selfApprovalDisabled && $userStore?.is_admin} +
    + Self-approval is disabled but allowed because you are an admin/owner +
    + {/if} + {#if canApprove && hasForm} +
    + {#if emptyString($enterpriseLicense)} + + {:else} + + {/if} +
    {/if}
    {:else} @@ -275,13 +376,13 @@ {@const result = stepResults[stepKey(k)]} {#if isDone && result !== undefined}
    -
    Result
    +
    Result
    {:else} -
    Step completed (no result)
    +
    Step completed (no result)
    {/if} {:else if loadingJobs[k] && !childJobs[k]}
    @@ -293,7 +394,7 @@ {#if job.logs || isRunning}
    -
    Logs
    +
    Logs
    {#if isDone && job.result !== undefined}
    -
    Result
    +
    Result
    diff --git a/frontend/src/lib/components/flows/content/SuspendDrawer.svelte b/frontend/src/lib/components/flows/content/SuspendDrawer.svelte index ee22349cac..3ac2afe001 100644 --- a/frontend/src/lib/components/flows/content/SuspendDrawer.svelte +++ b/frontend/src/lib/components/flows/content/SuspendDrawer.svelte @@ -39,27 +39,9 @@ render a cancel button, providing the operator with an option to cancel the step. e.g: - {#snippet content()} - - -
    {/if} diff --git a/frontend/src/lib/components/scriptEditor/LogPanel.svelte b/frontend/src/lib/components/scriptEditor/LogPanel.svelte index 271e2a765c..82845c4a88 100644 --- a/frontend/src/lib/components/scriptEditor/LogPanel.svelte +++ b/frontend/src/lib/components/scriptEditor/LogPanel.svelte @@ -158,6 +158,7 @@ result={previewJob?.result} success={previewJob?.success !== false} autoExpandResult + jobId={previewJob?.id} />
    {:else} diff --git a/frontend/src/lib/script_helpers.ts b/frontend/src/lib/script_helpers.ts index d05d78f6ef..6865637ba7 100644 --- a/frontend/src/lib/script_helpers.ts +++ b/frontend/src/lib/script_helpers.ts @@ -1601,7 +1601,7 @@ export function canHaveApproval(language: SupportedLanguage | undefined): boolea return false } - return ['python3', 'bun', 'deno'].includes(language) + return ['python3', 'bun'].includes(language) } export function canHaveFailure(language: SupportedLanguage | undefined): boolean { diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index 5bf8f21ac5..5f7e426fe9 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -797,6 +797,7 @@ stepResults={getStepResults(job.workflow_as_code_status)} result={job.result} success={(job as any).success !== false} + jobId={job.id} />
    diff --git a/frontend/src/routes/approve/[workspace]/[job]/+page.svelte b/frontend/src/routes/approve/[workspace]/[job]/+page.svelte new file mode 100644 index 0000000000..6c9638a948 --- /dev/null +++ b/frontend/src/routes/approve/[workspace]/[job]/+page.svelte @@ -0,0 +1,359 @@ + + + + + + {#if error} +
    + {#if error.includes('logged in') || error.includes('sign in') || error.includes('Not authorized')} +
    + +

    Not Authorized

    +
    +

    {error}

    + + {:else if error.includes('Permission denied') || error.includes('Self-approval')} +
    + +

    Permission denied

    +
    +

    {error}

    + {:else} +
    + +

    Error

    +
    +

    {error}

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

    Approvers

    +
    + {#if approvalInfo.approvers?.length > 0} +
      + {#each approvalInfo.approvers as a} +
    • +

      + {a.approver} + Unique id of approval: {a.resume_id} +

      +
    • + {/each} +
    + {:else} +

    + No current approvers for this step (approval steps can require more than one approval) +

    + {/if} +
    +
    +
    + {#if job && job.raw_flow} + + {/if} +
    +
    + + {#if !completed} +

    + {isWac ? 'Workflow' : 'Flow'} arguments +

    + + {/if} + +
    + +
    + {#if completed} + + The flow is not running anymore. You cannot cancel or resume it. + + {/if} + + {#if approvalInfo.description != undefined} + + {/if} + + {#if hasForm && !completed} + {#if emptyString($enterpriseLicense)} + + {:else} + + {/if} + {/if} + + {#if !completed && approvalInfo.can_approve} +
    + {#if approvalInfo.hide_cancel !== true} + + {:else} +
    + {/if} + +
    + {:else if !completed && !approvalInfo.can_approve} + {#if approvalInfo.user_auth_required && !$userStore} + + {:else} +
    +

    You are not authorized to approve this flow.

    + {#if approvalInfo.approval_conditions?.self_approval_disabled && $userStore && $userStore.email === (job as any)?.email} +

    Self-approval is disabled for this step.

    + {/if} + {#if approvalInfo.approval_conditions?.user_groups_required?.length > 0} +

    Only members of the following groups can approve: {approvalInfo.approval_conditions.user_groups_required.join(', ')}

    + {/if} +
    + {/if} + {:else if completed} + + {/if} + + {#if !completed && isSelfApprovalBypass} +
    + + As an administrator, by resuming or cancelling this stage of the flow, you bypass the + self-approval interdiction. + +
    + {/if} +
    + + + + {#if job && job.raw_flow && !completed} +

    Flow details

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

    Loading...

    + {/if} +
    diff --git a/python-client/wmill/wmill/client.py b/python-client/wmill/wmill/client.py index cdd3342771..d952584e5a 100644 --- a/python-client/wmill/wmill/client.py +++ b/python-client/wmill/wmill/client.py @@ -2463,7 +2463,7 @@ class WorkflowCtx: ) async def _wait_for_approval( - self, timeout: int = 1800, form: dict | None = None + self, timeout: int = 1800, form: dict | None = None, self_approval: bool = True ): key = self._alloc_key("approval") @@ -2479,6 +2479,7 @@ class WorkflowCtx: "key": key, "timeout": timeout, "form": form, + "self_approval_disabled": not self_approval, "steps": [], }) @@ -2762,6 +2763,7 @@ async def sleep(seconds: int): async def wait_for_approval( timeout: int = 1800, form: dict | None = None, + self_approval: bool = True, ) -> dict: """Suspend the workflow and wait for an external approval. @@ -2770,6 +2772,11 @@ async def wait_for_approval( Returns a dict with ``value`` (form data), ``approver``, and ``approved``. + Args: + timeout: Approval timeout in seconds (default 1800). + form: Optional form schema for the approval page. + self_approval: Whether the user who triggered the flow can approve it (default True). + Example:: urls = await step("urls", lambda: get_resume_urls()) @@ -2778,7 +2785,7 @@ async def wait_for_approval( """ ctx: WorkflowCtx | None = _workflow_ctx.get(None) if ctx is not None: - return await ctx._wait_for_approval(timeout=timeout, form=form) + return await ctx._wait_for_approval(timeout=timeout, form=form, self_approval=self_approval) raise RuntimeError("wait_for_approval can only be called inside a @workflow") diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 0170d7edca..d30a5be3eb 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -632,7 +632,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. @@ -1336,12 +1336,17 @@ async def sleep(seconds: int) # # Returns a dict with \`\`value\`\` (form data), \`\`approver\`\`, and \`\`approved\`\`. # +# Args: +# timeout: Approval timeout in seconds (default 1800). +# form: Optional form schema for the approval page. +# self_approval: Whether the user who triggered the flow can approve it (default True). +# # Example:: # # urls = await step("urls", lambda: get_resume_urls()) # await step("notify", lambda: send_email(urls["approvalPage"])) # result = await wait_for_approval(timeout=3600) -async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict +async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict # Process items in parallel with optional concurrency control. # diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index ec776de97e..674c9986b9 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -1605,7 +1605,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. @@ -2309,12 +2309,17 @@ async def sleep(seconds: int) # # Returns a dict with ``value`` (form data), ``approver``, and ``approved``. # +# Args: +# timeout: Approval timeout in seconds (default 1800). +# form: Optional form schema for the approval page. +# self_approval: Whether the user who triggered the flow can approve it (default True). +# # Example:: # # urls = await step("urls", lambda: get_resume_urls()) # await step("notify", lambda: send_email(urls["approvalPage"])) # result = await wait_for_approval(timeout=3600) -async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict +async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict # Process items in parallel with optional concurrency control. # diff --git a/system_prompts/auto-generated/sdks/python.md b/system_prompts/auto-generated/sdks/python.md index 7163e76a4a..241d438f58 100644 --- a/system_prompts/auto-generated/sdks/python.md +++ b/system_prompts/auto-generated/sdks/python.md @@ -648,12 +648,17 @@ async def sleep(seconds: int) # # Returns a dict with ``value`` (form data), ``approver``, and ``approved``. # +# Args: +# timeout: Approval timeout in seconds (default 1800). +# form: Optional form schema for the approval page. +# self_approval: Whether the user who triggered the flow can approve it (default True). +# # Example:: # # urls = await step("urls", lambda: get_resume_urls()) # await step("notify", lambda: send_email(urls["approvalPage"])) # result = await wait_for_approval(timeout=3600) -async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict +async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict # Process items in parallel with optional concurrency control. # diff --git a/system_prompts/auto-generated/sdks/typescript.md b/system_prompts/auto-generated/sdks/typescript.md index f38ba274c1..8d96473313 100644 --- a/system_prompts/auto-generated/sdks/typescript.md +++ b/system_prompts/auto-generated/sdks/typescript.md @@ -481,7 +481,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. 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 ba40a2d624..b4db20ae80 100644 --- a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md @@ -610,7 +610,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. 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 cdd015863a..ecf7fe2103 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -608,7 +608,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. 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 fddae85f6e..563d01ed48 100644 --- a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md @@ -614,7 +614,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. diff --git a/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md b/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md index 4687be55e4..1d52290283 100644 --- a/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md @@ -575,7 +575,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. 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 c860ee696c..e6aa3b848c 100644 --- a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md @@ -783,12 +783,17 @@ async def sleep(seconds: int) # # Returns a dict with ``value`` (form data), ``approver``, and ``approved``. # +# Args: +# timeout: Approval timeout in seconds (default 1800). +# form: Optional form schema for the approval page. +# self_approval: Whether the user who triggered the flow can approve it (default True). +# # Example:: # # urls = await step("urls", lambda: get_resume_urls()) # await step("notify", lambda: send_email(urls["approvalPage"])) # result = await wait_for_approval(timeout=3600) -async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict +async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict # Process items in parallel with optional concurrency control. # diff --git a/typescript-client/client.ts b/typescript-client/client.ts index 49087a8b60..1aded4a720 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -1577,6 +1577,7 @@ export class WorkflowCtx { _waitForApproval(options?: { timeout?: number; form?: object; + selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> { const key = this._allocKey("approval"); @@ -1597,6 +1598,7 @@ export class WorkflowCtx { key, timeout: options?.timeout ?? 1800, form: options?.form, + self_approval_disabled: !(options?.selfApproval ?? true), steps: [], }); } @@ -1842,6 +1844,7 @@ export function workflow(fn: (...args: any[]) => Promise) { export function waitForApproval(options?: { timeout?: number; form?: object; + selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> { const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx"); if (!ctx) { From 6060ac3adc0afd94d62ec233f5d7282238d3ffc9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 24 Mar 2026 21:40:26 +0000 Subject: [PATCH 171/204] chore(main): release 1.664.0 (#8498) * chore(main): release 1.664.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 15 ++ backend/Cargo.lock | 175 +++++++++--------- backend/Cargo.toml | 4 +- 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 +- 15 files changed, 113 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8aa1a9648..e08a1cf181 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [1.664.0](https://github.com/windmill-labs/windmill/compare/v1.663.0...v1.664.0) (2026-03-24) + + +### Features + +* add instance-level AI settings ([#8453](https://github.com/windmill-labs/windmill/issues/8453)) ([db5e036](https://github.com/windmill-labs/windmill/commit/db5e03610da325288d53afdbca94b9cbfc7ceace)) +* add selfApproval option to WAC + inline approval buttons ([#8440](https://github.com/windmill-labs/windmill/issues/8440)) ([d578e40](https://github.com/windmill-labs/windmill/commit/d578e40101a838d3dffda14157cf72ee4d5a93c0)) +* flow group nodes with collapsible groups ([#8075](https://github.com/windmill-labs/windmill/issues/8075)) ([81eb446](https://github.com/windmill-labs/windmill/commit/81eb446eee359f44374b81320690e5345fd08c15)) + + +### Bug Fixes + +* add GIT_SSL_CAINFO to tracing proxy env vars ([#8502](https://github.com/windmill-labs/windmill/issues/8502)) ([bdfd5d5](https://github.com/windmill-labs/windmill/commit/bdfd5d57261a4bb760fc57ad41ee56aff9b9c0af)) +* create parent dirs and accept 'python' alias in script bootstrap ([#8497](https://github.com/windmill-labs/windmill/issues/8497)) ([7f27d99](https://github.com/windmill-labs/windmill/commit/7f27d996accb3c3b471d1c50df397867d89c738a)) + ## [1.663.0](https://github.com/windmill-labs/windmill/compare/v1.662.0...v1.663.0) (2026-03-24) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 30138085c9..1871fda815 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -7401,14 +7401,15 @@ dependencies = [ [[package]] name = "ipconfig" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d72a21f6a71a6c4c3160e095e8925861f5119dd26ef71acee1b9146f74f76c8" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ "socket2 0.6.3", "widestring", + "windows-registry", + "windows-result 0.4.1", "windows-sys 0.61.2", - "winreg", ] [[package]] @@ -8063,9 +8064,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "bitflags 2.9.4", "libc", @@ -15044,9 +15045,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" [[package]] name = "unicode-width" @@ -15746,7 +15747,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-nats", @@ -15822,7 +15823,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15835,7 +15836,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "argon2", @@ -15976,7 +15977,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15999,7 +16000,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16012,7 +16013,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16038,7 +16039,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.663.0" +version = "1.664.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16048,7 +16049,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16065,7 +16066,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -16088,7 +16089,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16111,7 +16112,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16127,7 +16128,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16147,7 +16148,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16167,7 +16168,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16181,7 +16182,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-nats", @@ -16209,7 +16210,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16234,7 +16235,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "flate2", @@ -16252,7 +16253,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16274,7 +16275,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16294,7 +16295,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16324,7 +16325,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16351,7 +16352,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.663.0" +version = "1.664.0" dependencies = [ "lazy_static", "serde", @@ -16363,7 +16364,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.663.0" +version = "1.664.0" dependencies = [ "argon2", "axum 0.7.9", @@ -16386,7 +16387,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16400,7 +16401,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16431,7 +16432,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.663.0" +version = "1.664.0" dependencies = [ "chrono", "lazy_static", @@ -16445,7 +16446,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16464,7 +16465,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.663.0" +version = "1.664.0" dependencies = [ "aes-gcm", "anyhow", @@ -16564,7 +16565,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.663.0" +version = "1.664.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16583,7 +16584,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.663.0" +version = "1.664.0" dependencies = [ "regex", "serde", @@ -16598,7 +16599,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16622,7 +16623,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "futures", @@ -16639,7 +16640,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.663.0" +version = "1.664.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16655,7 +16656,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -16676,7 +16677,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -16707,7 +16708,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-oauth2", @@ -16731,7 +16732,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-stream", @@ -16765,7 +16766,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "futures", @@ -16783,7 +16784,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.663.0" +version = "1.664.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16792,7 +16793,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "lazy_static", @@ -16804,7 +16805,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "serde_json", @@ -16816,7 +16817,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "gosyn", @@ -16828,7 +16829,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "lazy_static", @@ -16840,7 +16841,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "serde_json", @@ -16852,7 +16853,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "nu-parser", @@ -16863,7 +16864,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16874,7 +16875,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16886,7 +16887,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16897,7 +16898,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-recursion", @@ -16919,7 +16920,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "lazy_static", @@ -16933,7 +16934,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16950,7 +16951,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "lazy_static", @@ -16963,7 +16964,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "serde", @@ -16975,7 +16976,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "lazy_static", @@ -16993,7 +16994,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -17009,7 +17010,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17025,7 +17026,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "serde", @@ -17036,7 +17037,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-recursion", @@ -17073,7 +17074,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "const_format", @@ -17111,7 +17112,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.663.0" +version = "1.664.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17122,7 +17123,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-recursion", @@ -17151,7 +17152,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -17174,7 +17175,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17207,7 +17208,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17227,7 +17228,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17261,7 +17262,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17296,7 +17297,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17319,7 +17320,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17343,7 +17344,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-nats", @@ -17367,7 +17368,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17402,7 +17403,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17430,7 +17431,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17453,7 +17454,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17472,7 +17473,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-once-cell", @@ -17580,7 +17581,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.663.0" +version = "1.664.0" dependencies = [ "bytes", "futures", @@ -18193,16 +18194,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" -[[package]] -name = "winreg" -version = "0.55.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" -dependencies = [ - "cfg-if", - "windows-sys 0.59.0", -] - [[package]] name = "winsafe" version = "0.0.19" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 6dc821689c..3b44e3d87a 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.663.0" +version = "1.664.0" authors.workspace = true edition.workspace = true @@ -82,7 +82,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal"] [workspace.package] -version = "1.663.0" +version = "1.664.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 6576633319..993569e0f0 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.663.0 + version: 1.664.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index f93b61c6ee..e79e14588d 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.663.0"; +export const VERSION = "v1.664.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 83e9c1bbc8..8901d5f979 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -68,7 +68,7 @@ export { workspaceAdd, }; -export const VERSION = "1.663.0"; +export const VERSION = "1.664.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 792e303b6e..3cd78bf5a6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.663.0", + "version": "1.664.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.663.0", + "version": "1.664.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 39bb3b6365..19eb2b69b5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.663.0", + "version": "1.664.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 130d95a820..13775e4a5d 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.663.0" +wmill = ">=1.664.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 9a0983b663..7480b43a1e 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.663.0 + version: 1.664.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index c84a6d49f7..6629cc8034 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.663.0' + ModuleVersion = '1.664.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 92fbaf56d1..8cfc22d3a6 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.663.0" +version = "1.664.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 5435328c88..9632ec19a2 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.663.0", + "version": "1.664.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 bd66c04c51..d4023c7e69 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.663.0", + "version": "1.664.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index eb4feec596..694b27ca91 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.663.0 +1.664.0 From 85c52e2cded10606cc895d0d3b717e13c69bc9b3 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 06:40:20 +0000 Subject: [PATCH 172/204] fix: use /apps_raw/get/ redirect URL for raw apps set as workspace default (#8508) * fix: use /apps_raw/get/ redirect URL for raw apps set as workspace default Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update sqlx cache for default_app query Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- ...c3592deb61d1111d1430ddd2879b72e6424ef.json | 28 +++++++++++++++++++ ...3f3be67b6160cd258c86b8e8f22a6d601afd0.json | 22 --------------- .../windmill-api-workspaces/src/workspaces.rs | 19 +++++++++---- backend/windmill-api/openapi.yaml | 2 ++ frontend/src/lib/components/Login.svelte | 3 +- .../(logged)/user/(user)/login/+page.svelte | 3 +- .../user/(user)/workspaces/+page.svelte | 3 +- 7 files changed, 49 insertions(+), 31 deletions(-) create mode 100644 backend/.sqlx/query-1bc77ad29b9c68b1d339b85158bc3592deb61d1111d1430ddd2879b72e6424ef.json delete mode 100644 backend/.sqlx/query-ed1a053c7b22d9cb69767be40d33f3be67b6160cd258c86b8e8f22a6d601afd0.json diff --git a/backend/.sqlx/query-1bc77ad29b9c68b1d339b85158bc3592deb61d1111d1430ddd2879b72e6424ef.json b/backend/.sqlx/query-1bc77ad29b9c68b1d339b85158bc3592deb61d1111d1430ddd2879b72e6424ef.json new file mode 100644 index 0000000000..0930bdf1b8 --- /dev/null +++ b/backend/.sqlx/query-1bc77ad29b9c68b1d339b85158bc3592deb61d1111d1430ddd2879b72e6424ef.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT ws.default_app AS default_app_path, av.raw_app AS \"default_app_raw: Option\"\n FROM workspace_settings ws\n LEFT JOIN app ON app.path = ws.default_app AND app.workspace_id = ws.workspace_id\n LEFT JOIN app_version av ON av.id = app.versions[array_upper(app.versions, 1)]\n WHERE ws.workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "default_app_path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "default_app_raw: Option", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + false + ] + }, + "hash": "1bc77ad29b9c68b1d339b85158bc3592deb61d1111d1430ddd2879b72e6424ef" +} diff --git a/backend/.sqlx/query-ed1a053c7b22d9cb69767be40d33f3be67b6160cd258c86b8e8f22a6d601afd0.json b/backend/.sqlx/query-ed1a053c7b22d9cb69767be40d33f3be67b6160cd258c86b8e8f22a6d601afd0.json deleted file mode 100644 index b7c642ef12..0000000000 --- a/backend/.sqlx/query-ed1a053c7b22d9cb69767be40d33f3be67b6160cd258c86b8e8f22a6d601afd0.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT default_app FROM workspace_settings WHERE workspace_id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "default_app", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - true - ] - }, - "hash": "ed1a053c7b22d9cb69767be40d33f3be67b6160cd258c86b8e8f22a6d601afd0" -} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 8cd8f27d9b..55ad777cbf 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -2233,22 +2233,29 @@ async fn edit_default_app( #[derive(Serialize)] struct WorkspaceDefaultApp { pub default_app_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_app_raw: Option, } async fn get_default_app( Extension(db): Extension, Path(w_id): Path, ) -> JsonResult { - let mut tx = db.begin().await?; - let default_app_path = sqlx::query_scalar!( - "SELECT default_app FROM workspace_settings WHERE workspace_id = $1", + let row = sqlx::query!( + "SELECT ws.default_app AS default_app_path, av.raw_app AS \"default_app_raw: Option\" + FROM workspace_settings ws + LEFT JOIN app ON app.path = ws.default_app AND app.workspace_id = ws.workspace_id + LEFT JOIN app_version av ON av.id = app.versions[array_upper(app.versions, 1)] + WHERE ws.workspace_id = $1", &w_id ) - .fetch_one(&mut *tx) + .fetch_one(&db) .await .map_err(|err| Error::internal_err(format!("getting default_app: {err}")))?; - tx.commit().await?; - Ok(Json(WorkspaceDefaultApp { default_app_path })) + Ok(Json(WorkspaceDefaultApp { + default_app_path: row.default_app_path, + default_app_raw: row.default_app_raw, + })) } async fn edit_error_handler( diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 993569e0f0..bdd70229e9 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -3702,6 +3702,8 @@ paths: properties: default_app_path: type: string + default_app_raw: + type: boolean /w/{workspace}/workspaces/usage: get: diff --git a/frontend/src/lib/components/Login.svelte b/frontend/src/lib/components/Login.svelte index 1e375cb53d..c1a2a7ebdb 100644 --- a/frontend/src/lib/components/Login.svelte +++ b/frontend/src/lib/components/Login.svelte @@ -164,7 +164,8 @@ workspace: $workspaceStore! }) if (!emptyString(defaultApp.default_app_path)) { - goto(`/apps/get/${defaultApp.default_app_path}`) + const prefix = defaultApp.default_app_raw ? '/apps_raw/get' : '/apps/get' + goto(`${prefix}/${defaultApp.default_app_path}`) } else { goto(rd ?? '/') } diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte index 63843578c9..3e7ef57bf6 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte @@ -87,7 +87,8 @@ workspace: $workspaceStore! }) if (!emptyString(defaultApp.default_app_path)) { - goto(`/apps/get/${defaultApp.default_app_path}`) + const prefix = defaultApp.default_app_raw ? '/apps_raw/get' : '/apps/get' + goto(`${prefix}/${defaultApp.default_app_path}`) } else { goto(rd ?? '/') } diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte index 0ddfd92914..2cc2a65fbf 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte @@ -151,7 +151,8 @@ workspace: $workspaceStore! }) if (!emptyString(defaultApp.default_app_path)) { - await goto(`/apps/get/${defaultApp.default_app_path}`) + const prefix = defaultApp.default_app_raw ? '/apps_raw/get' : '/apps/get' + await goto(`${prefix}/${defaultApp.default_app_path}`) } else { if (rd?.startsWith('http')) { window.location.href = rd From 1341a1321da3ab7c5ce24df27fe6b028887d6a0b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 07:11:57 +0000 Subject: [PATCH 173/204] chore: update tantivy from 0.24 to 0.26 (#8510) * [ee] chore: update tantivy from 0.24 to 0.26 - Rebase windmill-labs/tantivy fork onto upstream 0.26 - Bump serde pin from 1.0.219 to 1.0.220 (required by tantivy 0.26's time dependency) Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to ec613f2db9e72e32e9131181546dcd679405a782 This commit updates the EE repository reference after PR #479 was merged in windmill-ee-private. Previous ee-repo-ref: 920cf601b0651b7ba94493668ea051e00f3e74bf New ee-repo-ref: ec613f2db9e72e32e9131181546dcd679405a782 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/Cargo.lock | 149 +++++++++++++++++++++++----------------- backend/Cargo.toml | 4 +- backend/ee-repo-ref.txt | 2 +- 3 files changed, 89 insertions(+), 66 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 1871fda815..fcb56bb81c 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -405,7 +405,7 @@ dependencies = [ "arrow-data", "arrow-schema", "flatbuffers", - "lz4_flex", + "lz4_flex 0.11.6", ] [[package]] @@ -2124,7 +2124,7 @@ dependencies = [ "num-traits", "num_cpus", "rand 0.9.0", - "rand_distr 0.5.1", + "rand_distr", "rayon", "safetensors", "thiserror 2.0.18", @@ -3640,6 +3640,12 @@ dependencies = [ "sqlparser 0.55.0", ] +[[package]] +name = "datasketches" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c286de4e81ea2590afc24d754e0f83810c566f50a1388fa75ebd57928c0d9745" + [[package]] name = "debug-helper" version = "0.3.13" @@ -5678,7 +5684,7 @@ dependencies = [ "half", "num-traits", "rand 0.9.0", - "rand_distr 0.5.1", + "rand_distr", ] [[package]] @@ -6490,7 +6496,7 @@ dependencies = [ "crunchy", "num-traits", "rand 0.9.0", - "rand_distr 0.5.1", + "rand_distr", "zerocopy", ] @@ -7109,15 +7115,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "hyperloglogplus" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "621debdf94dcac33e50475fdd76d34d5ea9c0362a834b9db08c3024696c1fbe3" -dependencies = [ - "serde", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -8197,6 +8194,15 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.0", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -8221,6 +8227,12 @@ dependencies = [ "twox-hash 2.1.2", ] +[[package]] +name = "lz4_flex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db9a0d582c2874f68138a16ce1867e0ffde6c0bb0a0df85e1f36d04146db488a" + [[package]] name = "lzma-sys" version = "0.1.20" @@ -9186,9 +9198,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-format" @@ -9713,6 +9725,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-float" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +dependencies = [ + "num-traits", +] + [[package]] name = "os_pipe" version = "1.1.5" @@ -9738,7 +9759,7 @@ checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" [[package]] name = "ownedbytes" version = "0.9.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "stable_deref_trait", ] @@ -9850,7 +9871,7 @@ dependencies = [ "futures", "half", "hashbrown 0.15.5", - "lz4_flex", + "lz4_flex 0.11.6", "num", "num-bigint", "object_store", @@ -10842,16 +10863,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_distr" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" -dependencies = [ - "num-traits", - "rand 0.8.5", -] - [[package]] name = "rand_distr" version = "0.5.1" @@ -12191,10 +12202,11 @@ checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.220" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "ceecad4c782e936ac90ecfd6b56532322e3262b14320abf30ce89a92ffdbfe22" dependencies = [ + "serde_core", "serde_derive", ] @@ -12216,7 +12228,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" dependencies = [ - "ordered-float", + "ordered-float 2.10.1", "serde", ] @@ -12241,10 +12253,19 @@ dependencies = [ ] [[package]] -name = "serde_derive" -version = "1.0.219" +name = "serde_core" +version = "1.0.220" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "ddba47394f3b862d6ff6efdbd26ca4673e3566a307880a0ffb98f274bbe0ec32" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.220" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60e1f3b1761e96def5ec6d04a6e7421c0404fa3cf5c0155f1e2848fae3d8cc08" dependencies = [ "proc-macro2", "quote", @@ -12608,9 +12629,9 @@ checksum = "1b6709c7b6754dca1311b3c73e79fcce40dd414c782c66d88e8823030093b02b" [[package]] name = "sketches-ddsketch" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" +checksum = "05e40b6cf54d988dc1a2223531b969c9a9e30906ad90ef64890c27b4bfbb46ea" dependencies = [ "serde", ] @@ -13705,8 +13726,8 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tantivy" -version = "0.24.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.26.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "aho-corasick", "arc-swap", @@ -13717,17 +13738,17 @@ dependencies = [ "census", "crc32fast", "crossbeam-channel", + "datasketches", "downcast-rs", "fastdivide", "fnv", "fs4", "htmlescape", - "hyperloglogplus", "itertools 0.14.0", "levenshtein_automata", "log", - "lru 0.12.5", - "lz4_flex", + "lru 0.16.3", + "lz4_flex 0.13.0", "measure_time", "memmap2 0.9.10", "once_cell", @@ -13750,22 +13771,23 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "time", + "typetag", "uuid", "winapi", ] [[package]] name = "tantivy-bitpacker" -version = "0.8.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.9.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "bitpacking", ] [[package]] name = "tantivy-columnar" -version = "0.5.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.6.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "downcast-rs", "fastdivide", @@ -13779,8 +13801,8 @@ dependencies = [ [[package]] name = "tantivy-common" -version = "0.9.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.10.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "async-trait", "byteorder", @@ -13802,18 +13824,20 @@ dependencies = [ [[package]] name = "tantivy-query-grammar" -version = "0.24.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.25.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ + "fnv", "nom 7.1.3", + "ordered-float 5.1.0", "serde", "serde_json", ] [[package]] name = "tantivy-sstable" -version = "0.5.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.6.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "futures-util", "itertools 0.14.0", @@ -13825,18 +13849,17 @@ dependencies = [ [[package]] name = "tantivy-stacker" -version = "0.5.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.6.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "murmurhash32", - "rand_distr 0.4.3", "tantivy-common", ] [[package]] name = "tantivy-tokenizer-api" -version = "0.5.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.6.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "serde", ] @@ -13966,7 +13989,7 @@ checksum = "7e54bc85fc7faa8bc175c4bab5b92ba8d9a3ce893d0e9f42cc455c8ab16a9e09" dependencies = [ "byteorder", "integer-encoding", - "ordered-float", + "ordered-float 2.10.1", ] [[package]] @@ -14043,30 +14066,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 3b44e3d87a..927a6f6ba3 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -373,7 +373,7 @@ tower = "^0" tower-http = { version = "^0.6", features = ["trace", "cors", "catch-panic"] } tower-cookies = "^0.10" #stuck because of swc for now -serde = "=1.0.219" +serde = "=1.0.220" serde_json = { version = "^1", features = ["preserve_order", "raw_value"] } serde_yml = "0.0.12" uuid = { version = "^1", features = ["serde", "v4", "js"] } @@ -587,7 +587,7 @@ tikv-jemalloc-ctl = { version = "^0.5" } triomphe = "^0" pin-project-lite = "^0" -tantivy = { git="https://github.com/windmill-labs/tantivy", rev="6a24621231202ccd77bec90d8787e2281fb94e4e" } +tantivy = { git="https://github.com/windmill-labs/tantivy", rev="6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" } backon = "1.3.0" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 7ef2ef46db..c5ca6a15cf 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -faeaa43bbe2ba4804f80b828b85fd4d6daef096c +ec613f2db9e72e32e9131181546dcd679405a782 From fe223bffa32c17815988ff4210d89f4f01d486e2 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 07:34:24 +0000 Subject: [PATCH 174/204] chore: update samael from 0.0.14 to 0.0.20 (#8512) Co-authored-by: Claude Opus 4.6 (1M context) --- backend/Cargo.lock | 105 +++++++++++++++++++-------------------------- backend/Cargo.toml | 2 +- 2 files changed, 46 insertions(+), 61 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index fcb56bb81c..af95c7a85e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1594,29 +1594,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bindgen" -version = "0.69.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" -dependencies = [ - "bitflags 2.9.4", - "cexpr", - "clang-sys", - "itertools 0.12.1", - "lazy_static", - "lazycell", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 1.1.0", - "shlex", - "syn 2.0.117", - "which 4.4.2", -] - [[package]] name = "bindgen" version = "0.70.1" @@ -1667,6 +1644,8 @@ dependencies = [ "cexpr", "clang-sys", "itertools 0.13.0", + "log", + "prettyplease", "proc-macro2", "quote", "regex", @@ -4862,7 +4841,16 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" dependencies = [ - "derive_builder_macro", + "derive_builder_macro 0.12.0", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro 0.20.2", ] [[package]] @@ -4877,16 +4865,38 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_builder_macro" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" dependencies = [ - "derive_builder_core", + "derive_builder_core 0.12.0", "syn 1.0.109", ] +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core 0.20.2", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "1.0.0" @@ -7476,15 +7486,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -7900,12 +7901,6 @@ dependencies = [ "spin 0.9.8", ] -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -9349,7 +9344,7 @@ dependencies = [ "md-5 0.10.6", "parking_lot", "percent-encoding", - "quick-xml 0.37.5", + "quick-xml", "rand 0.9.0", "reqwest 0.12.28", "ring 0.17.14", @@ -10646,16 +10641,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "quick-xml" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "quick-xml" version = "0.37.5" @@ -11950,15 +11935,15 @@ dependencies = [ [[package]] name = "samael" -version = "0.0.14" +version = "0.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75583aad4a51c50fc0af69c230d18078c9d5a69a98d0f6013d01053acf744f4" +checksum = "8b010d88b2c7b2c3fc9e49f6fffa086d4c350ec50538a8082f88e446ea16c670" dependencies = [ - "base64 0.21.7", - "bindgen 0.69.5", + "base64 0.22.1", + "bindgen 0.72.1", "chrono", "data-encoding", - "derive_builder", + "derive_builder 0.20.2", "flate2", "lazy_static", "libc", @@ -11967,10 +11952,10 @@ dependencies = [ "openssl-probe 0.1.6", "openssl-sys", "pkg-config", - "quick-xml 0.30.0", - "rand 0.8.5", + "quick-xml", + "rand 0.9.0", "serde", - "thiserror 1.0.69", + "thiserror 2.0.18", "url", "uuid", ] @@ -14152,7 +14137,7 @@ checksum = "d9be88c795d8b9f9c4002b3a8f26a6d0876103a6f523b32ea3bac52d8560c17c" dependencies = [ "aho-corasick", "clap", - "derive_builder", + "derive_builder 0.12.0", "esaxx-rs", "getrandom 0.2.17", "indicatif", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 927a6f6ba3..e98d4b793e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -510,7 +510,7 @@ native-tls = ">=0.2, <0.2.17" # samael will break compilation on MacOS. Use this fork instead to make it work # samael = { git="https://github.com/njaremko/samael", rev="464d015e3ae393e4b5dd00b4d6baa1b617de0dd6", features = ["xmlsec"] } libxml = { version = "=0.3.3" } -samael = { version="0.0.14", features = ["xmlsec"] } +samael = { version="0.0.20", features = ["xmlsec"] } gcp_auth = "0.9.0" rust_decimal = { version = "^1", features = ["db-postgres", "serde-float"]} jsonwebtoken = "8.3.0" From 0db21aa6b7c5b557ad53a1a74493c4fd80a53b49 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 07:44:49 +0000 Subject: [PATCH 175/204] samael bump --- backend/Cargo.lock | 3 +-- backend/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index af95c7a85e..a5e2e8fc98 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -11936,8 +11936,7 @@ dependencies = [ [[package]] name = "samael" version = "0.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b010d88b2c7b2c3fc9e49f6fffa086d4c350ec50538a8082f88e446ea16c670" +source = "git+https://github.com/njaremko/samael?rev=f879f1942ec1b34b6d3027ce7e4724ad95d15dfa#f879f1942ec1b34b6d3027ce7e4724ad95d15dfa" dependencies = [ "base64 0.22.1", "bindgen 0.72.1", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e98d4b793e..e1d6dbe9d2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -510,7 +510,7 @@ native-tls = ">=0.2, <0.2.17" # samael will break compilation on MacOS. Use this fork instead to make it work # samael = { git="https://github.com/njaremko/samael", rev="464d015e3ae393e4b5dd00b4d6baa1b617de0dd6", features = ["xmlsec"] } libxml = { version = "=0.3.3" } -samael = { version="0.0.20", features = ["xmlsec"] } +samael = { git="https://github.com/njaremko/samael", rev="f879f1942ec1b34b6d3027ce7e4724ad95d15dfa", features = ["xmlsec"] } gcp_auth = "0.9.0" rust_decimal = { version = "^1", features = ["db-postgres", "serde-float"]} jsonwebtoken = "8.3.0" From e3620e074e1bdb46b2b8d732f35a91d300589663 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 07:56:45 +0000 Subject: [PATCH 176/204] fix: serve index disk storage sizes from /srch/ endpoint (#8511) * [ee] fix: serve index disk storage sizes from /srch/ endpoint On multi-container deployments, the API server doesn't have the index files on its local disk, so disk size was always reported as 0.0B. Added a new GET /srch/index/storage/disk endpoint that calculates disk sizes on the indexer process (which owns the files). The frontend now fetches disk sizes from this endpoint in parallel with the status call. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 71aab648925f31cde37efd31d79a7f3a977fd42a This commit updates the EE repository reference after PR #480 was merged in windmill-ee-private. Previous ee-repo-ref: b3e0000e2528809302c18f36930aebf3d004747a New ee-repo-ref: 71aab648925f31cde37efd31d79a7f3a977fd42a Automated by sync-ee-ref workflow. * chore: update ee-repo-ref to indexer-disk-storage-zero branch Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update sqlx metadata and ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...3e69e4ef8821c6cbf3b4f296b3853d95692af.json | 22 +++++++++ ...ed5c8c64daec45ba820a6c8f7d7ab76cc40c9.json | 28 ----------- ...08cb1ca21fbdba3373af54fadf1f4af324073.json | 35 -------------- ...59f29220c08e94e1822e3bcb5c6ed6aa7d2bb.json | 46 ------------------- ...6e8a4f8f3a9bf04238b33e9caf46836df73d9.json | 35 -------------- ...91688f3ed0efd3a43e81f4ea296255248092c.json | 16 ------- ...32e97ebefb46be9e58bd3da9067748075311b.json | 35 -------------- ...8903bec93ef79a71053c00227e17c6f0415a2.json | 23 ---------- backend/ee-repo-ref.txt | 2 +- backend/windmill-api/openapi.yaml | 21 +++++++++ .../IndexerMemorySettings.svelte | 29 ++++++++---- 11 files changed, 64 insertions(+), 228 deletions(-) create mode 100644 backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json delete mode 100644 backend/.sqlx/query-32ca7941db013dacd2479962fa9ed5c8c64daec45ba820a6c8f7d7ab76cc40c9.json delete mode 100644 backend/.sqlx/query-79b82ae996fba2e2ab53fcf84c108cb1ca21fbdba3373af54fadf1f4af324073.json delete mode 100644 backend/.sqlx/query-950f364c9fa3c680eea895558a559f29220c08e94e1822e3bcb5c6ed6aa7d2bb.json delete mode 100644 backend/.sqlx/query-98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9.json delete mode 100644 backend/.sqlx/query-b1979a8249557d29e9055fde06191688f3ed0efd3a43e81f4ea296255248092c.json delete mode 100644 backend/.sqlx/query-c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b.json delete mode 100644 backend/.sqlx/query-ecf67b08d327c351909b7ba80218903bec93ef79a71053c00227e17c6f0415a2.json diff --git a/backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json b/backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json new file mode 100644 index 0000000000..a78e67067f --- /dev/null +++ b/backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af" +} diff --git a/backend/.sqlx/query-32ca7941db013dacd2479962fa9ed5c8c64daec45ba820a6c8f7d7ab76cc40c9.json b/backend/.sqlx/query-32ca7941db013dacd2479962fa9ed5c8c64daec45ba820a6c8f7d7ab76cc40c9.json deleted file mode 100644 index 0a2976f868..0000000000 --- a/backend/.sqlx/query-32ca7941db013dacd2479962fa9ed5c8c64daec45ba820a6c8f7d7ab76cc40c9.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT parent_job, flow_step_id FROM v2_job WHERE id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "parent_job", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "flow_step_id", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - true, - true - ] - }, - "hash": "32ca7941db013dacd2479962fa9ed5c8c64daec45ba820a6c8f7d7ab76cc40c9" -} diff --git a/backend/.sqlx/query-79b82ae996fba2e2ab53fcf84c108cb1ca21fbdba3373af54fadf1f4af324073.json b/backend/.sqlx/query-79b82ae996fba2e2ab53fcf84c108cb1ca21fbdba3373af54fadf1f4af324073.json deleted file mode 100644 index f990932367..0000000000 --- a/backend/.sqlx/query-79b82ae996fba2e2ab53fcf84c108cb1ca21fbdba3373af54fadf1f4af324073.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH dk AS (\n INSERT INTO debounce_key (job_id, key)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n previous_job_id = debounce_key.job_id,\n job_id = EXCLUDED.job_id,\n debounced_times = debounce_key.debounced_times + 1\n RETURNING\n debounced_times,\n first_started_at,\n previous_job_id AS job_id_to_debounce\n ), _batch AS (\n INSERT INTO v2_job_debounce_batch (id, debounce_batch)\n SELECT\n $1,\n COALESCE(\n (SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = dk.job_id_to_debounce LIMIT 1),\n nextval('debounce_batch_seq')\n )\n FROM dk\n )\n SELECT debounced_times, first_started_at, job_id_to_debounce FROM dk\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "debounced_times", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "first_started_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 2, - "name": "job_id_to_debounce", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Varchar" - ] - }, - "nullable": [ - false, - false, - true - ] - }, - "hash": "79b82ae996fba2e2ab53fcf84c108cb1ca21fbdba3373af54fadf1f4af324073" -} diff --git a/backend/.sqlx/query-950f364c9fa3c680eea895558a559f29220c08e94e1822e3bcb5c6ed6aa7d2bb.json b/backend/.sqlx/query-950f364c9fa3c680eea895558a559f29220c08e94e1822e3bcb5c6ed6aa7d2bb.json deleted file mode 100644 index 9ec97dbc82..0000000000 --- a/backend/.sqlx/query-950f364c9fa3c680eea895558a559f29220c08e94e1822e3bcb5c6ed6aa7d2bb.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH RECURSIVE chain AS (\n SELECT\n j.id,\n j.parent_job,\n j.flow_step_id,\n 1 AS depth\n FROM v2_job j\n WHERE j.id = $1\n UNION ALL\n SELECT\n pj.id,\n pj.parent_job,\n pj.flow_step_id,\n c.depth + 1\n FROM chain c\n JOIN v2_job pj ON pj.id = c.parent_job\n WHERE c.parent_job IS NOT NULL\n )\n SELECT\n c.id,\n c.parent_job,\n c.flow_step_id,\n EXISTS(SELECT 1 FROM v2_job_queue q WHERE q.id = c.parent_job) AS \"parent_in_queue!\",\n EXISTS(\n SELECT 1 FROM v2_job sib\n WHERE sib.parent_job = c.parent_job\n AND sib.id != c.id\n AND sib.id IN (SELECT sq.id FROM v2_job_queue sq)\n ) AS \"has_other_active_siblings!\"\n FROM chain c\n WHERE c.depth >= 1\n ORDER BY c.depth ASC\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "parent_job", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "flow_step_id", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "parent_in_queue!", - "type_info": "Bool" - }, - { - "ordinal": 4, - "name": "has_other_active_siblings!", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - null, - null, - null, - null, - null - ] - }, - "hash": "950f364c9fa3c680eea895558a559f29220c08e94e1822e3bcb5c6ed6aa7d2bb" -} diff --git a/backend/.sqlx/query-98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9.json b/backend/.sqlx/query-98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9.json deleted file mode 100644 index 7dd6e9ac5d..0000000000 --- a/backend/.sqlx/query-98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH dk AS (\n INSERT INTO debounce_key (job_id, key)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n previous_job_id = debounce_key.job_id,\n job_id = EXCLUDED.job_id,\n debounced_times = debounce_key.debounced_times + 1\n RETURNING\n debounced_times,\n first_started_at,\n previous_job_id AS job_id_to_debounce\n ), _batch AS (\n INSERT INTO v2_job_debounce_batch (id, debounce_batch)\n SELECT\n $1,\n COALESCE(\n (SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = dk.job_id_to_debounce LIMIT 1),\n nextval('debounce_batch_seq')\n )\n FROM dk\n )\n SELECT debounced_times, first_started_at, job_id_to_debounce FROM dk\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "debounced_times", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "first_started_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 2, - "name": "job_id_to_debounce", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Varchar" - ] - }, - "nullable": [ - false, - false, - true - ] - }, - "hash": "98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9" -} diff --git a/backend/.sqlx/query-b1979a8249557d29e9055fde06191688f3ed0efd3a43e81f4ea296255248092c.json b/backend/.sqlx/query-b1979a8249557d29e9055fde06191688f3ed0efd3a43e81f4ea296255248092c.json deleted file mode 100644 index 5fb12bed16..0000000000 --- a/backend/.sqlx/query-b1979a8249557d29e9055fde06191688f3ed0efd3a43e81f4ea296255248092c.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE v2_job_status SET flow_status = (\n SELECT jsonb_set(\n flow_status,\n ARRAY['modules', (idx - 1)::text],\n $2::jsonb\n )\n FROM jsonb_array_elements(flow_status->'modules')\n WITH ORDINALITY arr(elem, idx)\n WHERE elem->>'id' = $3\n LIMIT 1\n ) WHERE id = $1 AND (\n SELECT COUNT(*) FROM jsonb_array_elements(flow_status->'modules')\n WITH ORDINALITY arr(elem, idx)\n WHERE elem->>'id' = $3\n ) > 0", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Jsonb", - "Text" - ] - }, - "nullable": [] - }, - "hash": "b1979a8249557d29e9055fde06191688f3ed0efd3a43e81f4ea296255248092c" -} diff --git a/backend/.sqlx/query-c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b.json b/backend/.sqlx/query-c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b.json deleted file mode 100644 index 7d7842d7f4..0000000000 --- a/backend/.sqlx/query-c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH dk AS (\n INSERT INTO debounce_key (job_id, key)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n previous_job_id = debounce_key.job_id,\n job_id = EXCLUDED.job_id,\n debounced_times = debounce_key.debounced_times + 1\n RETURNING\n debounced_times,\n first_started_at,\n previous_job_id AS job_id_to_debounce\n ), _batch AS (\n INSERT INTO v2_job_debounce_batch (id, debounce_batch)\n SELECT\n $1,\n COALESCE(\n (SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = dk.job_id_to_debounce LIMIT 1),\n nextval('debounce_batch_seq')\n )\n FROM dk\n )\n SELECT debounced_times, first_started_at, job_id_to_debounce FROM dk\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "debounced_times", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "first_started_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 2, - "name": "job_id_to_debounce", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Varchar" - ] - }, - "nullable": [ - false, - false, - true - ] - }, - "hash": "c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b" -} diff --git a/backend/.sqlx/query-ecf67b08d327c351909b7ba80218903bec93ef79a71053c00227e17c6f0415a2.json b/backend/.sqlx/query-ecf67b08d327c351909b7ba80218903bec93ef79a71053c00227e17c6f0415a2.json deleted file mode 100644 index c43a2bcd30..0000000000 --- a/backend/.sqlx/query-ecf67b08d327c351909b7ba80218903bec93ef79a71053c00227e17c6f0415a2.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT EXISTS(\n SELECT 1 FROM v2_job\n WHERE parent_job = $1 AND id != $2\n AND id IN (SELECT id FROM v2_job_queue)\n ) as has", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "has", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Uuid" - ] - }, - "nullable": [ - null - ] - }, - "hash": "ecf67b08d327c351909b7ba80218903bec93ef79a71053c00227e17c6f0415a2" -} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index c5ca6a15cf..40b16c0b62 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -ec613f2db9e72e32e9131181546dcd679405a782 +414202845a45e2a7c6a2d3e154bd8dfd0273cc14 \ No newline at end of file diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index bdd70229e9..5f350f2ce5 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -17429,6 +17429,27 @@ paths: description: count of log lines that matched the query per hostname type: object + /srch/index/storage/disk: + get: + summary: Get index disk storage sizes from the indexer. + operationId: getIndexDiskStorageSizes + tags: + - indexSearch + responses: + "200": + description: disk storage sizes for each index + content: + application/json: + schema: + type: object + properties: + job_index_disk_size_bytes: + type: integer + nullable: true + log_index_disk_size_bytes: + type: integer + nullable: true + /indexer/delete/{idx_name}: delete: summary: Clear an index and restart the indexer. diff --git a/frontend/src/lib/components/instanceSettings/IndexerMemorySettings.svelte b/frontend/src/lib/components/instanceSettings/IndexerMemorySettings.svelte index 52bc3cd9bc..eb37ac8a0c 100644 --- a/frontend/src/lib/components/instanceSettings/IndexerMemorySettings.svelte +++ b/frontend/src/lib/components/instanceSettings/IndexerMemorySettings.svelte @@ -2,7 +2,7 @@ import { Button } from '$lib/components/common' import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte' import { IndexSearchService } from '$lib/gen' - import type { GetIndexerStatusResponse } from '$lib/gen' + import type { GetIndexerStatusResponse, GetIndexDiskStorageSizesResponse } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { displaySize } from '$lib/utils' import Tooltip from '../Tooltip.svelte' @@ -24,6 +24,7 @@ let clearServiceLogsIndexModalOpen = $state(false) let status: GetIndexerStatusResponse | undefined = $state(undefined) + let diskSizes: GetIndexDiskStorageSizesResponse | undefined = $state(undefined) let statusLoading = $state(true) let statusError = $state(false) @@ -41,9 +42,15 @@ statusLoading = true statusError = false try { - status = await IndexSearchService.getIndexerStatus() + const [statusRes, diskRes] = await Promise.all([ + IndexSearchService.getIndexerStatus(), + IndexSearchService.getIndexDiskStorageSizes().catch(() => undefined) + ]) + status = statusRes + diskSizes = diskRes } catch (e) { status = undefined + diskSizes = undefined statusError = true } finally { statusLoading = false @@ -139,7 +146,11 @@ : 'bg-red-500'}" > {label}: - + {entry?.is_alive ? 'Running' : 'Stopped'} {#if entry?.last_locked_at} @@ -161,21 +172,21 @@
    Jobs index: - {#if status.job_indexer?.storage?.disk_size_bytes != null} - Disk: {displaySize(status.job_indexer.storage.disk_size_bytes) ?? 'N/A'} + {#if diskSizes?.job_index_disk_size_bytes != null} + Disk: {displaySize(diskSizes.job_index_disk_size_bytes) ?? 'N/A'} {/if} {#if status.job_indexer?.storage?.s3_size_bytes != null} - {#if status.job_indexer?.storage?.disk_size_bytes != null}·{/if} + {#if diskSizes?.job_index_disk_size_bytes != null}·{/if} S3: {displaySize(status.job_indexer.storage.s3_size_bytes) ?? 'N/A'} {/if} Service logs index: - {#if status.log_indexer?.storage?.disk_size_bytes != null} - Disk: {displaySize(status.log_indexer.storage.disk_size_bytes) ?? 'N/A'} + {#if diskSizes?.log_index_disk_size_bytes != null} + Disk: {displaySize(diskSizes.log_index_disk_size_bytes) ?? 'N/A'} {/if} {#if status.log_indexer?.storage?.s3_size_bytes != null} - {#if status.log_indexer?.storage?.disk_size_bytes != null}·{/if} + {#if diskSizes?.log_index_disk_size_bytes != null}·{/if} S3: {displaySize(status.log_indexer.storage.s3_size_bytes) ?? 'N/A'} {/if} From 79d2bd51a00654162754046308d7670242120df6 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 08:41:29 +0000 Subject: [PATCH 177/204] feat: move basic git sync from EE to CE with runtime user count gating (#8493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: move basic git sync from EE to CE with runtime user count gating Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt for git sync CE migration Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: keep git sync impl in private repo, revert oss to stub Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt after merge Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use LICENSE_KEY check instead of get_license_plan for runtime gating Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * fix: improve git sync CE UX — use "Community Edition" wording, mention user limit Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use "workspace members" instead of "users" in git sync messaging Co-Authored-By: Claude Opus 4.6 (1M context) * fix: lower CE git sync limit from 3 to 2 workspace members Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * fix: simplify git sync CE alerts to warn about EE feature with member limit Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add EE feature restrictions detail to CE git sync warning Co-Authored-By: Claude Opus 4.6 (1M context) * fix: show git sync settings even when >2 members, with disabled warning Co-Authored-By: Claude Opus 4.6 (1M context) * fix: show error alert when git sync settings exist but members exceed CE limit Co-Authored-By: Claude Opus 4.6 (1M context) * fix: mention CE git sync limit is for testing and hobbyist use Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 79eeacccc0438010d7dfa60207a5cbdaf2eda08d This commit updates the EE repository reference after PR #476 was merged in windmill-ee-private. Previous ee-repo-ref: c4d69c6e700c16d44f909d9c7b6738b07043db98 New ee-repo-ref: 79eeacccc0438010d7dfa60207a5cbdaf2eda08d Automated by sync-ee-ref workflow. * chore: update sqlx cache Co-Authored-By: Claude Opus 4.6 (1M context) * chore: regenerate full sqlx cache after main merge Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update sqlx cache Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref and regenerate sqlx cache with private feature Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use LICENSE_KEY_VALID for EE check, allow delete without access check, extract helpers Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: use compile-time cfg(enterprise) gating instead of runtime license checks Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 6171a91da38d6d16a88aeb1a3a4f4df78f995383 This commit updates the EE repository reference after PR #481 was merged in windmill-ee-private. Previous ee-repo-ref: 52681940cda6d70f65aeeb7144288f060b4d736e New ee-repo-ref: 6171a91da38d6d16a88aeb1a3a4f4df78f995383 Automated by sync-ee-ref workflow. * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to b5c8af4df9ba2c39fdd494d7a40f9a92fbff8abc This commit updates the EE repository reference after PR #482 was merged in windmill-ee-private. Previous ee-repo-ref: 6e5b2741831468a7b30b26c0df1241e6141c6833 New ee-repo-ref: b5c8af4df9ba2c39fdd494d7a40f9a92fbff8abc Automated by sync-ee-ref workflow. * fix: gate CE_GIT_SYNC_MAX_USERS behind cfg(not(enterprise)) Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...aacf6af2c284ae446860113c82bc4e1da08ab.json | 12 - ...bc47caebc25215a430d6b301b35e265888159.json | 12 - ...fb7cf5f2b76f013c274245af13d7d727ebf1f.json | 12 - ...e2e60e3183fa81a411622891caea6dc03fa90.json | 15 -- ...960ffc33da5f31bf780e8fd6a66d5150b8027.json | 12 - ...69c87a9d29370ec985d2c8c28633cd078ffaf.json | 12 - ...74da8c73120b3e16194904575f79a4e055002.json | 12 - ...437ab3e02d8c3c10c53decc664533b8d04bc0.json | 22 -- backend/ee-repo-ref.txt | 2 +- .../windmill-api-workspaces/src/workspaces.rs | 131 +++++++--- backend/windmill-api/openapi.yaml | 31 +++ backend/windmill-common/src/ee_oss.rs | 1 + backend/windmill-git-sync/Cargo.toml | 2 +- backend/windmill-git-sync/src/lib.rs | 20 +- cli/package-lock.json | 14 +- .../git_sync/GitSyncContext.svelte.ts | 11 + .../components/git_sync/GitSyncSection.svelte | 245 +++++++++++------- 17 files changed, 298 insertions(+), 268 deletions(-) delete mode 100644 backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json delete mode 100644 backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json delete mode 100644 backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json delete mode 100644 backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json delete mode 100644 backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json delete mode 100644 backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json delete mode 100644 backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json delete mode 100644 backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json diff --git a/backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json b/backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json deleted file mode 100644 index 0ad1fe4367..0000000000 --- a/backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO app_version (id, app_id, value, created_by, created_at)\n VALUES (3001, 3001, '{\"grid\": []}', 'admin', NOW())", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab" -} diff --git a/backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json b/backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json deleted file mode 100644 index 24d3c8929a..0000000000 --- a/backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE app SET versions = ARRAY[3001::bigint] WHERE id = 3001", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159" -} diff --git a/backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json b/backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json deleted file mode 100644 index 10cab9117a..0000000000 --- a/backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, schema, extra_perms)\n VALUES ('test-workspace', 'u/operator/existing_flow', 'Existing flow', '', '{\"modules\": []}', 'admin', NOW(), '{}', '{}')", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f" -} diff --git a/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json b/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json deleted file mode 100644 index 27d46b27ed..0000000000 --- a/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token (token_hash, token_prefix, email, label, super_admin, owner, workspace_id)\n VALUES ($1, $2, 'charlie@windmill.dev', 'Charlie new token', false, 'u/charlie', 'test-workspace')", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90" -} diff --git a/backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json b/backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json deleted file mode 100644 index 8e558fe67b..0000000000 --- a/backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO app (id, workspace_id, path, summary, versions, policy, extra_perms)\n VALUES (3001, 'test-workspace', 'u/operator/existing_app', 'Existing app', '{}',\n '{\"on_behalf_of\": \"u/admin\", \"on_behalf_of_email\": \"admin@windmill.dev\", \"execution_mode\": \"viewer\"}', '{}')", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027" -} diff --git a/backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json b/backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json deleted file mode 100644 index 6da123cbc4..0000000000 --- a/backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO usr_to_group (workspace_id, group_, usr) VALUES ('test-workspace', 'editors', 'charlie')", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf" -} diff --git a/backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json b/backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json deleted file mode 100644 index d7cc49fe3f..0000000000 --- a/backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, extra_perms)\n VALUES ('test-workspace', 3001, 'u/operator/existing_script', 'export function main() { return \"original\"; }', 'deno', 'script', 'admin', '{}', 'Existing script', '', '', '{}')", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002" -} diff --git a/backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json b/backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json deleted file mode 100644 index d9b7688eba..0000000000 --- a/backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO kafka_trigger (\n path, kafka_resource_path, topics, group_id,\n script_path, is_flow, workspace_id, edited_by, permissioned_as\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "VarcharArray", - "Varchar", - "Varchar", - "Bool", - "Varchar", - "Varchar", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0" -} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 40b16c0b62..7d86a6114e 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -414202845a45e2a7c6a2d3e154bd8dfd0273cc14 \ No newline at end of file +b5c8af4df9ba2c39fdd494d7a40f9a92fbff8abc diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 55ad777cbf..fb234a13a3 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -35,7 +35,6 @@ use windmill_common::variables::{ build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE, }; use windmill_common::worker::{to_raw_value, CLOUD_HOSTED}; -#[cfg(feature = "enterprise")] use windmill_common::workspaces::GitRepositorySettings; #[cfg(feature = "enterprise")] use windmill_common::workspaces::WorkspaceDeploymentUISettings; @@ -115,6 +114,7 @@ pub fn workspaced_service() -> Router { .route("/list_datatables", get(list_datatables)) .route("/list_datatable_schemas", get(list_datatable_schemas)) .route("/edit_datatable_config", post(edit_datatable_config)) + .route("/git_sync_enabled", get(get_git_sync_enabled)) .route("/edit_git_sync_config", post(edit_git_sync_config)) .route("/edit_git_sync_repository", post(edit_git_sync_repository)) .route( @@ -1595,24 +1595,20 @@ async fn edit_datatable_config( #[derive(Deserialize)] pub struct EditGitSyncConfig { - #[cfg(feature = "enterprise")] pub git_sync_settings: Option, } -#[cfg(feature = "enterprise")] #[derive(Deserialize, Debug)] pub struct EditGitSyncRepository { pub git_repo_resource_path: String, pub repository: GitRepositorySettings, } -#[cfg(feature = "enterprise")] #[derive(Deserialize, Debug)] pub struct DeleteGitSyncRepositoryRequest { pub git_repo_resource_path: String, } -#[cfg(feature = "enterprise")] fn validate_git_repo_resource_path(path: &str) -> Result<()> { // Resource paths should follow the pattern: $res:f// or $res:u// if path.is_empty() { @@ -1661,7 +1657,6 @@ fn validate_git_repo_resource_path(path: &str) -> Result<()> { Ok(()) } -#[cfg(feature = "enterprise")] fn cleanup_legacy_git_sync_settings_in_memory( git_sync_settings: &mut windmill_common::workspaces::WorkspaceGitSyncSettings, workspace_id: &str, @@ -1688,18 +1683,72 @@ fn cleanup_legacy_git_sync_settings_in_memory( } #[cfg(not(feature = "enterprise"))] -async fn edit_git_sync_config( - _authed: ApiAuthed, - Extension(_db): Extension, - Path(_w_id): Path, - Json(_new_config): Json, -) -> Result { - return Err(Error::BadRequest( - "Git sync is only available on Windmill Enterprise Edition".to_string(), - )); +const CE_GIT_SYNC_MAX_USERS: i64 = 2; + +#[cfg(feature = "enterprise")] +async fn check_git_sync_access(_db: &DB, _w_id: &str) -> Result<()> { + Ok(()) +} + +#[cfg(not(feature = "enterprise"))] +async fn check_git_sync_access(db: &DB, w_id: &str) -> Result<()> { + let user_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false", + w_id + ) + .fetch_one(db) + .await? + .unwrap_or(0); + + if user_count > CE_GIT_SYNC_MAX_USERS { + return Err(Error::BadRequest(format!( + "Git sync is available for workspaces with up to {} members. \ + Upgrade to Windmill Enterprise Edition for unlimited workspace members.", + CE_GIT_SYNC_MAX_USERS + ))); + } + Ok(()) } #[cfg(feature = "enterprise")] +async fn get_git_sync_enabled( + _authed: ApiAuthed, + Extension(_db): Extension, + Path(_w_id): Path, +) -> JsonResult { + Ok(Json(serde_json::json!({ + "enabled": true, + "reason": "enterprise", + "max_repos": null, + "user_count": null, + "max_users": null, + }))) +} + +#[cfg(not(feature = "enterprise"))] +async fn get_git_sync_enabled( + _authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> JsonResult { + let user_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false", + &w_id + ) + .fetch_one(&db) + .await? + .unwrap_or(0); + + let enabled = user_count <= CE_GIT_SYNC_MAX_USERS; + Ok(Json(serde_json::json!({ + "enabled": enabled, + "reason": if enabled { Some("free_tier") } else { None::<&str> }, + "max_repos": if enabled { Some(1) } else { None:: }, + "user_count": user_count, + "max_users": CE_GIT_SYNC_MAX_USERS, + }))) +} + async fn edit_git_sync_config( authed: ApiAuthed, Extension(db): Extension, @@ -1708,6 +1757,7 @@ async fn edit_git_sync_config( Json(new_config): Json, ) -> Result { require_admin(is_admin, &username)?; + check_git_sync_access(&db, &w_id).await?; let mut tx = db.begin().await?; @@ -1764,19 +1814,6 @@ async fn edit_git_sync_config( Ok(format!("Edit git sync config for workspace {}", &w_id)) } -#[cfg(not(feature = "enterprise"))] -async fn edit_git_sync_repository( - _authed: ApiAuthed, - Extension(_db): Extension, - Path(_w_id): Path, - Json(_new_config): Json, -) -> Result { - return Err(Error::BadRequest( - "Git sync is only available on Windmill Enterprise Edition".to_string(), - )); -} - -#[cfg(feature = "enterprise")] async fn edit_git_sync_repository( authed: ApiAuthed, Extension(db): Extension, @@ -1785,10 +1822,19 @@ async fn edit_git_sync_repository( Json(new_config): Json, ) -> Result { require_admin(is_admin, &username)?; + check_git_sync_access(&db, &w_id).await?; // Validate the resource path format validate_git_repo_resource_path(&new_config.git_repo_resource_path)?; + // Promotion mode: EE only + #[cfg(not(feature = "enterprise"))] + if new_config.repository.use_individual_branch.unwrap_or(false) { + return Err(Error::BadRequest( + "Promotion mode is an Enterprise Edition feature".to_string(), + )); + } + let mut tx = db.begin().await?; // First, get the current git sync settings @@ -1810,6 +1856,20 @@ async fn edit_git_sync_repository( WorkspaceGitSyncSettings::default() }; + // Multi-repo: EE only + #[cfg(not(feature = "enterprise"))] + { + let is_new = !git_sync_settings + .repositories + .iter() + .any(|r| r.git_repo_resource_path == new_config.git_repo_resource_path); + if is_new && !git_sync_settings.repositories.is_empty() { + return Err(Error::BadRequest( + "Multiple git sync repositories is an Enterprise Edition feature".to_string(), + )); + } + } + // Audit log before we move the repository audit_log( &mut *tx, @@ -1893,19 +1953,6 @@ async fn edit_git_sync_repository( )) } -#[cfg(not(feature = "enterprise"))] -async fn delete_git_sync_repository( - _authed: ApiAuthed, - Extension(_db): Extension, - Path(_w_id): Path, - Json(_request): Json, -) -> Result { - return Err(Error::BadRequest( - "Git sync is only available on Windmill Enterprise Edition".to_string(), - )); -} - -#[cfg(feature = "enterprise")] async fn delete_git_sync_repository( authed: ApiAuthed, Extension(db): Extension, @@ -1915,7 +1962,7 @@ async fn delete_git_sync_repository( ) -> Result { require_admin(is_admin, &username)?; - // For deletion, only validate that path is not empty to allow cleanup of malformed entries + // No check_git_sync_access here — admins should always be able to delete/clean up repos if request.git_repo_resource_path.is_empty() { return Err(Error::BadRequest( "Resource path cannot be empty".to_string(), diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 5f350f2ce5..a8f83c368a 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -3430,6 +3430,37 @@ paths: application/json: schema: {} + /w/{workspace}/workspaces/git_sync_enabled: + get: + summary: Check if git sync is available for this workspace + operationId: getGitSyncEnabled + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: Git sync availability status + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + reason: + type: string + nullable: true + max_repos: + type: integer + nullable: true + user_count: + type: integer + nullable: true + max_users: + type: integer + nullable: true + /w/{workspace}/workspaces/edit_git_sync_config: post: summary: edit workspace git sync settings diff --git a/backend/windmill-common/src/ee_oss.rs b/backend/windmill-common/src/ee_oss.rs index 51b1efd2e2..93d0061ade 100644 --- a/backend/windmill-common/src/ee_oss.rs +++ b/backend/windmill-common/src/ee_oss.rs @@ -23,6 +23,7 @@ lazy_static::lazy_static! { } #[cfg(not(feature = "private"))] +#[derive(PartialEq, Eq)] pub enum LicensePlan { Community, Pro, diff --git a/backend/windmill-git-sync/Cargo.toml b/backend/windmill-git-sync/Cargo.toml index 063e1ce54a..148746dcca 100644 --- a/backend/windmill-git-sync/Cargo.toml +++ b/backend/windmill-git-sync/Cargo.toml @@ -9,7 +9,7 @@ name = "windmill_git_sync" path = "./src/lib.rs" [features] -private = [] +private = ["windmill-common/private"] enterprise = ["windmill-queue/enterprise", "windmill-common/enterprise"] all_sqlx_features = ["enterprise"] default = [] diff --git a/backend/windmill-git-sync/src/lib.rs b/backend/windmill-git-sync/src/lib.rs index f9cecd46ce..dcbcd5bcb2 100644 --- a/backend/windmill-git-sync/src/lib.rs +++ b/backend/windmill-git-sync/src/lib.rs @@ -272,7 +272,10 @@ mod tests { path: "f/folder/script".to_string(), parent_path: Some("f/folder/old_script".to_string()), }; - assert_eq!(obj.get_parent_path(), Some("f/folder/old_script".to_string())); + assert_eq!( + obj.get_parent_path(), + Some("f/folder/old_script".to_string()) + ); } #[test] @@ -313,21 +316,13 @@ mod tests { #[test] fn test_get_kind_flow() { - let obj = DeployedObject::Flow { - path: "test".to_string(), - parent_path: None, - version: 1, - }; + let obj = DeployedObject::Flow { path: "test".to_string(), parent_path: None, version: 1 }; assert_eq!(obj.get_kind(), "flow"); } #[test] fn test_get_kind_app() { - let obj = DeployedObject::App { - path: "test".to_string(), - version: 1, - parent_path: None, - }; + let obj = DeployedObject::App { path: "test".to_string(), version: 1, parent_path: None }; assert_eq!(obj.get_kind(), "app"); } @@ -346,7 +341,8 @@ mod tests { "http_trigger" ); assert_eq!( - DeployedObject::WebsocketTrigger { path: "t".to_string(), parent_path: None }.get_kind(), + DeployedObject::WebsocketTrigger { path: "t".to_string(), parent_path: None } + .get_kind(), "websocket_trigger" ); assert_eq!( diff --git a/cli/package-lock.json b/cli/package-lock.json index 0e86b9d2b7..ae46c240c9 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -25,10 +25,11 @@ "windmill-parser-wasm-nu": "*", "windmill-parser-wasm-php": "*", "windmill-parser-wasm-py": "*", + "windmill-parser-wasm-py-imports": "*", "windmill-parser-wasm-regex": "*", "windmill-parser-wasm-ruby": "*", "windmill-parser-wasm-rust": "*", - "windmill-parser-wasm-ts": "*", + "windmill-parser-wasm-ts": "^1.659.1", "windmill-parser-wasm-yaml": "*", "windmill-yaml-validator": "1.1.1", "ws": "8.18.0", @@ -1414,6 +1415,11 @@ "resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.628.3.tgz", "integrity": "sha512-TlluqknZpg8cZ+A3m6JFLPseY2PpKtDsxdj26fAnCUzKPtse8TxQR+n0dwC80rfW5TwdWSulvNGRDgcNuf7CTw==" }, + "node_modules/windmill-parser-wasm-py-imports": { + "version": "1.659.1", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-py-imports/-/windmill-parser-wasm-py-imports-1.659.1.tgz", + "integrity": "sha512-nfnf04WBRf8f/mNIwdvggYOgz3erxrFGjKqULYBH+bKFMlKA6V7eB19m6CXOBkq9rjTp0ZFG+rgsR+Us7JEkyQ==" + }, "node_modules/windmill-parser-wasm-regex": { "version": "1.639.0", "resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.639.0.tgz", @@ -1430,9 +1436,9 @@ "integrity": "sha512-9yGLYZX2Hn9TdTqGY/5Fp50ftzgUsrfBkSK9vJkKJd5Amyg+yXLBGzd8pz6Org+4uxMenz/16wpsgijvo6uhhQ==" }, "node_modules/windmill-parser-wasm-ts": { - "version": "1.647.1", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.647.1.tgz", - "integrity": "sha512-64iSAUMU5W/WtePqE1vtDvglDqtkiZVndyieYBVDX0nl7UuovS+wPgH/P3TEoKbR+FwAPacki0CX3DsEzZ/Yxw==" + "version": "1.659.1", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.659.1.tgz", + "integrity": "sha512-EmXMzOmazC5r29UZh+1TVF9g/N2X51pqK11qDL6xWGeWTIIonhfOZ5nWdGvKQMDUR650fGxehImZzW2v9hNy+w==" }, "node_modules/windmill-parser-wasm-yaml": { "version": "1.593.0", diff --git a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts index 834982c3f3..ba919fa16b 100644 --- a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts +++ b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts @@ -1,4 +1,7 @@ import { getContext, setContext } from 'svelte' +import { enterpriseLicense } from '$lib/stores' +import { get } from 'svelte/store' +import { sendUserToast } from '$lib/toast' import { JobService, WorkspaceService, ResourceService } from '$lib/gen' import type { GitRepositorySettings as BackendGitRepositorySettings, @@ -646,6 +649,10 @@ export function createGitSyncContext(workspace: string) { } function addSyncRepository() { + if (!get(enterpriseLicense) && repositories && repositories.length >= 1) { + sendUserToast('Multiple repositories requires Enterprise Edition', true) + return + } repositories.push({ git_repo_resource_path: '', script_path: undefined, @@ -669,6 +676,10 @@ export function createGitSyncContext(workspace: string) { } function addPromotionRepository() { + if (!get(enterpriseLicense)) { + sendUserToast('Promotion mode requires Enterprise Edition', true) + return + } repositories.push({ git_repo_resource_path: '', script_path: undefined, diff --git a/frontend/src/lib/components/git_sync/GitSyncSection.svelte b/frontend/src/lib/components/git_sync/GitSyncSection.svelte index 893e782b19..9a03e43b27 100644 --- a/frontend/src/lib/components/git_sync/GitSyncSection.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncSection.svelte @@ -6,12 +6,46 @@ import GitSyncRepositoryCard from './GitSyncRepositoryCard.svelte' import GitSyncModalManager from './GitSyncModalManager.svelte' import { enterpriseLicense, workspaceStore } from '$lib/stores' + import { WorkspaceService } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { untrack } from 'svelte' // Create context reactively based on workspaceStore const gitSyncContext = $derived($workspaceStore ? setGitSyncContext($workspaceStore) : null) + // Fetch git sync eligibility + let gitSyncStatus = $state<{ + enabled: boolean + reason: string | null + max_repos: number | null + user_count: number | null + max_users: number | null + }>({ enabled: false, reason: null, max_repos: null, user_count: null, max_users: null }) + + $effect(() => { + if ($workspaceStore) { + WorkspaceService.getGitSyncEnabled({ workspace: $workspaceStore }) + .then((status) => { + gitSyncStatus = status as typeof gitSyncStatus + }) + .catch(() => { + gitSyncStatus = { + enabled: false, + reason: null, + max_repos: null, + user_count: null, + max_users: null + } + }) + } + }) + + const gitSyncAllowed = $derived(gitSyncStatus.enabled) + const isFreeTier = $derived(gitSyncAllowed && !$enterpriseLicense) + const hasConfiguredRepos = $derived( + gitSyncContext?.repositories?.some((r) => r.git_repo_resource_path) ?? false + ) + // Load settings when workspace context changes $effect(() => { if (gitSyncContext) { @@ -58,7 +92,7 @@ link="https://www.windmill.dev/docs/advanced/git_sync" > {#snippet actions()} - {#if $enterpriseLicense && gitSyncContext.repositories != undefined} + {#if (gitSyncAllowed || gitSyncStatus.user_count != null) && gitSyncContext?.repositories != undefined} - - {#if secondarySyncExpanded} -
    - {#if secondarySync.length === 0} -
    - No secondary sync repositories configured -
    - {:else} - {#each secondarySync as { repo, idx } (repo.git_repo_resource_path)} -
    - -
    - {/each} - {/if} - - {#if !hasUnsavedSecondary} -
    - -
    - {/if} -
    - {/if} -
    - {:else} - - {#if !hasUnsavedSecondary} -
    - -
    - {/if} - {/if} - {/if} - - -
    - gitSyncContext.addPromotionRepository()} - isCollapsible={false} - showEmptyState={primaryPromotion?.repo === null} - /> - - - {#if primaryPromotion && !primaryPromotion.repo?.isUnsavedConnection} - {#if secondaryPromotion.length > 0 || secondaryPromotionExpanded} + {#if $enterpriseLicense} + + {#if primarySync && !primarySync.repo?.isUnsavedConnection} + {#if secondarySync.length > 0 || secondarySyncExpanded}
    - {#if secondaryPromotionExpanded} + {#if secondarySyncExpanded}
    - {#if secondaryPromotion.length === 0} + {#if secondarySync.length === 0}
    - No secondary promotion repositories configured + No secondary sync repositories configured
    {:else} - {#each secondaryPromotion as { repo, idx } (repo.git_repo_resource_path)} + {#each secondarySync as { repo, idx } (repo.git_repo_resource_path)}
    {/each} {/if} - {#if !hasUnsavedSecondaryPromotion} + {#if !hasUnsavedSecondary}
    {/if} @@ -216,23 +187,99 @@ {/if}
    {:else} - - {#if !hasUnsavedSecondaryPromotion} + + {#if !hasUnsavedSecondary}
    {/if} {/if} {/if} -
    + + +
    + gitSyncContext.addPromotionRepository()} + isCollapsible={false} + showEmptyState={primaryPromotion?.repo === null} + /> + + + {#if primaryPromotion && !primaryPromotion.repo?.isUnsavedConnection} + {#if secondaryPromotion.length > 0 || secondaryPromotionExpanded} +
    + + + {#if secondaryPromotionExpanded} +
    + {#if secondaryPromotion.length === 0} +
    + No secondary promotion repositories configured +
    + {:else} + {#each secondaryPromotion as { repo, idx } (repo.git_repo_resource_path)} +
    + +
    + {/each} + {/if} + + {#if !hasUnsavedSecondaryPromotion} +
    + +
    + {/if} +
    + {/if} +
    + {:else} + + {#if !hasUnsavedSecondaryPromotion} +
    + +
    + {/if} + {/if} + {/if} +
    + {/if}
    From 10c5c97d3723dc317ed0a30098d248d91777d01a Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 08:48:05 +0000 Subject: [PATCH 178/204] nit frontend --- frontend/src/lib/components/git_sync/GitSyncSection.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/components/git_sync/GitSyncSection.svelte b/frontend/src/lib/components/git_sync/GitSyncSection.svelte index 9a03e43b27..8041c734da 100644 --- a/frontend/src/lib/components/git_sync/GitSyncSection.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncSection.svelte @@ -120,7 +120,7 @@ {:else if isFreeTier}
    - + Git sync is an EE feature provided in CE for testing and hobbyist use when workspace members ≤ {gitSyncStatus.max_users}. Limited to a single repository. Upgrade to EE for multiple repositories, promotion mode, and GitHub App authentication. From 60804a96c630087958e3dc8b8ea0c87cbb690bce Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:50:12 +0100 Subject: [PATCH 179/204] refactor: unify eval pipeline with production chat code path (#8504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: unify eval pipeline with production chat code path Extract a shared headless runChatLoop() that both AIChatManager (production) and the eval runner use, with injectable SDK clients. Drop OpenRouter — evals now use direct provider APIs (OpenAI SDK, Anthropic SDK) with streaming, matching production behavior. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: re-read tools/helpers/systemMessage/model on each loop iteration The old chatRequest() re-read this.tools, this.helpers, this.systemMessage, and getCurrentModel() on every iteration. This matters because changeModeTool (Navigator → Script/Flow) reassigns all of these mid-loop. Use JS getters in the config object so runChatLoop picks up changes each iteration. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../copilot/chat/AIChatManager.svelte.ts | 163 ++++--------- .../chat/__tests__/app/appChat.eval.test.ts | 165 +++++++++---- .../chat/__tests__/app/appEvalComparison.ts | 31 +-- .../chat/__tests__/app/appEvalRunner.ts | 39 ++-- .../chat/__tests__/flow/flowChat.eval.test.ts | 151 ++++++++---- .../chat/__tests__/flow/flowEvalComparison.ts | 6 +- .../chat/__tests__/flow/flowEvalRunner.ts | 39 ++-- .../chat/__tests__/shared/baseEvalRunner.ts | 216 ++++++++---------- .../chat/__tests__/shared/baseLLMEvaluator.ts | 37 +-- .../copilot/chat/__tests__/shared/types.ts | 3 + .../lib/components/copilot/chat/anthropic.ts | 24 +- .../lib/components/copilot/chat/chatLoop.ts | 211 +++++++++++++++++ .../copilot/chat/openai-responses.ts | 23 +- .../src/lib/components/copilot/chat/shared.ts | 6 +- frontend/src/lib/components/copilot/lib.ts | 26 ++- 15 files changed, 743 insertions(+), 397 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/chatLoop.ts diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 00a1a11fdf..140bbf4f14 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -23,8 +23,7 @@ import { } from './shared' import type { ChatCompletionMessageParam, - ChatCompletionSystemMessageParam, - ChatCompletionUserMessageParam + ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs' import { prepareInlineChatSystemPrompt, @@ -37,7 +36,7 @@ import { loadApiTools } from './api/apiTools' import { prepareScriptUserMessage } from './script/core' import { prepareNavigatorUserMessage } from './navigator/core' import { sendUserToast } from '$lib/toast' -import { getCompletion, getModelContextWindow, parseOpenAICompletion } from '../lib' +import { getModelContextWindow, workspaceAIClients } from '../lib' import { dfs } from '$lib/components/flows/previousResults' import { getStringError } from './utils' import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState' @@ -56,8 +55,7 @@ import type { import type { Selection } from 'monaco-editor' import type AIChatInput from './AIChatInput.svelte' import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core' -import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic' -import { getOpenAIResponsesCompletion, parseOpenAIResponsesCompletion } from './openai-responses' +import { runChatLoop } from './chatLoop' import type { ReviewChangesOpts } from './monaco-adapter' import { getCurrentModel, tryGetCurrentModel, getCombinedCustomPrompt } from '$lib/aiStore' @@ -413,130 +411,63 @@ class AIChatManager { systemMessage?: ChatCompletionSystemMessageParam }) => { try { - let addedMessages: ChatCompletionMessageParam[] = [] - while (true) { - const systemMessage = systemMessageOverride ?? this.systemMessage - const helpers = this.helpers - const tools = this.tools - for (const tool of tools) { - if (tool.setSchema) { - await tool.setSchema(helpers) - } - } - - let pendingPrompt = this.pendingPrompt - let pendingUserMessage: ChatCompletionUserMessageParam | undefined = undefined - if (pendingPrompt) { + // Use JS getters so runChatLoop re-reads tools/helpers/systemMessage/modelProvider + // on each iteration. This is critical for changeModeTool (Navigator → Script/Flow) + // which reassigns this.tools, this.helpers, this.systemMessage mid-loop. + const self = this + const result = await runChatLoop({ + messages, + get systemMessage() { + return systemMessageOverride ?? self.systemMessage + }, + get tools() { + return self.tools + }, + get helpers() { + return self.helpers + }, + abortController, + callbacks, + get modelProvider() { + return getCurrentModel() + }, + clients: { + openai: workspaceAIClients.getOpenaiClient(), + anthropic: workspaceAIClients.getAnthropicClient() + }, + workspace: get(workspaceStore) ?? '', + skipResponsesApi: this.skipResponsesApi, + onSkipResponsesApi: () => { + this.skipResponsesApi = true + }, + getPendingUserMessage: () => { + const pendingPrompt = this.pendingPrompt + if (!pendingPrompt) return undefined + this.pendingPrompt = '' if (this.mode === AIMode.SCRIPT) { - pendingUserMessage = prepareScriptUserMessage( + return prepareScriptUserMessage( pendingPrompt, this.contextManager.getSelectedContext() ) } else if (this.mode === AIMode.FLOW) { - pendingUserMessage = prepareFlowUserMessage( + return prepareFlowUserMessage( pendingPrompt, this.flowAiChatHelpers!.getFlowAndSelectedId() ) } else if (this.mode === AIMode.NAVIGATOR) { - pendingUserMessage = prepareNavigatorUserMessage(pendingPrompt) + return prepareNavigatorUserMessage(pendingPrompt) } - this.pendingPrompt = '' - } - - const model = getCurrentModel() - const isOpenAI = model.provider === 'openai' || model.provider === 'azure_openai' - const isAnthropic = model.provider === 'anthropic' - - const messageParams = [ - systemMessage, - ...messages, - ...(pendingUserMessage ? [pendingUserMessage] : []) - ] - const toolDefs = tools.map((t) => t.def) - - // For OpenAI/Azure, try Responses API first, fallback to Completions API - if (isOpenAI) { - let useCompletionsApi = this.skipResponsesApi - if (!this.skipResponsesApi) { - try { - const completion = await getOpenAIResponsesCompletion( - messageParams, - abortController, - toolDefs - ) - const continueCompletion = await parseOpenAIResponsesCompletion( - completion, - callbacks, - messages, - addedMessages, - tools, - helpers - ) - if (!continueCompletion) { - break - } - } catch (err) { - console.warn('OpenAI Responses API failed, falling back to Completions API:', err) - // If the error indicates Responses API is not available in this region, skip it for future requests - const errorMessage = err instanceof Error ? err.message : String(err) - if (errorMessage.includes('Responses API is not enabled')) { - this.skipResponsesApi = true - } - useCompletionsApi = true - } - } - - // Use Completions API if Responses API is not available or failed - if (useCompletionsApi) { - const completion = await getCompletion(messageParams, abortController, toolDefs, { - forceCompletions: true - }) - const continueCompletion = await parseOpenAICompletion( - completion, - callbacks, - messages, - addedMessages, - tools, - helpers - ) - if (!continueCompletion) { - break - } - } - } else if (isAnthropic) { - const completion = await getAnthropicCompletion(messageParams, abortController, toolDefs) - if (completion) { - const continueCompletion = await parseAnthropicCompletion( - completion, - callbacks, - messages, - addedMessages, - tools, - helpers, - abortController - ) - if (!continueCompletion) { - break - } - } - } else { - const completion = await getCompletion(messageParams, abortController, toolDefs) - if (completion) { - const continueCompletion = await parseOpenAICompletion( - completion, - callbacks, - messages, - addedMessages, - tools, - helpers - ) - if (!continueCompletion) { - break + return undefined + }, + onBeforeIteration: async (tools) => { + for (const tool of tools) { + if (tool.setSchema) { + await tool.setSchema(this.helpers) } } } - } - return addedMessages + }) + return result.addedMessages } catch (err) { console.log('chatRequest error', err) console.error('chatRequest error', err) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/appChat.eval.test.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/appChat.eval.test.ts index 5183377caf..a42ee1f099 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/app/appChat.eval.test.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/appChat.eval.test.ts @@ -6,44 +6,77 @@ import { loadAppFixtureForEval } from './appFixtureLoader' import { dirname, join } from 'path' // @ts-ignore - Node.js url import { fileURLToPath } from 'url' +import type { AIProvider } from '$lib/gen/types.gen' -// Get API key from environment - tests will be skipped if not set +// Get API keys from environment - tests will be skipped if none are set // @ts-ignore -const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY +const OPENAI_API_KEY = process.env.OPENAI_API_KEY +// @ts-ignore +const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY -// Skip all tests if no API key is provided -const describeWithApiKey = OPENROUTER_API_KEY ? describe : describe.skip +const hasAnyKey = OPENAI_API_KEY || ANTHROPIC_API_KEY +const describeWithApiKey = hasAnyKey ? describe : describe.skip // Get __dirname equivalent for ES modules const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) -const MODELS = ['google/gemini-2.5-flash', 'anthropic/claude-haiku-4.5', 'openai/gpt-4o'] +// Build model variants based on available keys +interface ModelVariant { + model: string + provider: AIProvider + apiKey: string +} + +const MODEL_VARIANTS: ModelVariant[] = [ + ...(OPENAI_API_KEY + ? [{ model: 'gpt-4o', provider: 'openai' as AIProvider, apiKey: OPENAI_API_KEY }] + : []), + ...(ANTHROPIC_API_KEY + ? [ + { + model: 'claude-haiku-4-5-20241022', + provider: 'anthropic' as AIProvider, + apiKey: ANTHROPIC_API_KEY + } + ] + : []) +] + const VARIANTS = [ - ...MODELS.map((model) => ({ + ...MODEL_VARIANTS.map((mv) => ({ ...BASELINE_VARIANT, - model, - name: `baseline-${model.replace('/', '-')}` + model: mv.model, + name: `baseline-${mv.provider}-${mv.model}`, + _provider: mv.provider, + _apiKey: mv.apiKey })), - ...MODELS.map((model) => ({ + ...MODEL_VARIANTS.map((mv) => ({ ...STREAMLINED_VARIANT, - model, - name: `streamlined-${model.replace('/', '-')}` + model: mv.model, + name: `streamlined-${mv.provider}-${mv.model}`, + _provider: mv.provider, + _apiKey: mv.apiKey })) ] describeWithApiKey('App Chat LLM Evaluation', () => { const TEST_TIMEOUT = 120_000 - if (!OPENROUTER_API_KEY) { - console.warn('OPENROUTER_API_KEY is not set, skipping tests') + if (!hasAnyKey) { + console.warn('No API keys set (OPENAI_API_KEY or ANTHROPIC_API_KEY), skipping tests') } it( 'test1: creates a simple counter app', async () => { const USER_PROMPT = `Create a counter app with increment/decrement buttons` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!) - // Write results to files + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + undefined, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) console.log(`App files: ${appPaths.join(', ')}`) @@ -56,17 +89,21 @@ describeWithApiKey('App Chat LLM Evaluation', () => { it( 'test2: modifies existing counter app to add reset button', async () => { - // Load initial app from fixture folder const { initialFrontend, initialBackend } = await loadAppFixtureForEval( join(__dirname, 'initial', 'test1_counter_app') ) const USER_PROMPT = `Add a reset button that sets the counter back to 0` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) - // Write results to files + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) console.log(`App files: ${appPaths.join(', ')}`) @@ -86,10 +123,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => { ) const USER_PROMPT = `Add a quantity selector (+ and - buttons) to each cart item so users can adjust quantities without removing and re-adding items` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -108,10 +151,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => { ) const USER_PROMPT = `Add a discount code input field in the cart. When the code "SAVE10" is entered, apply a 10% discount to the total` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -132,10 +181,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => { ) const USER_PROMPT = `Add a search bar in the toolbar that filters files and folders by name as the user types` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -154,10 +209,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => { ) const USER_PROMPT = `Show file size (formatted as KB/MB) and modified date in the file list for each item` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -176,10 +237,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => { ) const USER_PROMPT = `Add a "Select All" checkbox in the file list header and individual checkboxes for each file. Add a "Delete Selected" button that appears when items are selected` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -196,7 +263,13 @@ describeWithApiKey('App Chat LLM Evaluation', () => { 'test8: create quiz app from scratch', async () => { const USER_PROMPT = `Create a multiple choice quiz app with 5 questions about general knowledge. Show one question at a time with 4 answer options. Track the score and show results at the end with percentage correct.` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + undefined, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -211,7 +284,13 @@ describeWithApiKey('App Chat LLM Evaluation', () => { 'test9: create recipe book from scratch', async () => { const USER_PROMPT = `Create a recipe book app where users can add recipes with a name, ingredients list, and instructions. Include a search bar to filter recipes by name and the ability to delete recipes.` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + undefined, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalComparison.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalComparison.ts index 456299c142..e6c795d445 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalComparison.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalComparison.ts @@ -1,4 +1,4 @@ -import OpenAI from 'openai' +import Anthropic from '@anthropic-ai/sdk' import type { AppFiles, BackendRunnable } from '../../app/core' import { BASE_EVALUATOR_RESPONSE_FORMAT } from '../shared' import type { EvaluationResult } from '../shared' @@ -71,12 +71,7 @@ ${BASE_EVALUATOR_RESPONSE_FORMAT}` /** * Evaluates how well a generated app fulfills the user's request, considering any initial app state. - * This evaluator does not require an expected reference app - it evaluates based on the request alone. - * - * @param userPrompt The original user request - * @param generatedApp The app generated by the AI - * @param initialApp Optional initial app state (what the app looked like before AI changes) - * @returns Evaluation result with score, statement, and missing requirements + * Uses Anthropic API directly. */ export async function evaluateAppGeneration( userPrompt: string, @@ -84,9 +79,17 @@ export async function evaluateAppGeneration( initialApp?: InitialApp ): Promise { // @ts-ignore - const apiKey = process.env.OPENROUTER_API_KEY + const apiKey = process.env.ANTHROPIC_API_KEY + if (!apiKey) { + return { + success: false, + resemblanceScore: 0, + statement: 'No API key available for evaluation', + error: 'ANTHROPIC_API_KEY not set' + } + } - const client = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey }) + const client = new Anthropic({ apiKey }) let userMessage = `## User's Original Request ${userPrompt} @@ -117,16 +120,18 @@ Please evaluate how well the generated app: 2. ${initialApp ? 'Makes appropriate modifications to the initial app state' : 'Implements a complete and correct new app'}` try { - const response = await client.chat.completions.create({ - model: 'anthropic/claude-sonnet-4.5', + const response = await client.messages.create({ + model: 'claude-sonnet-4-5-20250514', + max_tokens: 2048, + system: APP_GENERATION_EVALUATOR_SYSTEM_PROMPT, messages: [ - { role: 'system', content: APP_GENERATION_EVALUATOR_SYSTEM_PROMPT }, { role: 'user', content: userMessage } ], temperature: 0 }) - const content = response.choices[0]?.message?.content + const textBlock = response.content.find((block) => block.type === 'text') + const content = textBlock?.text if (!content) { return { success: false, diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts index 3f0da73c92..2e6a491bce 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts @@ -14,6 +14,7 @@ import { type VariantDefaults } from '../shared' import { writeAppComparisonResultsToFolders } from './appResultsWriter' +import type { AIProvider } from '$lib/gen/types.gen' // Re-export for convenience export type { InitialApp } from './appEvalComparison' @@ -38,6 +39,8 @@ export interface AppEvalOptions { variant?: VariantConfig /** Whether to evaluate the generated app with LLM. Default: true. Set to false to skip evaluation. */ evaluateWithLLM?: boolean + /** AI provider (inferred from model name if omitted) */ + provider?: AIProvider } /** @@ -49,12 +52,11 @@ const appDefaults: VariantDefaults = { } /** - * Runs an app chat evaluation with real OpenAI API calls. - * Executes tool calls using the actual app tools from core.ts or variant-configured tools. + * Runs an app chat evaluation using the shared chat loop (same code path as production). */ export async function runAppEval( userPrompt: string, - openaiApiKey: string, + apiKey: string, options?: AppEvalOptions ): Promise { const { helpers, getFiles } = createAppEvalHelpers( @@ -69,7 +71,7 @@ export async function runAppEval( appDefaults, options?.customSystemPrompt ) - const { toolDefs, tools } = resolveTools(options?.variant, appDefaults) + const { tools } = resolveTools(options?.variant, appDefaults) const model = resolveModel(options?.variant, options?.model) // Build user message @@ -80,15 +82,15 @@ export async function runAppEval( userPrompt, systemMessage, userMessage, - toolDefs, tools, helpers, - apiKey: openaiApiKey, + apiKey, getOutput: getFiles, options: { maxIterations: options?.maxIterations, model, - workspace: 'test-workspace' + workspace: 'test-workspace', + provider: options?.provider } }) @@ -114,21 +116,32 @@ export async function runAppEval( } } +/** + * Per-variant provider override. + */ +export interface VariantProviderOverride { + provider: AIProvider + apiKey: string +} + /** * Runs the same prompt against multiple variants sequentially for comparison. - * Returns results in the same order as the input variants. + * Accepts optional per-variant provider/apiKey overrides. */ export async function runVariantComparison( userPrompt: string, variants: VariantConfig[], - openaiApiKey: string, - baseOptions?: Omit + defaultApiKey: string, + baseOptions?: Omit, + providerOverrides?: VariantProviderOverride[] ): Promise { const results: AppEvalResult[] = await Promise.all( - variants.map(async (variant) => { - return await runAppEval(userPrompt, openaiApiKey, { + variants.map(async (variant, i) => { + const override = providerOverrides?.[i] + return await runAppEval(userPrompt, override?.apiKey ?? defaultApiKey, { ...baseOptions, - variant + variant, + provider: override?.provider ?? baseOptions?.provider }) }) ) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowChat.eval.test.ts b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowChat.eval.test.ts index 8210ea50fb..de9b8e5f43 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowChat.eval.test.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowChat.eval.test.ts @@ -22,35 +22,60 @@ import initialTest6 from './initial/test6_initial.json' // @ts-ignore - JSON import import initialTest7 from './initial/test7_initial.json' import type { FlowModule } from '$lib/gen' +import type { AIProvider } from '$lib/gen/types.gen' -// Get API key from environment - tests will be skipped if not set +// Get API keys from environment - tests will be skipped if none are set // @ts-ignore -// const OPENAI_API_KEY = process.env.OPENAI_API_KEY -const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY +const OPENAI_API_KEY = process.env.OPENAI_API_KEY +// @ts-ignore +const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY -// Skip all tests if no API key is provided -// const describeWithApiKey = OPENAI_API_KEY ? describe : describe.skip -const describeWithApiKey = OPENROUTER_API_KEY ? describe : describe.skip +const hasAnyKey = OPENAI_API_KEY || ANTHROPIC_API_KEY +const describeWithApiKey = hasAnyKey ? describe : describe.skip -const MODELS = ['google/gemini-2.5-flash', 'anthropic/claude-haiku-4.5', 'openai/gpt-4o'] +// Build model variants based on available keys +interface ModelVariant { + model: string + provider: AIProvider + apiKey: string +} + +const MODEL_VARIANTS: ModelVariant[] = [ + ...(OPENAI_API_KEY + ? [{ model: 'gpt-4o', provider: 'openai' as AIProvider, apiKey: OPENAI_API_KEY }] + : []), + ...(ANTHROPIC_API_KEY + ? [ + { + model: 'claude-haiku-4-5-20241022', + provider: 'anthropic' as AIProvider, + apiKey: ANTHROPIC_API_KEY + } + ] + : []) +] const VARIANTS = [ - ...MODELS.map((model) => ({ + ...MODEL_VARIANTS.map((mv) => ({ ...BASELINE_VARIANT, - model, - name: `baseline-${model.replace('/', '-')}` + model: mv.model, + name: `baseline-${mv.provider}-${mv.model}`, + _provider: mv.provider, + _apiKey: mv.apiKey })), - ...MODELS.map((model) => ({ + ...MODEL_VARIANTS.map((mv) => ({ ...MINIMAL_SINGLE_TOOL_VARIANT, - model, - name: `minimal-single-tool-${model.replace('/', '-')}` + model: mv.model, + name: `minimal-single-tool-${mv.provider}-${mv.model}`, + _provider: mv.provider, + _apiKey: mv.apiKey })) ] describeWithApiKey('Flow Chat LLM Evaluation', () => { const TEST_TIMEOUT = 120_000 - if (!OPENROUTER_API_KEY) { - console.warn('OPENROUTER_API_KEY is not set, skipping tests') + if (!hasAnyKey) { + console.warn('No API keys set (OPENAI_API_KEY or ANTHROPIC_API_KEY), skipping tests') } it( @@ -65,9 +90,15 @@ STEP 3: Loop on all users STEP 4: Do branches based on user's role, do different action based on that. Roles are admin, user, moderator STEP 5: Return action taken for each user ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - expectedFlow: expectedTest1 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + expectedFlow: expectedTest1 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) // Write results to files const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) @@ -112,9 +143,15 @@ STEP 5: Branch based on inventory - if all items available, create shipment reco STEP 6: Send confirmation (mock email to customer_email) STEP 7: Return final order summary with status ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - expectedFlow: expectedTest2 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + expectedFlow: expectedTest2 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -161,9 +198,15 @@ STEP 5: Branch based on quality score: - If score < 70: Store in quarantine and send alert STEP 6: Return processing report with statistics (total records, quality score, destination) ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - expectedFlow: expectedTest3 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + expectedFlow: expectedTest3 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -210,9 +253,15 @@ STEP 3: Use an AI agent to handle the customer query. The agent should have acce STEP 4: Log the interaction to audit trail (customer_id, query, response summary) STEP 5: Return the agent's response and any actions taken ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - expectedFlow: expectedTest4 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + expectedFlow: expectedTest4 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -256,11 +305,17 @@ Modify this existing flow to add error handling: - If validation passes, return the data for the next step - Update save_results to handle the validation result appropriately ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialModules: initialTest5.value.modules as FlowModule[], - initialSchema: initialTest5.schema, - expectedFlow: expectedTest5 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialModules: initialTest5.value.modules as FlowModule[], + initialSchema: initialTest5.schema, + expectedFlow: expectedTest5 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -302,11 +357,17 @@ Modify the order processing loop to handle different order types: - Move the original process_order step to the default branch for unknown order types - Each branch step should return the orderId, shipping cost, and shipping type ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialModules: initialTest6.value.modules as FlowModule[], - initialSchema: initialTest6.schema, - expectedFlow: expectedTest6 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialModules: initialTest6.value.modules as FlowModule[], + initialSchema: initialTest6.schema, + expectedFlow: expectedTest6 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -348,11 +409,17 @@ Refactor this flow for better performance by parallelizing the enrichment steps: - The combine_data step should check if any enrichment used a fallback value and set a hasFallbacks flag - Keep get_item as the first step and return_result as the last step unchanged ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialModules: initialTest7.value.modules as FlowModule[], - initialSchema: initialTest7.schema, - expectedFlow: expectedTest7 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialModules: initialTest7.value.modules as FlowModule[], + initialSchema: initialTest7.schema, + expectedFlow: expectedTest7 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalComparison.ts b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalComparison.ts index f55979bb40..4c2b41d577 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalComparison.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalComparison.ts @@ -59,14 +59,10 @@ export async function evaluateFlowComparison( expectedFlow: ExpectedFlow, userPrompt: string ): Promise { - // @ts-ignore - const apiKey = process.env.OPENROUTER_API_KEY - return evaluateWithLLM({ userPrompt, generatedOutput: generatedFlow, expectedOutput: expectedFlow, - evaluatorSystemPrompt: FLOW_EVALUATOR_SYSTEM_PROMPT, - apiKey + evaluatorSystemPrompt: FLOW_EVALUATOR_SYSTEM_PROMPT }) } diff --git a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalRunner.ts b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalRunner.ts index 3f27143c69..f3c976950d 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalRunner.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalRunner.ts @@ -1,4 +1,5 @@ import type { FlowModule } from '$lib/gen' +import type { AIProvider } from '$lib/gen/types.gen' import type { ExtendedOpenFlow } from '$lib/components/flows/types' import { flowTools, prepareFlowSystemMessage, prepareFlowUserMessage, type FlowAIChatHelpers } from '../../flow/core' import { createFlowEvalHelpers } from './flowEvalHelpers' @@ -38,6 +39,8 @@ export interface FlowEvalOptions { maxIterations?: number variant?: VariantConfig expectedFlow?: ExpectedFlow + /** AI provider (inferred from model name if omitted) */ + provider?: AIProvider } /** @@ -49,12 +52,11 @@ const flowDefaults: VariantDefaults = { } /** - * Runs a flow chat evaluation with real OpenAI API calls. - * Executes tool calls using the actual flowTools from core.ts or variant-configured tools. + * Runs a flow chat evaluation using the shared chat loop (same code path as production). */ export async function runFlowEval( userPrompt: string, - openaiApiKey: string, + apiKey: string, options?: FlowEvalOptions ): Promise { const { helpers, getFlow } = createFlowEvalHelpers( @@ -65,7 +67,7 @@ export async function runFlowEval( // Resolve variant configuration const variantName = options?.variant?.name ?? 'baseline' const systemMessage = resolveSystemPrompt(options?.variant, flowDefaults, options?.customSystemPrompt) - const { toolDefs, tools } = resolveTools(options?.variant, flowDefaults) + const { tools } = resolveTools(options?.variant, flowDefaults) const model = resolveModel(options?.variant, options?.model) // Build user message @@ -76,15 +78,15 @@ export async function runFlowEval( userPrompt, systemMessage, userMessage, - toolDefs, tools, helpers, - apiKey: openaiApiKey, + apiKey, getOutput: getFlow, options: { maxIterations: options?.maxIterations, model, - workspace: 'test-workspace' + workspace: 'test-workspace', + provider: options?.provider } }) @@ -111,21 +113,32 @@ export async function runFlowEval( } } +/** + * Per-variant provider override. + */ +export interface VariantProviderOverride { + provider: AIProvider + apiKey: string +} + /** * Runs the same prompt against multiple variants sequentially for comparison. - * Returns results in the same order as the input variants. + * Accepts optional per-variant provider/apiKey overrides. */ export async function runVariantComparison( userPrompt: string, variants: VariantConfig[], - openaiApiKey: string, - baseOptions?: Omit + defaultApiKey: string, + baseOptions?: Omit, + providerOverrides?: VariantProviderOverride[] ): Promise { const results: FlowEvalResult[] = await Promise.all( - variants.map(async (variant) => { - return await runFlowEval(userPrompt, openaiApiKey, { + variants.map(async (variant, i) => { + const override = providerOverrides?.[i] + return await runFlowEval(userPrompt, override?.apiKey ?? defaultApiKey, { ...baseOptions, - variant + variant, + provider: override?.provider ?? baseOptions?.provider }) }) ) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/shared/baseEvalRunner.ts b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseEvalRunner.ts index b9b7820568..f46acb9108 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/shared/baseEvalRunner.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseEvalRunner.ts @@ -1,8 +1,14 @@ -import OpenAI, { APIError } from 'openai' -import type { ChatCompletionMessageParam, ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs' -import type { ChatCompletionTool } from 'openai/resources/chat/completions.mjs' +import OpenAI from 'openai' +import Anthropic from '@anthropic-ai/sdk' +import type { + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam +} from 'openai/resources/chat/completions.mjs' +import type { AIProvider, AIProviderModel } from '$lib/gen/types.gen' import type { TokenUsage, ToolCallDetail, EvalRunnerOptions } from './types' import type { Tool } from './baseVariants' +import { runChatLoop, type ChatClients } from '../../chatLoop' +import type { Tool as ProductionTool, ToolCallbacks } from '../../shared' /** * Result from a single eval run (before domain-specific evaluation). @@ -29,13 +35,13 @@ export interface RunEvalParams { systemMessage: ChatCompletionSystemMessageParam /** User message for the LLM */ userMessage: ChatCompletionMessageParam - /** Tool definitions for the LLM API */ - toolDefs: ChatCompletionTool[] + /** Tool definitions for the LLM API (unused — derived from tools) */ + toolDefs?: unknown /** Full tool implementations for execution */ tools: Tool[] /** Domain-specific helpers for tool execution */ helpers: THelpers - /** API key for OpenRouter */ + /** API key for the provider */ apiKey: string /** Function to get the current output state */ getOutput: () => TOutput @@ -44,10 +50,37 @@ export interface RunEvalParams { } /** - * Runs a generic evaluation with real LLM API calls. - * Executes tool calls in a loop until the LLM stops calling tools. - * - * This is the core execution loop shared across all chat eval tests. + * Creates SDK clients for the given provider. + */ +function createEvalClients(provider: AIProvider, apiKey: string): ChatClients { + if (provider === 'anthropic') { + return { + openai: new OpenAI({ apiKey: 'unused' }), + anthropic: new Anthropic({ apiKey }) + } + } + return { + openai: new OpenAI({ apiKey }), + anthropic: new Anthropic({ apiKey: 'unused' }) + } +} + +/** + * Resolves model string to AIProviderModel. + */ +function resolveModelProvider( + model: string, + provider?: AIProvider +): AIProviderModel { + if (provider) return { provider, model } + if (model.startsWith('claude')) return { provider: 'anthropic', model } + if (model.startsWith('gpt') || model.startsWith('o')) return { provider: 'openai', model } + return { provider: 'openai', model } +} + +/** + * Runs a generic evaluation using the shared chat loop (same code path as production). + * Uses streaming via real provider SDKs instead of OpenRouter non-streaming. */ export async function runEval( params: RunEvalParams @@ -55,7 +88,6 @@ export async function runEval( const { systemMessage, userMessage, - toolDefs, tools, helpers, apiKey, @@ -63,134 +95,82 @@ export async function runEval( options } = params - const client = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey }) const model = options?.model ?? 'gpt-4o' const maxIterations = options?.maxIterations ?? 20 const workspace = options?.workspace ?? 'test-workspace' + const provider = options?.provider - const messages: ChatCompletionMessageParam[] = [systemMessage, userMessage] - const totalTokens: TokenUsage = { prompt: 0, completion: 0, total: 0 } + const modelProvider = resolveModelProvider(model, provider) + const clients = createEvalClients(modelProvider.provider, apiKey) + + const messages: ChatCompletionMessageParam[] = [userMessage] let toolCallsCount = 0 const toolsCalled: string[] = [] const toolCallDetails: ToolCallDetail[] = [] - let iterations = 0 - // No-op tool callbacks for eval - const toolCallbacks = { + // Wrap tools to intercept fn calls for tracking. + // Cast to ProductionTool since the eval Tool has a narrower toolCallbacks type + // but the actual callbacks passed at runtime will satisfy both interfaces. + const wrappedTools = tools.map((tool) => ({ + ...tool, + fn: async (p: any) => { + toolCallsCount++ + toolsCalled.push(tool.def.function.name) + try { + const args = + typeof p.args === 'string' ? JSON.parse(p.args) : p.args + toolCallDetails.push({ name: tool.def.function.name, arguments: args }) + } catch { + toolCallDetails.push({ + name: tool.def.function.name, + arguments: p.args + }) + } + return tool.fn(p) + } + })) as ProductionTool[] + + // No-op callbacks for eval + const callbacks: ToolCallbacks & { + onNewToken: (token: string) => void + onMessageEnd: () => void + } = { setToolStatus: () => {}, - removeToolStatus: () => {} + removeToolStatus: () => {}, + onNewToken: () => {}, + onMessageEnd: () => {} } + const abortController = new AbortController() + try { - // Tool resolution loop - while (iterations < maxIterations) { - iterations++ - - const response = await client.chat.completions.create({ - model, - messages, - tools: toolDefs, - temperature: 0 - }) - - // Track token usage - if (response.usage) { - totalTokens.prompt += response.usage.prompt_tokens - totalTokens.completion += response.usage.completion_tokens - totalTokens.total += response.usage.total_tokens - } - - if (!response.choices.length) { - throw new Error('No response from API') - } - - const choice = response.choices[0] - const assistantMessage = choice.message - - // Add assistant message to history - messages.push(assistantMessage) - - // If no tool calls, we're done - if (!assistantMessage.tool_calls?.length) { - break - } - - // Execute each tool call - for (const toolCall of assistantMessage.tool_calls) { - toolCallsCount++ - - // Type guard: only handle function tool calls - if (toolCall.type !== 'function') { - messages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: `Unsupported tool type: ${toolCall.type}` - }) - continue - } - - toolsCalled.push(toolCall.function.name) - - const tool = tools.find((t) => t.def.function.name === toolCall.function.name) - if (!tool) { - messages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: `Unknown tool: ${toolCall.function.name}` - }) - continue - } - - try { - const args = JSON.parse(toolCall.function.arguments) - toolCallDetails.push({ name: toolCall.function.name, arguments: args }) - const result = await tool.fn({ - args, - workspace, - helpers, - toolCallbacks, - toolId: toolCall.id - }) - messages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: result - }) - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err) - messages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: `Error: ${errorMessage}` - }) - } - } - } + const result = await runChatLoop({ + messages, + systemMessage, + tools: wrappedTools, + helpers, + abortController, + callbacks, + modelProvider, + clients, + workspace, + maxIterations, + skipResponsesApi: modelProvider.provider !== 'openai' && modelProvider.provider !== 'azure_openai' + }) return { success: true, output: getOutput(), - tokenUsage: totalTokens, + tokenUsage: { prompt: 0, completion: 0, total: 0 }, toolCallsCount, toolsCalled, toolCallDetails, - iterations, + iterations: Math.max(1, result.addedMessages.filter((m) => m.role === 'assistant').length), messages } } catch (err) { - // Build detailed error message let errorMessage: string - if (err instanceof APIError) { - const details: string[] = [`${err.status} ${err.message}`] - if (err.code) details.push(`Code: ${err.code}`) - if (err.type) details.push(`Type: ${err.type}`) - if (err.param) details.push(`Param: ${err.param}`) - if (err.requestID) details.push(`Request ID: ${err.requestID}`) - if (err.error && typeof err.error === 'object') { - details.push(`Response: ${JSON.stringify(err.error, null, 2)}`) - } - errorMessage = details.join('\n') - } else if (err instanceof Error) { + if (err instanceof Error) { errorMessage = err.stack ?? err.message } else { errorMessage = String(err) @@ -200,11 +180,11 @@ export async function runEval( success: false, output: getOutput(), error: errorMessage, - tokenUsage: totalTokens, + tokenUsage: { prompt: 0, completion: 0, total: 0 }, toolCallsCount, toolsCalled, toolCallDetails, - iterations, + iterations: 0, messages } } diff --git a/frontend/src/lib/components/copilot/chat/__tests__/shared/baseLLMEvaluator.ts b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseLLMEvaluator.ts index 63c17828f4..bd7bd06d44 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/shared/baseLLMEvaluator.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseLLMEvaluator.ts @@ -1,4 +1,4 @@ -import OpenAI from 'openai' +import Anthropic from '@anthropic-ai/sdk' import type { EvaluationResult } from './types' /** @@ -13,9 +13,9 @@ export interface EvaluateParams { expectedOutput: unknown /** Domain-specific system prompt for the evaluator */ evaluatorSystemPrompt: string - /** API key for OpenRouter */ - apiKey: string - /** Model to use for evaluation (default: 'anthropic/claude-sonnet-4.5') */ + /** Anthropic API key for evaluation */ + apiKey?: string + /** Model to use for evaluation (default: 'claude-sonnet-4-5-20250514') */ model?: string } @@ -41,10 +41,7 @@ Score guidelines: /** * Evaluates how well a generated output matches an expected output using an LLM. - * Returns a resemblance score (0-100), a qualitative statement, and any missing requirements. - * - * @param params Evaluation parameters including prompts, outputs, and API configuration - * @returns Evaluation result with score, statement, and missing requirements + * Uses Anthropic API directly instead of OpenRouter. */ export async function evaluateWithLLM(params: EvaluateParams): Promise { const { @@ -53,10 +50,21 @@ export async function evaluateWithLLM(params: EvaluateParams): Promise block.type === 'text') + const content = textBlock?.text if (!content) { return { success: false, @@ -98,7 +108,6 @@ Please evaluate how well the generated output: // Parse JSON response - handle potential markdown code blocks let jsonContent = content.trim() if (jsonContent.startsWith('```')) { - // Remove markdown code block wrapper jsonContent = jsonContent.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '') } diff --git a/frontend/src/lib/components/copilot/chat/__tests__/shared/types.ts b/frontend/src/lib/components/copilot/chat/__tests__/shared/types.ts index 021e776440..61f7f1fd1f 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/shared/types.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/shared/types.ts @@ -1,4 +1,5 @@ import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.mjs' +import type { AIProvider } from '$lib/gen/types.gen' /** * Token usage tracking for LLM calls. @@ -83,6 +84,8 @@ export interface EvalRunnerOptions { model?: string /** Workspace ID for tool calls */ workspace?: string + /** AI provider (inferred from model name if omitted) */ + provider?: AIProvider } /** diff --git a/frontend/src/lib/components/copilot/chat/anthropic.ts b/frontend/src/lib/components/copilot/chat/anthropic.ts index 03d0f363a0..ac45c175a6 100644 --- a/frontend/src/lib/components/copilot/chat/anthropic.ts +++ b/frontend/src/lib/components/copilot/chat/anthropic.ts @@ -1,4 +1,5 @@ import { OpenAI } from 'openai' +import Anthropic from '@anthropic-ai/sdk' import type { ChatCompletionMessageParam, ChatCompletionMessageFunctionToolCall @@ -13,19 +14,28 @@ import type { RawMessageStreamEvent } from '@anthropic-ai/sdk/resources' import type { MessageStream } from '@anthropic-ai/sdk/lib/MessageStream' +import type { AIProviderModel } from '$lib/gen' import { getProviderAndCompletionConfig, workspaceAIClients } from '../lib' import { processToolCall, type Tool, type ToolCallbacks } from './shared' export async function getAnthropicCompletion( messages: ChatCompletionMessageParam[], abortController: AbortController, - tools?: OpenAI.Chat.Completions.ChatCompletionFunctionTool[] + tools?: OpenAI.Chat.Completions.ChatCompletionFunctionTool[], + options?: { + forceModelProvider?: AIProviderModel + anthropicClient?: Anthropic + } ): Promise { - const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true }) + const { provider, config } = getProviderAndCompletionConfig({ + messages, + stream: true, + forceModelProvider: options?.forceModelProvider + }) const { system, messages: anthropicMessages } = convertOpenAIToAnthropicMessages(messages) const anthropicTools = convertOpenAIToolsToAnthropic(tools) - const anthropicClient = workspaceAIClients.getAnthropicClient() + const client = options?.anthropicClient ?? workspaceAIClients.getAnthropicClient() const anthropicParams = { model: config.model, @@ -36,7 +46,7 @@ export async function getAnthropicCompletion( ...(typeof config.temperature === 'number' && { temperature: config.temperature }) } - const stream = anthropicClient.messages.stream(anthropicParams, { + const stream = client.messages.stream(anthropicParams, { signal: abortController.signal, headers: { 'X-Provider': provider, @@ -58,7 +68,8 @@ export async function parseAnthropicCompletion( addedMessages: ChatCompletionMessageParam[], tools: Tool[], helpers: any, - abortController?: AbortController + abortController?: AbortController, + options?: { workspace?: string } ): Promise { let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = [] let error = null @@ -209,7 +220,8 @@ export async function parseAnthropicCompletion( tools, toolCall, helpers, - toolCallbacks: callbacks + toolCallbacks: callbacks, + workspace: options?.workspace }) messages.push(messageToAdd) addedMessages.push(messageToAdd) diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.ts b/frontend/src/lib/components/copilot/chat/chatLoop.ts new file mode 100644 index 0000000000..4b239e4a05 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/chatLoop.ts @@ -0,0 +1,211 @@ +import OpenAI from 'openai' +import Anthropic from '@anthropic-ai/sdk' +import type { + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionUserMessageParam +} from 'openai/resources/chat/completions.mjs' +import type { AIProviderModel } from '$lib/gen' +import { getCompletion, parseOpenAICompletion } from '../lib' +import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic' +import { + getOpenAIResponsesCompletion, + parseOpenAIResponsesCompletion +} from './openai-responses' +import type { Tool, ToolCallbacks } from './shared' + +export interface ChatClients { + openai: OpenAI + anthropic: Anthropic +} + +export interface ChatLoopConfig { + messages: ChatCompletionMessageParam[] + /** + * System message, tools, helpers, and modelProvider are re-read from this config + * on every iteration. Callers can use JS getters to provide dynamic values + * (e.g. AIChatManager uses getters so mode changes mid-loop take effect). + */ + systemMessage: ChatCompletionSystemMessageParam + tools: Tool[] + helpers: any + abortController: AbortController + callbacks: ToolCallbacks & { + onNewToken: (token: string) => void + onMessageEnd: () => void + } + modelProvider: AIProviderModel + clients: ChatClients + workspace: string + /** Maximum iterations for the loop. undefined = unlimited (production). */ + maxIterations?: number + skipResponsesApi?: boolean + onSkipResponsesApi?: () => void + /** Return a pending user message to inject between iterations, or undefined. */ + getPendingUserMessage?: () => ChatCompletionUserMessageParam | undefined + /** Called before each iteration (e.g. to refresh tool schemas). */ + onBeforeIteration?: (tools: Tool[], helpers: any) => Promise +} + +export interface ChatLoopResult { + addedMessages: ChatCompletionMessageParam[] +} + +export async function runChatLoop(config: ChatLoopConfig): Promise { + const { + messages, + abortController, + callbacks, + clients, + workspace, + maxIterations, + onSkipResponsesApi, + getPendingUserMessage, + onBeforeIteration + } = config + let skipResponsesApi = config.skipResponsesApi ?? false + + const addedMessages: ChatCompletionMessageParam[] = [] + let iterations = 0 + + while (true) { + if (maxIterations !== undefined && iterations >= maxIterations) { + break + } + iterations++ + + // Re-read these from config each iteration so that mode changes + // (e.g. changeModeTool in Navigator) take effect immediately. + // Callers can use JS getter properties to provide dynamic values. + const tools = config.tools + const helpers = config.helpers + const systemMessage = config.systemMessage + const modelProvider = config.modelProvider + + if (onBeforeIteration) { + await onBeforeIteration(tools, helpers) + } + + const pendingUserMessage = getPendingUserMessage?.() + + const isOpenAI = + modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai' + const isAnthropic = modelProvider.provider === 'anthropic' + + const messageParams = [ + systemMessage, + ...messages, + ...(pendingUserMessage ? [pendingUserMessage] : []) + ] + const toolDefs = tools.map((t) => t.def) + const parseOptions = { workspace } + + if (isOpenAI) { + let useCompletionsApi = skipResponsesApi + if (!skipResponsesApi) { + try { + const completion = await getOpenAIResponsesCompletion( + messageParams, + abortController, + toolDefs, + { + forceModelProvider: modelProvider, + openaiClient: clients.openai + } + ) + const continueCompletion = await parseOpenAIResponsesCompletion( + completion, + callbacks, + messages, + addedMessages, + tools, + helpers, + parseOptions + ) + if (!continueCompletion) { + break + } + } catch (err) { + console.warn( + 'OpenAI Responses API failed, falling back to Completions API:', + err + ) + const errorMessage = err instanceof Error ? err.message : String(err) + if (errorMessage.includes('Responses API is not enabled')) { + skipResponsesApi = true + onSkipResponsesApi?.() + } + useCompletionsApi = true + } + } + + if (useCompletionsApi) { + const completion = await getCompletion(messageParams, abortController, toolDefs, { + forceCompletions: true, + forceModelProvider: modelProvider, + openaiClient: clients.openai + }) + const continueCompletion = await parseOpenAICompletion( + completion, + callbacks, + messages, + addedMessages, + tools, + helpers, + undefined, + parseOptions + ) + if (!continueCompletion) { + break + } + } + } else if (isAnthropic) { + const completion = await getAnthropicCompletion( + messageParams, + abortController, + toolDefs, + { + forceModelProvider: modelProvider, + anthropicClient: clients.anthropic + } + ) + if (completion) { + const continueCompletion = await parseAnthropicCompletion( + completion, + callbacks, + messages, + addedMessages, + tools, + helpers, + abortController, + parseOptions + ) + if (!continueCompletion) { + break + } + } + } else { + const completion = await getCompletion(messageParams, abortController, toolDefs, { + forceModelProvider: modelProvider, + openaiClient: clients.openai + }) + if (completion) { + const continueCompletion = await parseOpenAICompletion( + completion, + callbacks, + messages, + addedMessages, + tools, + helpers, + undefined, + parseOptions + ) + if (!continueCompletion) { + break + } + } + } + } + + return { addedMessages } +} diff --git a/frontend/src/lib/components/copilot/chat/openai-responses.ts b/frontend/src/lib/components/copilot/chat/openai-responses.ts index 5003f48099..56364e1401 100644 --- a/frontend/src/lib/components/copilot/chat/openai-responses.ts +++ b/frontend/src/lib/components/copilot/chat/openai-responses.ts @@ -125,15 +125,24 @@ function convertCompletionConfigToResponsesConfig( export async function getOpenAIResponsesCompletion( messages: ChatCompletionMessageParam[], abortController: AbortController, - tools?: OpenAI.Chat.Completions.ChatCompletionTool[] + tools?: OpenAI.Chat.Completions.ChatCompletionTool[], + options?: { + forceModelProvider?: AIProviderModel + openaiClient?: OpenAI + } ) { - const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true, tools }) + const { provider, config } = getProviderAndCompletionConfig({ + messages, + stream: true, + tools, + forceModelProvider: options?.forceModelProvider + }) const { instructions, input } = convertMessagesToResponsesInput(messages) const responsesConfig = convertCompletionConfigToResponsesConfig(config) - const openaiClient = workspaceAIClients.getOpenaiClient() + const client = options?.openaiClient ?? workspaceAIClients.getOpenaiClient() - const runner = openaiClient.responses.stream( + const runner = client.responses.stream( { ...responsesConfig, input, @@ -208,7 +217,8 @@ export async function parseOpenAIResponsesCompletion( messages: ChatCompletionMessageParam[], addedMessages: ChatCompletionMessageParam[], tools: Tool[], - helpers: any + helpers: any, + options?: { workspace?: string } ): Promise { let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = [] let error: OpenAIError | ResponseErrorEvent | null = null @@ -342,7 +352,8 @@ export async function parseOpenAIResponsesCompletion( tools, toolCall, helpers, - toolCallbacks: callbacks + toolCallbacks: callbacks, + workspace: options?.workspace }) messages.push(messageToAdd) addedMessages.push(messageToAdd) diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 4a95912b47..20e488d923 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -417,12 +417,14 @@ export async function processToolCall({ tools, toolCall, helpers, - toolCallbacks + toolCallbacks, + workspace }: { tools: Tool[] toolCall: ChatCompletionMessageFunctionToolCall helpers: T toolCallbacks: ToolCallbacks + workspace?: string }): Promise { try { const args = JSON.parse(toolCall.function.arguments || '{}') @@ -472,7 +474,7 @@ export async function processToolCall({ tools, functionName: toolCall.function.name, args, - workspace: get(workspaceStore) ?? '', + workspace: workspace ?? get(workspaceStore) ?? '', helpers, toolCallbacks, toolId: toolCall.id diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index dc05e3a247..d8149086d4 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -296,7 +296,12 @@ function getModelSpecificConfig( ) { const defaultMaxTokens = getModelMaxTokens(modelProvider.provider, modelProvider.model) const modelKey = `${modelProvider.provider}:${modelProvider.model}` - const customMaxTokensStore = get(copilotInfo)?.maxTokensPerModel + let customMaxTokensStore: Record | undefined + try { + customMaxTokensStore = get(copilotInfo)?.maxTokensPerModel + } catch { + // copilotInfo store may not be initialized in vitest + } const maxTokens = customMaxTokensStore?.[modelKey] ?? defaultMaxTokens if ( (modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai') && @@ -876,9 +881,16 @@ export async function getCompletion( tools?: OpenAI.Chat.Completions.ChatCompletionTool[], options?: { forceCompletions?: boolean + forceModelProvider?: AIProviderModel + openaiClient?: OpenAI } ): Promise> { - const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true, tools }) + const { provider, config } = getProviderAndCompletionConfig({ + messages, + stream: true, + tools, + forceModelProvider: options?.forceModelProvider + }) // Use Responses API for OpenAI and Azure OpenAI if ((provider === 'openai' || provider === 'azure_openai') && !options?.forceCompletions) { @@ -891,8 +903,8 @@ export async function getCompletion( } // Use Completions API for other providers - const openaiClient = workspaceAIClients.getOpenaiClient() - const completion = openaiClient.chat.completions.create(config, { + const client = options?.openaiClient ?? workspaceAIClients.getOpenaiClient() + const completion = client.chat.completions.create(config, { signal: abortController.signal, headers: { 'X-Provider': provider @@ -921,7 +933,8 @@ export async function parseOpenAICompletion( addedMessages: ChatCompletionMessageParam[], tools: Tool[], helpers: any, - _abortController?: AbortController // unused, for signature compatibility with parseAnthropicCompletion + _abortController?: AbortController, // unused, for signature compatibility with parseAnthropicCompletion + options?: { workspace?: string } ): Promise { const finalToolCalls: Record = {} let malformedFunctionCallError = false @@ -1060,7 +1073,8 @@ export async function parseOpenAICompletion( tools, toolCall, helpers, - toolCallbacks: callbacks + toolCallbacks: callbacks, + workspace: options?.workspace }) messages.push(messageToAdd) addedMessages.push(messageToAdd) From 34e3115bcbd19a8e0b6f483435586a2ab43d0a8e Mon Sep 17 00:00:00 2001 From: wendrul <53628737+wendrul@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:59:48 +0100 Subject: [PATCH 180/204] fix: raw apps bundle not found during deployment error (#8515) --- backend/Cargo.lock | 1 + backend/windmill-api-workspaces/Cargo.toml | 2 + .../windmill-api-workspaces/src/workspaces.rs | 65 +++++++++++++++++++ backend/windmill-api/Cargo.toml | 2 +- backend/windmill-api/src/apps.rs | 26 ++++++-- 5 files changed, 88 insertions(+), 8 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a5e2e8fc98..34532bc173 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -16433,6 +16433,7 @@ dependencies = [ "windmill-common", "windmill-dep-map", "windmill-git-sync", + "windmill-object-store", "windmill-queue", "windmill-types", ] diff --git a/backend/windmill-api-workspaces/Cargo.toml b/backend/windmill-api-workspaces/Cargo.toml index a03bb3a490..86f0649c73 100644 --- a/backend/windmill-api-workspaces/Cargo.toml +++ b/backend/windmill-api-workspaces/Cargo.toml @@ -14,9 +14,11 @@ enterprise = ["windmill-common/enterprise"] private = ["windmill-common/private"] cloud = ["windmill-common/cloud"] no_auth = ["windmill-api-auth/no_auth"] +parquet = ["windmill-object-store/parquet"] [dependencies] windmill-common = { workspace = true, default-features = false } +windmill-object-store = { workspace = true, optional = true } windmill-types.workspace = true windmill-api-auth.workspace = true windmill-api-users.workspace = true diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index fb234a13a3..4e832b3a93 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -3474,6 +3474,11 @@ async fn clone_apps( .fetch_all(&mut **tx) .await?; + let mut cloned_from_db: std::collections::HashSet<(i64, String)> = HashSet::new(); + for bundle in &bundles { + cloned_from_db.insert((bundle.app_version_id, bundle.file_type.clone())); + } + for bundle in bundles { if let Some(&new_version_id) = version_id_mapping.get(&bundle.app_version_id) { sqlx::query!( @@ -3488,6 +3493,66 @@ async fn clone_apps( .await?; } } + + // Clone bundles from S3 for versions not found in DB + #[cfg(all(feature = "enterprise", feature = "parquet"))] + { + let object_store = windmill_object_store::get_object_store().await; + if let Some(os) = object_store { + for (&old_version_id, &new_version_id) in &version_id_mapping { + for file_type in &["js", "css"] { + if cloned_from_db.contains(&(old_version_id, file_type.to_string())) { + continue; + } + let src_path = format!( + "/app_bundles/{}/{}.{}", + source_workspace_id, old_version_id, file_type + ); + let get_result = os + .get(&windmill_object_store::object_store_reexports::Path::from( + src_path, + )) + .await; + match get_result { + Ok(result) => { + let data = result.bytes().await.map_err( + windmill_object_store::object_store_error_to_error, + )?; + let dst_path = format!( + "/app_bundles/{}/{}.{}", + target_workspace_id, new_version_id, file_type + ); + os.put( + &windmill_object_store::object_store_reexports::Path::from( + dst_path.clone(), + ), + data.into(), + ) + .await + .map_err( + windmill_object_store::object_store_error_to_error, + )?; + tracing::info!( + "Cloned app bundle from S3: {}.{} -> {}.{}", + old_version_id, + file_type, + new_version_id, + file_type + ); + } + Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound { .. }) => { + // No bundle in S3 for this version/type, skip + } + Err(e) => { + return Err( + windmill_object_store::object_store_error_to_error(e), + ); + } + } + } + } + } + } } // Update app versions arrays diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index 1b09f37861..3f015513e2 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -18,7 +18,7 @@ agent_worker_server = ["dep:windmill-worker", "dep:windmill-api-agent-workers"] enterprise_saml = ["dep:samael", "dep:libxml"] benchmark = [] embedding = ["windmill-api-embeddings/embedding"] -parquet = ["dep:datafusion", "windmill-common/parquet", "windmill-object-store/parquet", "windmill-worker?/parquet", "windmill-api-users/parquet", "windmill-api-settings/parquet", "dep:aws-sigv4", "dep:aws-sdk-config"] +parquet = ["dep:datafusion", "windmill-common/parquet", "windmill-object-store/parquet", "windmill-worker?/parquet", "windmill-api-users/parquet", "windmill-api-settings/parquet", "windmill-api-workspaces/parquet", "dep:aws-sigv4", "dep:aws-sdk-config"] prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus", "windmill-worker?/prometheus"] openidconnect = ["dep:openidconnect", "windmill-common/openidconnect", "windmill-store/openidconnect"] tantivy = ["dep:windmill-indexer"] diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 33a632ecbd..eaead14558 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -440,17 +440,29 @@ async fn get_raw_app_data( #[cfg(all(feature = "enterprise", feature = "parquet"))] if let Some(os) = object_store { let path = format!("/app_bundles/{}/{}.{}", w_id, id, file_type); - let stream = os + match os .get(&windmill_object_store::object_store_reexports::Path::from( path, )) .await - .map_err(windmill_object_store::object_store_error_to_error)? - .bytes() - .await - .map_err(windmill_object_store::object_store_error_to_error)?; - tracing::info!("stream: {}", stream.len()); - body = Some(Body::from(stream)); + { + Ok(result) => { + let stream = result + .bytes() + .await + .map_err(windmill_object_store::object_store_error_to_error)?; + tracing::info!("stream: {}", stream.len()); + body = Some(Body::from(stream)); + } + Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound { + .. + }) => { + // S3 key not found, fall through to DB lookup below + } + Err(e) => { + return Err(windmill_object_store::object_store_error_to_error(e)); + } + } } if body.is_none() { From b7d14c8614f4da0da262bb20c0eb01854975cf65 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 11:20:39 +0000 Subject: [PATCH 181/204] regenerate sqlx offline query cache for integration tests (#8518) Co-authored-by: Claude Opus 4.5 --- ...aacf6af2c284ae446860113c82bc4e1da08ab.json | 12 ++++++++++ ...bc47caebc25215a430d6b301b35e265888159.json | 12 ++++++++++ ...fb7cf5f2b76f013c274245af13d7d727ebf1f.json | 12 ++++++++++ ...e2e60e3183fa81a411622891caea6dc03fa90.json | 15 +++++++++++++ ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...960ffc33da5f31bf780e8fd6a66d5150b8027.json | 12 ++++++++++ ...69c87a9d29370ec985d2c8c28633cd078ffaf.json | 12 ++++++++++ ...74da8c73120b3e16194904575f79a4e055002.json | 12 ++++++++++ ...437ab3e02d8c3c10c53decc664533b8d04bc0.json | 22 +++++++++++++++++++ 9 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json create mode 100644 backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json create mode 100644 backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json create mode 100644 backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json create mode 100644 backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json create mode 100644 backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json create mode 100644 backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json create mode 100644 backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json diff --git a/backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json b/backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json new file mode 100644 index 0000000000..0ad1fe4367 --- /dev/null +++ b/backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app_version (id, app_id, value, created_by, created_at)\n VALUES (3001, 3001, '{\"grid\": []}', 'admin', NOW())", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab" +} diff --git a/backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json b/backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json new file mode 100644 index 0000000000..24d3c8929a --- /dev/null +++ b/backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE app SET versions = ARRAY[3001::bigint] WHERE id = 3001", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159" +} diff --git a/backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json b/backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json new file mode 100644 index 0000000000..10cab9117a --- /dev/null +++ b/backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, schema, extra_perms)\n VALUES ('test-workspace', 'u/operator/existing_flow', 'Existing flow', '', '{\"modules\": []}', 'admin', NOW(), '{}', '{}')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f" +} diff --git a/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json b/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json new file mode 100644 index 0000000000..27d46b27ed --- /dev/null +++ b/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token (token_hash, token_prefix, email, label, super_admin, owner, workspace_id)\n VALUES ($1, $2, 'charlie@windmill.dev', 'Charlie new token', false, 'u/charlie', 'test-workspace')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90" +} 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-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json b/backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json new file mode 100644 index 0000000000..8e558fe67b --- /dev/null +++ b/backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app (id, workspace_id, path, summary, versions, policy, extra_perms)\n VALUES (3001, 'test-workspace', 'u/operator/existing_app', 'Existing app', '{}',\n '{\"on_behalf_of\": \"u/admin\", \"on_behalf_of_email\": \"admin@windmill.dev\", \"execution_mode\": \"viewer\"}', '{}')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027" +} diff --git a/backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json b/backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json new file mode 100644 index 0000000000..6da123cbc4 --- /dev/null +++ b/backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO usr_to_group (workspace_id, group_, usr) VALUES ('test-workspace', 'editors', 'charlie')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf" +} diff --git a/backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json b/backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json new file mode 100644 index 0000000000..d7cc49fe3f --- /dev/null +++ b/backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, extra_perms)\n VALUES ('test-workspace', 3001, 'u/operator/existing_script', 'export function main() { return \"original\"; }', 'deno', 'script', 'admin', '{}', 'Existing script', '', '', '{}')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002" +} diff --git a/backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json b/backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json new file mode 100644 index 0000000000..d9b7688eba --- /dev/null +++ b/backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO kafka_trigger (\n path, kafka_resource_path, topics, group_id,\n script_path, is_flow, workspace_id, edited_by, permissioned_as\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "VarcharArray", + "Varchar", + "Varchar", + "Bool", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0" +} From 520706b640a1f9c8470d41f169b76d90db70a1e4 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:01:03 +0100 Subject: [PATCH 182/204] chore: use workingdir in webmux panes (#8516) Co-authored-by: Claude Opus 4.5 --- .webmux.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.webmux.yaml b/.webmux.yaml index 6a765fbb12..c41d0aa699 100644 --- a/.webmux.yaml +++ b/.webmux.yaml @@ -55,11 +55,13 @@ profiles: - id: backend kind: command split: right - command: ROOT="$(git rev-parse --show-toplevel)"; cd "$ROOT/backend" && cargo watch -x "run ${CARGO_FEATURES:+--features $CARGO_FEATURES}" + workingDir: backend + command: PORT=${BACKEND_PORT:-8000} cargo watch -x "run ${CARGO_FEATURES:+--features $CARGO_FEATURES}" - id: frontend kind: command split: bottom - command: ROOT="$(git rev-parse --show-toplevel)"; cd "$ROOT/frontend" && npm run generate-backend-client && npm run dev -- --host 0.0.0.0 + workingDir: frontend + command: npm run generate-backend-client && REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}} npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0 frontendOnly: runtime: host @@ -82,7 +84,8 @@ profiles: - id: frontend kind: command split: right - command: ROOT="$(git rev-parse --show-toplevel)"; cd "$ROOT/frontend" && npm run generate-backend-client && npm run dev -- --host 0.0.0.0 + workingDir: frontend + command: npm run generate-backend-client && npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0 agentOnly: runtime: host From 0904d7fffeb0cc4ad1627b60ebc0340cfad74bcf Mon Sep 17 00:00:00 2001 From: Samuel Wilk <34423885+da-wilky@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:51:18 +0100 Subject: [PATCH 183/204] Add 'fast' query parameter to API definition (#8521) --- backend/windmill-api/openapi.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index a8f83c368a..825842f057 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -10644,6 +10644,10 @@ paths: in: query schema: type: boolean + - name: fast + in: query + schema: + type: boolean responses: "200": From 34cf0a0324627d4da6d4324ab662155045ddf327 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 13:51:34 +0000 Subject: [PATCH 184/204] show sync resource types button when resource type is missing (#8514) * feat: show sync resource types button when resource type is missing Co-Authored-By: Claude Opus 4.6 (1M context) * fix: show prominent error message when resource type is not found Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use sync_cached_resource_types endpoint instead of hub_sync script Co-Authored-By: Claude Opus 4.6 (1M context) * fix: fallback to fetching resource types from hub when cache file missing Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- backend/windmill-api-settings/src/lib.rs | 69 ++++++++++++--- .../src/lib/components/ApiConnectForm.svelte | 6 +- .../src/lib/components/AppConnectInner.svelte | 87 +++++++++---------- .../src/lib/components/ResourceEditor.svelte | 12 ++- .../lib/components/SyncResourceTypes.svelte | 41 +++++++++ 5 files changed, 153 insertions(+), 62 deletions(-) create mode 100644 frontend/src/lib/components/SyncResourceTypes.svelte diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index dbe80aca94..d1b52a8cc3 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -1124,6 +1124,59 @@ struct CachedResourceType { description: Option, } +#[derive(serde::Deserialize)] +struct HubResourceTypeRaw { + id: i64, + name: String, + schema: Option, + app: String, + description: Option, +} + +async fn fetch_resource_types_from_hub() -> error::Result> { + let response = HTTP_CLIENT + .get(format!( + "{}/resource_types/list", + windmill_common::DEFAULT_HUB_BASE_URL + )) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| error::Error::InternalErr(format!("Failed to fetch from hub: {}", e)))?; + + if !response.status().is_success() { + return Err(error::Error::InternalErr(format!( + "Hub returned status {}", + response.status() + ))); + } + + let raw_types: Vec = response + .json() + .await + .map_err(|e| error::Error::InternalErr(format!("Failed to parse hub response: {}", e)))?; + + Ok(raw_types + .into_iter() + .filter_map(|rt| { + let schema = match rt.schema { + Some(s) => match serde_json::from_str(&s) { + Ok(v) => Some(v), + Err(_) => return None, + }, + None => None, + }; + Some(CachedResourceType { + id: rt.id, + name: rt.name, + schema, + app: rt.app, + description: rt.description, + }) + }) + .collect()) +} + async fn sync_cached_resource_types( Extension(db): Extension, authed: ApiAuthed, @@ -1133,16 +1186,12 @@ async fn sync_cached_resource_types( use windmill_common::worker::HUB_RT_CACHE_DIR; let cache_path = format!("{}/resource_types.json", *HUB_RT_CACHE_DIR); - let content = tokio::fs::read_to_string(&cache_path).await.map_err(|e| { - error::Error::NotFound(format!( - "No cached resource types found at {}: {}", - cache_path, e - )) - })?; - - let cached_types: Vec = serde_json::from_str(&content).map_err(|e| { - error::Error::InternalErr(format!("Failed to parse cached resource types: {}", e)) - })?; + let cached_types = match tokio::fs::read_to_string(&cache_path).await { + Ok(content) => serde_json::from_str::>(&content).map_err(|e| { + error::Error::InternalErr(format!("Failed to parse cached resource types: {}", e)) + })?, + Err(_) => fetch_resource_types_from_hub().await?, + }; let mut synced_count = 0; diff --git a/frontend/src/lib/components/ApiConnectForm.svelte b/frontend/src/lib/components/ApiConnectForm.svelte index 636a20f290..7d10db1d48 100644 --- a/frontend/src/lib/components/ApiConnectForm.svelte +++ b/frontend/src/lib/components/ApiConnectForm.svelte @@ -16,6 +16,7 @@ import BedrockCredentialsCheck from './BedrockCredentialsCheck.svelte' import { isCloudHosted } from '$lib/cloud' import ResourceGen from './copilot/ResourceGen.svelte' + import SyncResourceTypes from './SyncResourceTypes.svelte' interface Props { resourceType: string @@ -25,6 +26,7 @@ isValid?: boolean linkedSecretCandidates?: string[] | undefined description?: string | undefined + onSynced?: () => void } let { @@ -34,7 +36,8 @@ linkedSecrets = $bindable([]), isValid = $bindable(true), linkedSecretCandidates = undefined, - description = $bindable(undefined) + description = $bindable(undefined), + onSynced = undefined }: Props = $props() let schema = $state(emptySchema()) @@ -227,6 +230,7 @@ >No corresponding resource type found in your workspace for {resourceType}. Define the value in JSON directly

    + {/if} {#if notFound || viewJsonSchema} {#if !emptyString(error)} import { run } from 'svelte/legacy' - import { superadmin, userStore, workspaceStore } from '$lib/stores' + import { userStore, workspaceStore } from '$lib/stores' import IconedResourceType from './IconedResourceType.svelte' import { OauthService, ResourceService, VariableService, type TokenResponse, - type ResourceType, - JobService + type ResourceType } from '$lib/gen' import { emptyString, truncateRev, urlize } from '$lib/utils' import { createEventDispatcher, onDestroy } from 'svelte' @@ -31,9 +30,8 @@ import type { SchemaProperty } from '$lib/common' import Tooltip from './Tooltip.svelte' import TextInput from './text_input/TextInput.svelte' - import { usePromise } from '$lib/svelte5Utils.svelte' - import { pollJobResult } from './jobs/utils' import { sameTopDomainOrigin } from '$lib/cookies' + import SyncResourceTypes from './SyncResourceTypes.svelte' interface Props { step?: number @@ -135,6 +133,7 @@ let tokenUrl = $state('') let resourceTypeInfo: ResourceType | undefined = $state(undefined) + let resourceTypeNotFound = $state(false) let pathError = $state('') @@ -319,18 +318,24 @@ } async function getResourceTypeInfo() { - resourceTypeInfo = await ResourceService.getResourceType({ - workspace: effectiveWorkspace, - path: resourceType - }) - const props: Record = resourceTypeInfo?.schema?.['properties'] ?? {} - const newArgsKeys = Object.keys(props).filter((x) => props?.[x]?.type == 'string') ?? [] + try { + resourceTypeNotFound = false + resourceTypeInfo = await ResourceService.getResourceType({ + workspace: effectiveWorkspace, + path: resourceType + }) + const props: Record = resourceTypeInfo?.schema?.['properties'] ?? {} + const newArgsKeys = Object.keys(props).filter((x) => props?.[x]?.type == 'string') ?? [] - const passwords = newArgsKeys.filter((x) => { - return props?.[x]?.password - }) - if (linkedSecrets.length === 0) { - linkedSecrets = computeDefaultLinkedSecrets(resourceType, newArgsKeys, passwords) + const passwords = newArgsKeys.filter((x) => { + return props?.[x]?.password + }) + if (linkedSecrets.length === 0) { + linkedSecrets = computeDefaultLinkedSecrets(resourceType, newArgsKeys, passwords) + } + } catch (err) { + resourceTypeInfo = undefined + resourceTypeNotFound = true } } export async function next() { @@ -589,23 +594,6 @@ let filteredConnectsManual: { key: string; img?: string; instructions: string[] }[] = $state([]) let editScopes = $state(false) - - let hubRtSync = usePromise( - async () => { - let jobUuid = await JobService.runScriptByPath({ - workspace: 'admins', - path: 'u/admin/hub_sync', - requestBody: {} - }) - await pollJobResult(jobUuid, 'admins') - connectsManual = undefined - await loadResourceTypes() - connects = undefined - await loadConnects() - sendUserToast('Hub resource types sync completed') - }, - { loadInit: false } - ) {#if !express} @@ -722,20 +710,16 @@ {/each} {/if}
    - {#if $superadmin} - - {#if hubRtSync.status === 'error'} - - Error syncing resource types : {JSON.stringify(hubRtSync.error)} - - {/if} - {/if} +
    + { + connectsManual = undefined + await loadResourceTypes() + connects = undefined + await loadConnects() + }} + /> +
    {:else if step == 2 && manual}
    + {#if resourceTypeNotFound} +
    +

    + Resource type '{resourceType}' not found in your workspace +

    + +
    + {/if} {#key resourceTypeInfo} {/key}
    diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 3956cea7eb..416068bf8b 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -25,6 +25,7 @@ import Button from './common/button/Button.svelte' import { clearJsonSchemaResourceCache } from './schema/jsonSchemaResource.svelte' import ResourceGen from './copilot/ResourceGen.svelte' + import SyncResourceTypes from './SyncResourceTypes.svelte' interface Props { canSave?: boolean @@ -347,10 +348,13 @@ {:else} {#if !viewJsonSchema} -

    - No corresponding resource type found in your workspace for {resource_type}. Define the - value in JSON directly -

    +
    +

    + Resource type '{resource_type}' not found in your workspace +

    + +

    Define the value in JSON directly

    +
    {/if} {#if !emptyString(jsonError)} + import { superadmin } from '$lib/stores' + import { usePromise } from '$lib/svelte5Utils.svelte' + import { sendUserToast } from '$lib/toast' + import Button from './common/button/Button.svelte' + + interface Props { + onSynced?: () => void + } + + let { onSynced = undefined }: Props = $props() + + let hubRtSync = usePromise( + async () => { + const res = await fetch('/api/settings/sync_cached_resource_types', { method: 'POST' }) + if (!res.ok) { + const body = await res.text() + throw new Error(body || res.statusText) + } + sendUserToast('Hub resource types sync completed') + onSynced?.() + }, + { loadInit: false } + ) + + +{#if $superadmin} + + {#if hubRtSync.status === 'error'} + + Error syncing resource types: {hubRtSync.error?.message ?? JSON.stringify(hubRtSync.error)} + + {/if} +{/if} From 031766808945aefc926f0836d011c0b2a5d2243d Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 14:33:20 +0000 Subject: [PATCH 185/204] fix: require admin for workspace encryption key export (#8523) Move the require_admin check from blocking the entire tarball export to only guarding the include_key=true path. Non-admins can still export tarballs for workspace sync/git, but only admins can export the raw workspace encryption key. Co-authored-by: Claude Opus 4.6 (1M context) --- backend/windmill-api/src/workspaces_export.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index d685d4c4cc..6c6c783ba7 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -388,8 +388,6 @@ pub(crate) async fn tarball_workspace( settings_version, }): Query, ) -> Result<([(HeaderName, String); 2], impl IntoResponse)> { - // require_admin(authed.is_admin, &authed.username)?; - tracing::info!( "tarball_workspace called for workspace {}: include_workspace_dependencies={:?}, skip_variables={:?}, skip_resources={:?}", w_id, @@ -1078,6 +1076,8 @@ pub(crate) async fn tarball_workspace( } if include_key.unwrap_or(false) { + require_admin(authed.is_admin, &authed.username)?; + let key = sqlx::query_scalar!( "SELECT key FROM workspace_key WHERE workspace_id = $1", &w_id From 8a32322c187ccc60ec7eafb61a9678f267a82282 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:38:29 +0100 Subject: [PATCH 186/204] fix: auto-generate datatable SDK reference for app mode system prompt (#8522) The app mode AI chat system prompt had hand-written datatable API docs that were missing methods (fetchOneScalar, execute, query). This adds datatable-specific extraction to generate.py so the prompt stays in sync with the actual TypeScript and Python client APIs. Co-authored-by: Claude Opus 4.6 (1M context) --- .../lib/components/copilot/chat/app/core.ts | 17 +- system_prompts/auto-generated/index.d.ts | 1 + system_prompts/auto-generated/index.ts | 8 + system_prompts/auto-generated/prompts.ts | 148 ++++++++++++ .../auto-generated/sdks/datatable-python.md | 68 ++++++ .../sdks/datatable-typescript.md | 76 +++++++ system_prompts/generate.py | 215 ++++++++++++++++++ 7 files changed, 521 insertions(+), 12 deletions(-) create mode 100644 system_prompts/auto-generated/sdks/datatable-python.md create mode 100644 system_prompts/auto-generated/sdks/datatable-typescript.md diff --git a/frontend/src/lib/components/copilot/chat/app/core.ts b/frontend/src/lib/components/copilot/chat/app/core.ts index 8fc88d4d16..c6a64a3e5a 100644 --- a/frontend/src/lib/components/copilot/chat/app/core.ts +++ b/frontend/src/lib/components/copilot/chat/app/core.ts @@ -10,6 +10,7 @@ import { createGetRunnableDetailsTool, type Tool } from '../shared' +import { getDatatableSdkReference } from '$system_prompts' import { aiChatManager } from '../AIChatManager.svelte' import type { ContextElement, @@ -842,38 +843,30 @@ For inline scripts, the code must have a \`main\` function as its entrypoint. Backend runnables should only perform **data operations** (SELECT, INSERT, UPDATE, DELETE) on **existing tables**. Never use CREATE TABLE, DROP TABLE, or ALTER TABLE inside runnables. -**TypeScript (Bun)**: +**TypeScript (Bun) example**: \`\`\`typescript import * as wmill from 'windmill-client'; export async function main(user_id: string) { const sql = ${datatableCall}; - - // Safe string interpolation (parameterized query) const user = await sql\`SELECT * FROM ${schemaPrefix}users WHERE id = \${user_id}\`.fetchOne(); return user; } \`\`\` -**Python**: +**Python example**: \`\`\`python import wmill def main(user_id: str): db = ${datatableCall} - - # Use positional arguments ($1, $2, etc.) user = db.query('SELECT * FROM ${schemaPrefix}users WHERE id = $1', user_id).fetch_one() return user \`\`\` -### Common Operations (for use in backend runnables) +### Datatable Client API Reference -- **Fetch all**: \`sql\`SELECT * FROM ${schemaPrefix}table\`.fetch()\` or \`db.query('SELECT * FROM ${schemaPrefix}table').fetch()\` -- **Fetch one**: \`.fetchOne()\` or \`.fetch_one()\` -- **Insert**: \`sql\`INSERT INTO ${schemaPrefix}table (col) VALUES (\${value})\`\` -- **Update**: \`sql\`UPDATE ${schemaPrefix}table SET col = \${value} WHERE id = \${id}\`\` -- **Delete**: \`sql\`DELETE FROM ${schemaPrefix}table WHERE id = \${id}\`\` +${getDatatableSdkReference()} ### Schema Modifications (DDL) - Use exec_datatable_sql tool ONLY diff --git a/system_prompts/auto-generated/index.d.ts b/system_prompts/auto-generated/index.d.ts index ab649a2090..4f2dd469ac 100644 --- a/system_prompts/auto-generated/index.d.ts +++ b/system_prompts/auto-generated/index.d.ts @@ -1,3 +1,4 @@ export * from './prompts'; export declare function getScriptPrompt(language: string): string; export declare function getFlowPrompt(): string; +export declare function getDatatableSdkReference(): string; diff --git a/system_prompts/auto-generated/index.ts b/system_prompts/auto-generated/index.ts index b283892470..b2c4bf0ec8 100644 --- a/system_prompts/auto-generated/index.ts +++ b/system_prompts/auto-generated/index.ts @@ -37,3 +37,11 @@ export function getFlowPrompt(): string { prompts.OPENFLOW_SCHEMA ].filter(Boolean).join('\n\n'); } + +// Helper to get datatable SDK reference for app mode +export function getDatatableSdkReference(): string { + return [ + prompts.DATATABLE_SDK_TYPESCRIPT, + prompts.DATATABLE_SDK_PYTHON + ].filter(Boolean).join('\n\n'); +} diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index d30a5be3eb..6eb870e62b 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1371,6 +1371,154 @@ async def parallel(items, fn, concurrency: Optional[int] = None) # offset: Message offset to commit (from event['offset']) def commit_kafka_offsets(trigger_path: str, topic: str, partition: int, offset: int) -> None +`; + +export const DATATABLE_SDK_TYPESCRIPT = `## TypeScript Datatable API (windmill-client) + +Import: \`import * as wmill from 'windmill-client'\` + +SQL statement object with query content, arguments, and execution methods +\`\`\`typescript +type SqlStatement = { + /** Raw SQL content with formatted arguments */ + content: string; + + /** Argument values keyed by parameter name */ + args: Record; + + /** + * Execute the SQL query and return results + * @param params - Optional parameters including result collection mode + * @returns Query results based on the result collection mode + */ + fetch( + params?: FetchParams // The union is for auto-completion + ): Promise>; + + /** + * Execute the SQL query and return only the first row + * @param params - Optional parameters + * @returns First row of the query result + */ + fetchOne( + params?: Omit, "resultCollection"> + ): Promise>; + + /** + * Execute the SQL query and return only the first row as a scalar value + * @param params - Optional parameters + * @returns First row of the query result + */ + fetchOneScalar( + params?: Omit< + FetchParams<"last_statement_first_row_scalar">, + "resultCollection" + > + ): Promise>; + + /** + * Execute the SQL query without fetching rows + * @param params - Optional parameters + */ + execute( + params?: Omit, "resultCollection"> + ): Promise; +}; +\`\`\` + +\`\`\`typescript +// Template tag function: sql\`SELECT * FROM table WHERE id = \${id}\`.fetch() +interface DatatableSqlTemplateFunction { + // Tagged template usage: + (strings: TemplateStringsArray, ...values: any[]): SqlStatement; + query(sql: string, ...params: any[]): SqlStatement; +}; +\`\`\` + +Create a SQL template function for PostgreSQL/datatable queries +@param name - Database/datatable name (default: "main") +@returns SQL template function for building parameterized queries +@example +let sql = wmill.datatable() +let name = 'Robin' +let age = 21 +await sql\` + SELECT * FROM friends + WHERE name = \${name} AND age = \${age}::int +\`.fetch() +\`\`\`typescript +function datatable(name: string = "main"): DatatableSqlTemplateFunction +\`\`\` +`; + +export const DATATABLE_SDK_PYTHON = `## Python Datatable API (wmill) + +Import: \`import wmill\` + +# Get a DataTable client for SQL queries. +# +# Args: +# name: Database name (default: "main") +# +# Returns: +# DataTableClient instance +def datatable(name: str = 'main') -> DataTableClient + +# Client for executing SQL queries against Windmill DataTables. +class DataTableClient: + # Initialize DataTableClient. + # + # Args: + # client: Windmill client instance + # name: DataTable name + def __init__(client: Windmill, name: str) + + # Execute a SQL query against the DataTable. + # + # Args: + # sql: SQL query string with $1, $2, etc. placeholders + # *args: Positional arguments to bind to query placeholders + # + # Returns: + # SqlQuery instance for fetching results + def query(sql: str, *args) -> SqlQuery + + +# Query result handler for DataTable and DuckLake queries. +class SqlQuery: + # Initialize SqlQuery. + # + # Args: + # sql: SQL query string + # fetch_fn: Function to execute the query + def __init__(sql: str, fetch_fn) + + # Execute query and fetch results. + # + # Args: + # result_collection: Optional result collection mode + # + # Returns: + # Query results + def fetch(result_collection: str | None = None) + + # Execute query and fetch first row of results. + # + # Returns: + # First row of query results + def fetch_one() + + # Execute query and fetch first row of results. Return result as a scalar value. + # + # Returns: + # First row of query result as a scalar value + def fetch_one_scalar() + + # Execute query and don't return any results. + # + def execute() + + `; export const OPENFLOW_SCHEMA = `## OpenFlow Schema diff --git a/system_prompts/auto-generated/sdks/datatable-python.md b/system_prompts/auto-generated/sdks/datatable-python.md new file mode 100644 index 0000000000..752019a9a3 --- /dev/null +++ b/system_prompts/auto-generated/sdks/datatable-python.md @@ -0,0 +1,68 @@ +## Python Datatable API (wmill) + +Import: `import wmill` + +# Get a DataTable client for SQL queries. +# +# Args: +# name: Database name (default: "main") +# +# Returns: +# DataTableClient instance +def datatable(name: str = 'main') -> DataTableClient + +# Client for executing SQL queries against Windmill DataTables. +class DataTableClient: + # Initialize DataTableClient. + # + # Args: + # client: Windmill client instance + # name: DataTable name + def __init__(client: Windmill, name: str) + + # Execute a SQL query against the DataTable. + # + # Args: + # sql: SQL query string with $1, $2, etc. placeholders + # *args: Positional arguments to bind to query placeholders + # + # Returns: + # SqlQuery instance for fetching results + def query(sql: str, *args) -> SqlQuery + + +# Query result handler for DataTable and DuckLake queries. +class SqlQuery: + # Initialize SqlQuery. + # + # Args: + # sql: SQL query string + # fetch_fn: Function to execute the query + def __init__(sql: str, fetch_fn) + + # Execute query and fetch results. + # + # Args: + # result_collection: Optional result collection mode + # + # Returns: + # Query results + def fetch(result_collection: str | None = None) + + # Execute query and fetch first row of results. + # + # Returns: + # First row of query results + def fetch_one() + + # Execute query and fetch first row of results. Return result as a scalar value. + # + # Returns: + # First row of query result as a scalar value + def fetch_one_scalar() + + # Execute query and don't return any results. + # + def execute() + + diff --git a/system_prompts/auto-generated/sdks/datatable-typescript.md b/system_prompts/auto-generated/sdks/datatable-typescript.md new file mode 100644 index 0000000000..0256515911 --- /dev/null +++ b/system_prompts/auto-generated/sdks/datatable-typescript.md @@ -0,0 +1,76 @@ +## TypeScript Datatable API (windmill-client) + +Import: `import * as wmill from 'windmill-client'` + +SQL statement object with query content, arguments, and execution methods +```typescript +type SqlStatement = { + /** Raw SQL content with formatted arguments */ + content: string; + + /** Argument values keyed by parameter name */ + args: Record; + + /** + * Execute the SQL query and return results + * @param params - Optional parameters including result collection mode + * @returns Query results based on the result collection mode + */ + fetch( + params?: FetchParams // The union is for auto-completion + ): Promise>; + + /** + * Execute the SQL query and return only the first row + * @param params - Optional parameters + * @returns First row of the query result + */ + fetchOne( + params?: Omit, "resultCollection"> + ): Promise>; + + /** + * Execute the SQL query and return only the first row as a scalar value + * @param params - Optional parameters + * @returns First row of the query result + */ + fetchOneScalar( + params?: Omit< + FetchParams<"last_statement_first_row_scalar">, + "resultCollection" + > + ): Promise>; + + /** + * Execute the SQL query without fetching rows + * @param params - Optional parameters + */ + execute( + params?: Omit, "resultCollection"> + ): Promise; +}; +``` + +```typescript +// Template tag function: sql`SELECT * FROM table WHERE id = ${id}`.fetch() +interface DatatableSqlTemplateFunction { + // Tagged template usage: + (strings: TemplateStringsArray, ...values: any[]): SqlStatement; + query(sql: string, ...params: any[]): SqlStatement; +}; +``` + +Create a SQL template function for PostgreSQL/datatable queries +@param name - Database/datatable name (default: "main") +@returns SQL template function for building parameterized queries +@example +let sql = wmill.datatable() +let name = 'Robin' +let age = 21 +await sql` + SELECT * FROM friends + WHERE name = ${name} AND age = ${age}::int +`.fetch() +```typescript +function datatable(name: string = "main"): DatatableSqlTemplateFunction +``` diff --git a/system_prompts/generate.py b/system_prompts/generate.py index cf57a3cecb..034c94e20d 100644 --- a/system_prompts/generate.py +++ b/system_prompts/generate.py @@ -655,6 +655,202 @@ def generate_schema_files(cli_schemas: dict[str, dict]) -> dict[str, str]: return schema_yaml_content +# ============================================================================= +# Datatable SDK Extraction +# ============================================================================= + + +TS_SQL_UTILS_PATH = TS_SDK_DIR / "sqlUtils.ts" + + +def extract_datatable_ts_sdk() -> str: + """Extract datatable-specific type definitions from TypeScript SDK (sqlUtils.ts). + + Reads the source file and extracts the public API surface: + - SqlStatement type (fetch, fetchOne, fetchOneScalar, execute methods) + - DatatableSqlTemplateFunction interface (template tag + query method) + - datatable() function signature + """ + if not TS_SQL_UTILS_PATH.exists(): + print(f" Warning: sqlUtils.ts not found at {TS_SQL_UTILS_PATH}") + return '' + + content = TS_SQL_UTILS_PATH.read_text() + + md = "## TypeScript Datatable API (windmill-client)\n\n" + md += "Import: `import * as wmill from 'windmill-client'`\n\n" + + # Extract exported type/interface/function definitions from sqlUtils.ts + # We use extract_balanced to handle nested braces correctly + + # 1. Extract SqlStatement type + match = re.search(r'(\/\*\*(?:[^*]|\*(?!\/))*\*\/\s*)?export\s+type\s+SqlStatement\s*=\s*', content) + if match: + jsdoc_raw = match.group(1) + brace_start = content.index('{', match.end() - 1) + body, end = extract_balanced(content, brace_start, '{', '}') + if end != -1: + if jsdoc_raw: + md += clean_jsdoc(jsdoc_raw) + "\n" + md += "```typescript\n" + md += f"type SqlStatement = {{\n{_indent_body(body)}\n}};\n" + md += "```\n\n" + + # 2. Extract DatatableSqlTemplateFunction interface + match = re.search( + r'(\/\*\*(?:[^*]|\*(?!\/))*\*\/\s*)?export\s+interface\s+DatatableSqlTemplateFunction\s+extends\s+SqlTemplateFunction\s*', + content + ) + if match: + brace_start = content.index('{', match.end() - 1) + body, end = extract_balanced(content, brace_start, '{', '}') + if end != -1: + md += "```typescript\n" + md += "// Template tag function: sql`SELECT * FROM table WHERE id = ${id}`.fetch()\n" + md += f"interface DatatableSqlTemplateFunction {{\n" + md += f" // Tagged template usage:\n" + md += f" (strings: TemplateStringsArray, ...values: any[]): SqlStatement;\n" + md += f"{_indent_body(body)}\n" + md += "};\n" + md += "```\n\n" + + # 3. Extract datatable() function + match = re.search( + r'(\/\*\*(?:[^*]|\*(?!\/))*\*\/\s*)?export\s+function\s+datatable\s*\(([^)]*)\)\s*:\s*(\S+)', + content + ) + if match: + jsdoc_raw, params, return_type = match.groups() + if jsdoc_raw: + md += clean_jsdoc(jsdoc_raw) + "\n" + md += "```typescript\n" + md += f"function datatable({params.strip()}): {return_type}\n" + md += "```\n" + + return md + + +def extract_datatable_py_sdk(py_content: str) -> str: + """Extract datatable-specific class/function definitions from Python SDK. + + Uses Python AST to extract: + - datatable() function + - DataTableClient class with query() method + - SqlQuery class with fetch(), fetch_one(), fetch_one_scalar(), execute() methods + """ + if not py_content: + return '' + + try: + tree = ast.parse(py_content) + except SyntaxError as e: + print(f" Warning: Could not parse Python SDK for datatable extraction: {e}") + return '' + + md = "## Python Datatable API (wmill)\n\n" + md += "Import: `import wmill`\n\n" + + # Target classes and the top-level datatable function + target_classes = {'DataTableClient', 'SqlQuery'} + + # 1. Extract datatable() top-level function + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == 'datatable': + docstring = ast.get_docstring(node) or '' + params = _format_py_params(node) + return_ann = f" -> {ast.unparse(node.returns)}" if node.returns else '' + if docstring: + for line in docstring.split('\n'): + md += f"# {line}\n" + md += f"def datatable({params}){return_ann}\n\n" + break + + # 2. Extract target classes with their public methods + for node in tree.body: + if isinstance(node, ast.ClassDef) and node.name in target_classes: + class_doc = ast.get_docstring(node) or '' + if class_doc: + for line in class_doc.split('\n'): + md += f"# {line}\n" + md += f"class {node.name}:\n" + + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + if item.name.startswith('_') and item.name != '__init__': + continue + docstring = ast.get_docstring(item) or '' + params = _format_py_params(item, skip_self=True) + return_ann = f" -> {ast.unparse(item.returns)}" if item.returns else '' + async_prefix = 'async ' if isinstance(item, ast.AsyncFunctionDef) else '' + if docstring: + for line in docstring.split('\n'): + md += f" # {line}\n" + md += f" {async_prefix}def {item.name}({params}){return_ann}\n\n" + + md += "\n" + + return md + + +def _format_py_params(node: ast.FunctionDef, skip_self: bool = False) -> str: + """Format function parameters from AST node.""" + params = [] + args = node.args + num_defaults = len(args.defaults) + num_args = len(args.args) + + for i, arg in enumerate(args.args): + if skip_self and arg.arg == 'self': + continue + param_str = arg.arg + if arg.annotation: + param_str += f": {ast.unparse(arg.annotation)}" + default_idx = i - (num_args - num_defaults) + if default_idx >= 0: + default = args.defaults[default_idx] + param_str += f" = {ast.unparse(default)}" + params.append(param_str) + + if args.vararg: + vararg_str = f"*{args.vararg.arg}" + if args.vararg.annotation: + vararg_str += f": {ast.unparse(args.vararg.annotation)}" + params.append(vararg_str) + + for i, arg in enumerate(args.kwonlyargs): + param_str = arg.arg + if arg.annotation: + param_str += f": {ast.unparse(arg.annotation)}" + if args.kw_defaults[i]: + param_str += f" = {ast.unparse(args.kw_defaults[i])}" + params.append(param_str) + + if args.kwarg: + kwarg_str = f"**{args.kwarg.arg}" + if args.kwarg.annotation: + kwarg_str += f": {ast.unparse(args.kwarg.annotation)}" + params.append(kwarg_str) + + return ', '.join(params) + + +def _indent_body(body: str) -> str: + """Clean and re-indent a type body for readable output.""" + lines = body.strip().split('\n') + result = [] + for line in lines: + stripped = line.strip() + if stripped: + # Keep JSDoc comments and method signatures with consistent indentation + if not stripped.startswith('//') and not stripped.startswith('/*') and not stripped.startswith('*'): + result.append(f" {stripped}") + else: + result.append(f" {stripped}") + else: + result.append('') + return '\n'.join(result) + + # ============================================================================= # Skill Generation # ============================================================================= @@ -947,6 +1143,13 @@ def main(): (OUTPUT_SDKS_DIR / "python.md").write_text(py_sdk_md) print(f" Found {len(py_functions)} functions, {len(py_classes)} classes") + # Extract datatable-specific SDK docs (for app mode system prompt) + print("Extracting datatable SDK docs...") + datatable_ts_md = extract_datatable_ts_sdk() + datatable_py_md = extract_datatable_py_sdk(py_content) + (OUTPUT_SDKS_DIR / "datatable-typescript.md").write_text(datatable_ts_md) + (OUTPUT_SDKS_DIR / "datatable-python.md").write_text(datatable_py_md) + # Read base prompts print("Assembling complete prompts...") base_dir = SCRIPT_DIR / "base" @@ -1009,6 +1212,10 @@ def main(): 'SDK_TYPESCRIPT': ts_sdk_md, 'SDK_PYTHON': py_sdk_md, + # Datatable-specific SDK docs (for app mode) + 'DATATABLE_SDK_TYPESCRIPT': datatable_ts_md, + 'DATATABLE_SDK_PYTHON': datatable_py_md, + # Schema (raw YAML content) 'OPENFLOW_SCHEMA': openflow_content, @@ -1077,6 +1284,14 @@ export function getFlowPrompt(): string { prompts.OPENFLOW_SCHEMA ].filter(Boolean).join('\\n\\n'); } + +// Helper to get datatable SDK reference for app mode +export function getDatatableSdkReference(): string { + return [ + prompts.DATATABLE_SDK_TYPESCRIPT, + prompts.DATATABLE_SDK_PYTHON + ].filter(Boolean).join('\\n\\n'); +} """ (OUTPUT_GENERATED_DIR / "index.ts").write_text(index_content) From 4c8edd5e944d77ed2d41c2b87171c1115c0fdcdc Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 14:51:13 +0000 Subject: [PATCH 187/204] fix: restrict logout redirect to whitelisted domains (#8524) Co-authored-by: Claude Opus 4.5 --- backend/Cargo.lock | 1 + backend/windmill-api-users/Cargo.toml | 1 + backend/windmill-api-users/src/users.rs | 36 +++++++++++++++++-- frontend/src/lib/logoutRedirect.ts | 23 ++++++++++++ .../(logged)/user/(user)/logout/+page@.svelte | 7 +++- 5 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/logoutRedirect.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 34532bc173..c903490c8d 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -16386,6 +16386,7 @@ dependencies = [ "tokio", "tower-cookies", "tracing", + "url", "windmill-api-auth", "windmill-audit", "windmill-common", diff --git a/backend/windmill-api-users/Cargo.toml b/backend/windmill-api-users/Cargo.toml index e720b37eb7..13ab8143d8 100644 --- a/backend/windmill-api-users/Cargo.toml +++ b/backend/windmill-api-users/Cargo.toml @@ -34,3 +34,4 @@ time.workspace = true tokio.workspace = true tower-cookies.workspace = true tracing.workspace = true +url.workspace = true diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 8f7bec1398..75db0e2d24 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -49,13 +49,13 @@ use windmill_common::users::truncate_token; use windmill_common::users::COOKIE_NAME; use windmill_common::utils::paginate; use windmill_common::worker::CLOUD_HOSTED; -use windmill_common::BASE_URL; use windmill_common::{ auth::{get_folders_for_user, get_groups_for_user}, db::UserDB, error::{self, Error, JsonResult, Result}, utils::{not_found_if_none, rd_string, require_admin, Pagination, StripPath}, }; +use windmill_common::{BASE_URL, HUB_BASE_URL}; use windmill_git_sync::handle_deployment_metadata; const COOKIE_PATH: &str = "/"; @@ -577,12 +577,44 @@ async fn logout( } tx.commit().await?; if let Some(rd) = rd { - Ok((StatusCode::TEMPORARY_REDIRECT, [(LOCATION, rd)]).into_response()) + if is_valid_logout_redirect(&rd).await { + Ok((StatusCode::TEMPORARY_REDIRECT, [(LOCATION, rd)]).into_response()) + } else { + tracing::warn!("Blocked logout redirect to non-whitelisted URL: {}", rd); + Ok((StatusCode::OK, "logged out successfully".to_string()).into_response()) + } } else { Ok((StatusCode::OK, "logged out successfully".to_string()).into_response()) } } +async fn is_valid_logout_redirect(rd: &str) -> bool { + // Allow relative paths (same-origin redirects) + if rd.starts_with('/') && !rd.starts_with("//") { + return true; + } + let parsed = match url::Url::parse(rd) { + Ok(u) => u, + Err(_) => return false, + }; + let host: &str = match parsed.host_str() { + Some(h) => h, + None => return false, + }; + if host == "windmill.dev" || host.ends_with(".windmill.dev") { + return true; + } + let hub_url = HUB_BASE_URL.read().await.clone(); + if let Ok(hub_parsed) = url::Url::parse(&hub_url) { + if let Some(hub_host) = hub_parsed.host_str() { + if host == hub_host { + return true; + } + } + } + false +} + async fn whoami( Extension(db): Extension, Path(w_id): Path, diff --git a/frontend/src/lib/logoutRedirect.ts b/frontend/src/lib/logoutRedirect.ts new file mode 100644 index 0000000000..9b09880fd2 --- /dev/null +++ b/frontend/src/lib/logoutRedirect.ts @@ -0,0 +1,23 @@ +import { get } from 'svelte/store' +import { hubBaseUrlStore } from './stores' + +export function isValidLogoutRedirect(url: string): boolean { + if (url.startsWith('/') && !url.startsWith('//')) { + return true + } + try { + const parsed = new URL(url) + const host = parsed.hostname + if (host === 'windmill.dev' || host.endsWith('.windmill.dev')) { + return true + } + const hubBaseUrl = get(hubBaseUrlStore) + try { + const hubHost = new URL(hubBaseUrl).hostname + if (host === hubHost) { + return true + } + } catch {} + } catch {} + return false +} diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte index e328c37f5a..75e31a9a00 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte @@ -2,6 +2,7 @@ import { page } from '$app/state' import CenteredModal from '$lib/components/CenteredModal.svelte' import { clearUser } from '$lib/logout' + import { isValidLogoutRedirect } from '$lib/logoutRedirect' import { userStore } from '$lib/stores' import { onMount } from 'svelte' @@ -29,7 +30,11 @@ return } - window.location.href = rd ?? '/user/login' + if (rd && isValidLogoutRedirect(rd)) { + window.location.href = rd + } else { + window.location.href = '/user/login' + } }) From c28314f424ea0e04b86565ce88e6c91e0df1a0cf Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 25 Mar 2026 16:13:04 +0100 Subject: [PATCH 188/204] feat: runner groups for shared-process multi-script dedicated workers (#8434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add runner groups for shared-process multi-script dedicated workers Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: unify dedicated worker and runner group wrappers into single multi-script wrapper Replace per-language single-script wrappers with the unified load/exec/exec_preprocess/end protocol. Each start_worker() now writes scripts to scripts// and uses generate_multi_script_wrapper(). handle_dedicated_process() sends load: on start and exec: per job instead of raw JSON args. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: merge runner groups into dedicated workers with inline arg metadata Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to match EE branch Co-Authored-By: Claude Opus 4.6 (1M context) * fix: gate EE-only functions behind cfg(feature = "private") to fix OSS dead_code errors Co-Authored-By: Claude Opus 4.6 (1M context) * feat: auto-detect runner groups from workspace dependency annotations - New endpoint GET /scripts/list_dedicated_with_deps: returns dedicated scripts with parsed workspace dependency names from content annotations - Frontend: show dep badges in DedicatedWorkersSelector with links to workspace settings, warn when referenced dep doesn't exist, group scripts sharing deps into "Shared runner" sections - Remove manual "Runner groups" tab and RunnerGroupSelector component - Remove runner_groups from WorkerConfigOpt/WorkerConfig (auto-detected) - Fix Node.js single dedicated workers: transpile main.ts -> main.js via Bun.build so the multi-script wrapper's dynamic import() works under Node - Add package.json with type:module in scripts dir to silence Node warning Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: unify dedicated worker wrappers with baked-in codegen and routing Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * test: add e2e tests for multi-script dedicated worker routing (bun, deno, python) Co-Authored-By: Claude Opus 4.6 (1M context) * chore: remove dead generate_dedicated_worker_wrapper function Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add dependency installation to runner groups + make dep functions pub(crate) Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * fix: prevent bun loader from intercepting absolute paths within cwd When a plugin's onResolve returns an absolute path, Bun re-invokes the resolver with that path. The loader was then routing it through the remote URL resolver, breaking runner group script imports. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use _wm_ prefix for runner group scripts to avoid bun loader interception Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: extract DENO_UNSTABLE_ARGS constant to avoid repeating flags Co-Authored-By: Claude Opus 4.6 (1M context) * chore: regenerate system prompts Co-Authored-By: Claude Opus 4.6 (1M context) * fix: gate private-only exports behind cfg(feature = "private") for OSS build Co-Authored-By: Claude Opus 4.6 (1M context) * fix: move format strings before handle_dedicated_process to fix lifetime Co-Authored-By: Claude Opus 4.6 (1M context) * chore: regenerate sqlx offline cache Co-Authored-By: Claude Opus 4.6 (1M context) * fix sqlx * fix: skip empty lines in deno e2e tests (double newline from console.log + '\n') Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use dict() instead of {{}} in python wrapper to avoid set literal {{{{}}}} in format!() produces {{}} which Python interprets as an empty set, not a dict. Use dict() which is unambiguous. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove deno from runner groups and associated tests Deno resolves dependencies at runtime via URLs/import maps, so there's no shared node_modules/pip install to benefit from runner groups. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: revert deno wrapper to inline old-style with exec: protocol Since deno doesn't support runner groups, the unified multi-script wrapper is unnecessary. Reverted to the old inline wrapper from main but adapted to use the exec:: protocol. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: extract deno wrapper into reusable function and add e2e tests Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use codebase presence (not nodejs annotation) to determine wrapper import extension On main, codebase scripts import ./main.js (pre-bundled JS). The wrapper_ext was incorrectly based on annotation.nodejs. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: improve dedicated workers UI - combine lists, better badges, tooltips - Merge shared runners section with selected tags into one unified list - Move language tag to right side of selector for alignment - Change dep badge color from dark-gray to indigo - Add tooltip on yellow warning badge explaining missing workspace dep Co-Authored-By: Claude Opus 4.6 (1M context) * feat: group shared runners visually in dedicated workers list - Runner groups shown with a header (Shared runner · language · dep badge) - Scripts in the same group nested under the header - Standalone scripts/flows shown after groups - Used Svelte snippet for reusable tag row rendering Co-Authored-By: Claude Opus 4.6 (1M context) * fix: improve visual separation between shared runner groups and standalone items Co-Authored-By: Claude Opus 4.6 (1M context) * feat: give standalone runners same header style as shared runners - Each standalone script/flow gets its own header row with bg-surface-secondary - Header shows "Dedicated runner" / "Flow runner" label, dep link, language badge - Shared runner header: swapped language and dep badge positions - Dep shown as inline link instead of badge in headers for cleaner look Co-Authored-By: Claude Opus 4.6 (1M context) * feat: inline standalone runner path in header, language badge on right edge, no max height - Standalone items: path shown directly in header row (no sub-row) - Language badge placed after flex-1 spacer (right-aligned) - Removed max-h-64 overflow constraint from the list Co-Authored-By: Claude Opus 4.6 (1M context) * feat: consistent badges across runner list - dep+language on right, depBadge snippet - Shared runner scripts: show (workspace) and language badge on right - Standalone items: dep badges and language badge on right (after flex-1) - Shared runner header: dep badge and language badge on right - Extract depBadge snippet to deduplicate dep badge rendering - Picker selector also uses depBadge snippet Co-Authored-By: Claude Opus 4.6 (1M context) * fix: show language badge on standalone items, hide from shared runner sub-items - Fetch script language from API when not available from workspace deps - Hide dep+language badges from tagRow when script is inside a runner group (already shown in the group header) - Standalone items now always show language badge Co-Authored-By: Claude Opus 4.6 (1M context) * fix: differentiate badge colors - gray for language, indigo for workspace deps Matches codebase convention: gray for metadata (like script hashes), indigo for linkable features/entities. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use transparent (bordered) badge for language - visible on all backgrounds Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use gray badge for language everywhere Co-Authored-By: Claude Opus 4.6 (1M context) * fix: revert skills.ts and AI files, add _wm_ exclusion to Windows loader - Revert cli/src/guidance/skills.ts to main (not our change) - Revert AI provider formatting changes (not our change) - Add _wm_ prefix exclusion to loader.bun.windows.js filterResolve Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update ee-repo-ref and regenerate system prompts after merge Co-Authored-By: Claude Opus 4.6 (1M context) * perf: use DISTINCT ON in list_dedicated_with_deps to dedup at DB level Avoids fetching all script versions and deduplicating in Rust. Addresses PR review feedback. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use sqlx query! macro for list_dedicated_with_deps and regenerate cache Co-Authored-By: Claude Opus 4.6 (1M context) * fix: dedicated worker review fixes and test coverage - Fix Python relative imports in dedicated workers (write loader.py, add import loader to wrapper when needed) - Move Python colon parsing inside try/except to prevent crashes on malformed stdin - Add indexOf guard in Bun/Deno wrappers for malformed protocol messages - Add stderr logging for unrecognized stdin commands in all wrappers - Remove asyncio handling from Python wrapper (consistent with normal path) - Add exec_preprocess protocol tests for Bun, Deno, and Python - Add argument transformation tests (dates, bytes, kwargs, sentinel) - Add relative import detection test for Python wrapper - Add PreprocessedArgs variant to DedicatedWorkerResult test helper Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove symlink from git and gate has_relative_imports behind private feature Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update ee-repo-ref for dedicated_worker_ee.rs changes Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add mixed exec+preprocess test to use ProtocolCmd::Exec variant Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove hanging deno missing-preprocessor test The Deno wrapper only generates the exec_preprocess handler when the script has a preprocessor function. Without one, the message is unrecognized and the test hangs reading stdout. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 182943e5ad9bf2a905ccdf07d4e346437fb329a9 This commit updates the EE repository reference after PR #466 was merged in windmill-ee-private. Previous ee-repo-ref: 995f701fe3754be6260fc6b679e5de8fc636e68a New ee-repo-ref: 182943e5ad9bf2a905ccdf07d4e346437fb329a9 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...6be1bceb79c82e7b5542f17b23b6d70cc02d6.json | 104 +++ ...7d5ee9490240a627b20a1037444845e39c5f.json} | 4 +- backend/ee-repo-ref.txt | 2 +- backend/tests/bun_jobs.rs | 769 +++++++++++++++++- backend/tests/python_jobs.rs | 461 +++++++++++ backend/windmill-api-scripts/src/scripts.rs | 69 +- backend/windmill-api/openapi.yaml | 54 ++ backend/windmill-common/src/ai_google.rs | 40 +- backend/windmill-common/src/worker.rs | 12 +- backend/windmill-test-utils/src/lib.rs | 10 + backend/windmill-worker/loader.bun.js | 2 +- backend/windmill-worker/loader.bun.windows.js | 2 +- backend/windmill-worker/src/bun_executor.rs | 360 +++++--- backend/windmill-worker/src/deno_executor.rs | 284 +++---- backend/windmill-worker/src/lib.rs | 19 +- .../windmill-worker/src/python_executor.rs | 499 +++++++++--- .../DedicatedWorkersSelector.svelte | 412 ++++++++-- 17 files changed, 2590 insertions(+), 513 deletions(-) create mode 100644 backend/.sqlx/query-f2fa27ed5020aa9c085176b25466be1bceb79c82e7b5542f17b23b6d70cc02d6.json rename backend/.sqlx/{query-d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90.json => query-fd4c5391107af34a3bf9b83b0c3f7d5ee9490240a627b20a1037444845e39c5f.json} (50%) diff --git a/backend/.sqlx/query-f2fa27ed5020aa9c085176b25466be1bceb79c82e7b5542f17b23b6d70cc02d6.json b/backend/.sqlx/query-f2fa27ed5020aa9c085176b25466be1bceb79c82e7b5542f17b23b6d70cc02d6.json new file mode 100644 index 0000000000..a0d7f223d0 --- /dev/null +++ b/backend/.sqlx/query-f2fa27ed5020aa9c085176b25466be1bceb79c82e7b5542f17b23b6d70cc02d6.json @@ -0,0 +1,104 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (path) path, language AS \"language: ScriptLang\", content FROM script\n WHERE workspace_id = $1\n AND archived = false\n AND dedicated_worker = true\n AND language = ANY($2::SCRIPT_LANG[])\n ORDER BY path, created_at DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "language: ScriptLang", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby" + ] + } + } + } + }, + { + "ordinal": 2, + "name": "content", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "script_lang[]", + "kind": { + "Array": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby" + ] + } + } + } + } + } + } + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "f2fa27ed5020aa9c085176b25466be1bceb79c82e7b5542f17b23b6d70cc02d6" +} diff --git a/backend/.sqlx/query-d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90.json b/backend/.sqlx/query-fd4c5391107af34a3bf9b83b0c3f7d5ee9490240a627b20a1037444845e39c5f.json similarity index 50% rename from backend/.sqlx/query-d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90.json rename to backend/.sqlx/query-fd4c5391107af34a3bf9b83b0c3f7d5ee9490240a627b20a1037444845e39c5f.json index e778c17bf6..50cf586c79 100644 --- a/backend/.sqlx/query-d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90.json +++ b/backend/.sqlx/query-fd4c5391107af34a3bf9b83b0c3f7d5ee9490240a627b20a1037444845e39c5f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO token\n (token_hash, token_prefix, token, label, super_admin, email)\n VALUES ($1, $2, $3, $4, $5, $6)", + "query": "INSERT INTO token\n (token_hash, token_prefix, token, label, super_admin, email)\n VALUES ($1, $2, $3, $4, $5, $6)", "describe": { "columns": [], "parameters": { @@ -15,5 +15,5 @@ }, "nullable": [] }, - "hash": "d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90" + "hash": "fd4c5391107af34a3bf9b83b0c3f7d5ee9490240a627b20a1037444845e39c5f" } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 7d86a6114e..263ec1de9a 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -b5c8af4df9ba2c39fdd494d7a40f9a92fbff8abc +182943e5ad9bf2a905ccdf07d4e346437fb329a9 diff --git a/backend/tests/bun_jobs.rs b/backend/tests/bun_jobs.rs index 2edfef8989..7183cf6e10 100644 --- a/backend/tests/bun_jobs.rs +++ b/backend/tests/bun_jobs.rs @@ -891,25 +891,34 @@ mod dedicated_worker_protocol { use std::process::{Command, Stdio}; use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult}; use windmill_worker::{ - build_loader, generate_dedicated_worker_wrapper, LoaderMode, BUN_DEDICATED_WORKER_ARGS, - BUN_PATH, NODE_BIN_PATH, + build_loader, compute_ts_codegen, generate_multi_script_wrapper, LoaderMode, TsScriptEntry, + BUN_DEDICATED_WORKER_ARGS, BUN_PATH, NODE_BIN_PATH, }; + const TEST_SCRIPT_PATH: &str = "f/test/script"; + /// Creates test worker files and optionally bundles for Node.js (like production) /// Returns the path to the wrapper file to execute fn create_test_worker_files( dir: &std::path::Path, script: &str, - arg_names: &[&str], bundle_for_node: bool, ) -> std::path::PathBuf { let dir_str = dir.to_str().unwrap(); + // Write main.ts at root (like production single-script) std::fs::write(dir.join("main.ts"), script).unwrap(); + let codegen = compute_ts_codegen(script); + let ext = if bundle_for_node { "js" } else { "ts" }; + let scripts = [TsScriptEntry { + import_name: "main", + original_path: TEST_SCRIPT_PATH, + codegen: &codegen, + }]; + let wrapper = generate_multi_script_wrapper(&scripts, ext); + if bundle_for_node { - // For Node.js: bundle to JavaScript first (like production's build_loader with LoaderMode::Node) - let wrapper = generate_dedicated_worker_wrapper(arg_names, "./main.js", None, None); - std::fs::write(dir.join("wrapper.mjs"), wrapper).unwrap(); + std::fs::write(dir.join("wrapper.mjs"), &wrapper).unwrap(); // Use the exact same build_loader function as production tokio::runtime::Runtime::new() @@ -919,7 +928,7 @@ mod dedicated_worker_protocol { "http://localhost:8000", "test_token", "test-workspace", - "f/test/script", + TEST_SCRIPT_PATH, LoaderMode::Node, &None, )) @@ -945,10 +954,8 @@ mod dedicated_worker_protocol { std::fs::rename(&bundled_path, &output_path).unwrap(); output_path } else { - // For Bun: use TypeScript directly (like production) - let wrapper = generate_dedicated_worker_wrapper(arg_names, "./main.ts", None, None); let wrapper_path = dir.join("wrapper.mjs"); - std::fs::write(&wrapper_path, wrapper).unwrap(); + std::fs::write(&wrapper_path, &wrapper).unwrap(); wrapper_path } } @@ -957,14 +964,12 @@ mod dedicated_worker_protocol { fn run_worker_test( runtime: &str, script: &str, - arg_names: &[&str], jobs: Vec, ) -> Vec> { let temp_dir = tempfile::tempdir().unwrap(); // Create files and get the wrapper path (bundled for node, raw for bun) - let wrapper_path = - create_test_worker_files(temp_dir.path(), script, arg_names, runtime == "node"); + let wrapper_path = create_test_worker_files(temp_dir.path(), script, runtime == "node"); let wrapper_str = wrapper_path.to_str().unwrap(); // Build args matching production behavior @@ -1008,7 +1013,8 @@ mod dedicated_worker_protocol { let mut results = Vec::new(); for job_args in jobs { - writeln!(stdin, "{}", job_args.to_string()).unwrap(); + // Protocol: exec:: + writeln!(stdin, "exec:{}:{}", TEST_SCRIPT_PATH, job_args.to_string()).unwrap(); stdin.flush().unwrap(); let mut response = String::new(); @@ -1043,12 +1049,7 @@ export function main(x: number, y: number): number { return x + y; } "#; - let results = run_worker_test( - "node", - script, - &["x", "y"], - vec![serde_json::json!({"x": 5, "y": 3})], - ); + let results = run_worker_test("node", script, vec![serde_json::json!({"x": 5, "y": 3})]); assert_eq!(results.len(), 1); assert_eq!(results[0], Ok(serde_json::json!(8))); @@ -1062,7 +1063,7 @@ export function main(n: number): number { } "#; let jobs: Vec = (1..=5).map(|i| serde_json::json!({"n": i})).collect(); - let results = run_worker_test("node", script, &["n"], jobs); + let results = run_worker_test("node", script, jobs); assert_eq!(results.len(), 5); for (i, result) in results.iter().enumerate() { @@ -1081,7 +1082,6 @@ export function main(msg: string): never { let results = run_worker_test( "node", script, - &["msg"], vec![serde_json::json!({"msg": "test error"})], ); @@ -1099,12 +1099,7 @@ export function main(x: number, y: number): number { return x + y; } "#; - let results = run_worker_test( - "bun", - script, - &["x", "y"], - vec![serde_json::json!({"x": 5, "y": 3})], - ); + let results = run_worker_test("bun", script, vec![serde_json::json!({"x": 5, "y": 3})]); assert_eq!(results.len(), 1); assert_eq!(results[0], Ok(serde_json::json!(8))); @@ -1118,7 +1113,7 @@ export function main(n: number): number { } "#; let jobs: Vec = (1..=5).map(|i| serde_json::json!({"n": i})).collect(); - let results = run_worker_test("bun", script, &["n"], jobs); + let results = run_worker_test("bun", script, jobs); assert_eq!(results.len(), 5); for (i, result) in results.iter().enumerate() { @@ -1137,7 +1132,6 @@ export function main(msg: string): never { let results = run_worker_test( "bun", script, - &["msg"], vec![serde_json::json!({"msg": "test error"})], ); @@ -1145,6 +1139,721 @@ export function main(msg: string): never { assert!(results[0].is_err()); assert_eq!(results[0], Err("test error".to_string())); } + + // ==================== Multi-Script (Runner Group) Tests ==================== + + /// Job to send to a specific script in a multi-script wrapper + struct MultiScriptJob { + script_path: String, + args: serde_json::Value, + } + + /// Creates a multi-script wrapper with multiple scripts as flat files, returns the wrapper path + fn create_multi_script_worker_files( + dir: &std::path::Path, + scripts: &[(&str, &str)], // (original_path, script_content) + ) -> std::path::PathBuf { + let mut entries_data = Vec::new(); + for (path, content) in scripts { + let safe_name = format!("_wm_{}", path.replace('/', "__")); + std::fs::write(dir.join(format!("{safe_name}.ts")), content).unwrap(); + entries_data.push((safe_name, path.to_string(), compute_ts_codegen(content))); + } + + let entries: Vec> = entries_data + .iter() + .map(|(safe, path, cg)| TsScriptEntry { + import_name: safe.as_str(), + original_path: path.as_str(), + codegen: cg, + }) + .collect(); + + let wrapper = generate_multi_script_wrapper(&entries, "ts"); + let wrapper_path = dir.join("wrapper.mjs"); + std::fs::write(&wrapper_path, &wrapper).unwrap(); + wrapper_path + } + + /// Helper to run a multi-script dedicated worker test + fn run_multi_script_worker_test( + scripts: &[(&str, &str)], + jobs: Vec, + ) -> Vec> { + let temp_dir = tempfile::tempdir().unwrap(); + let wrapper_path = create_multi_script_worker_files(temp_dir.path(), scripts); + let wrapper_str = wrapper_path.to_str().unwrap(); + + let mut cmd_args: Vec<&str> = BUN_DEDICATED_WORKER_ARGS.to_vec(); + cmd_args.push(wrapper_str); + + let mut child = Command::new(BUN_PATH.as_str()) + .args(cmd_args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(temp_dir.path()) + .spawn() + .expect("Failed to spawn worker process"); + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout); + + // Wait for "start" signal + let mut start_line = String::new(); + reader.read_line(&mut start_line).unwrap(); + assert_eq!( + parse_dedicated_worker_line(start_line.trim()), + DedicatedWorkerResult::Start, + "Expected 'start', got: {}", + start_line.trim() + ); + + let mut results = Vec::new(); + + for job in &jobs { + writeln!(stdin, "exec:{}:{}", job.script_path, job.args.to_string()).unwrap(); + stdin.flush().unwrap(); + + let mut response = String::new(); + reader.read_line(&mut response).unwrap(); + + match parse_dedicated_worker_line(response.trim()) { + DedicatedWorkerResult::Success(value) => results.push(Ok(value)), + DedicatedWorkerResult::Error(err) => { + let msg = err["message"] + .as_str() + .unwrap_or("Unknown error") + .to_string(); + results.push(Err(msg)); + } + other => panic!("Unexpected response: {:?}", other), + } + } + + writeln!(stdin, "end").unwrap(); + stdin.flush().unwrap(); + let _ = child.wait().expect("Worker process failed to exit"); + + results + } + + #[test] + fn test_multi_script_routing_basic() { + let script_add = r#" +export function main(a: number, b: number): number { + return a + b; +} +"#; + let script_mul = r#" +export function main(x: number, y: number): number { + return x * y; +} +"#; + let results = run_multi_script_worker_test( + &[("f/math/add", script_add), ("f/math/mul", script_mul)], + vec![ + MultiScriptJob { + script_path: "f/math/add".to_string(), + args: serde_json::json!({"a": 3, "b": 4}), + }, + MultiScriptJob { + script_path: "f/math/mul".to_string(), + args: serde_json::json!({"x": 5, "y": 6}), + }, + // Route back to add + MultiScriptJob { + script_path: "f/math/add".to_string(), + args: serde_json::json!({"a": 10, "b": 20}), + }, + ], + ); + + assert_eq!(results.len(), 3); + assert_eq!(results[0], Ok(serde_json::json!(7))); // 3 + 4 + assert_eq!(results[1], Ok(serde_json::json!(30))); // 5 * 6 + assert_eq!(results[2], Ok(serde_json::json!(30))); // 10 + 20 + } + + #[test] + fn test_multi_script_interleaved_jobs() { + let script_upper = r#" +export function main(s: string): string { + return s.toUpperCase(); +} +"#; + let script_len = r#" +export function main(s: string): number { + return s.length; +} +"#; + let results = run_multi_script_worker_test( + &[("f/str/upper", script_upper), ("f/str/len", script_len)], + vec![ + MultiScriptJob { + script_path: "f/str/upper".to_string(), + args: serde_json::json!({"s": "hello"}), + }, + MultiScriptJob { + script_path: "f/str/len".to_string(), + args: serde_json::json!({"s": "hello"}), + }, + MultiScriptJob { + script_path: "f/str/upper".to_string(), + args: serde_json::json!({"s": "world"}), + }, + MultiScriptJob { + script_path: "f/str/len".to_string(), + args: serde_json::json!({"s": "ab"}), + }, + ], + ); + + assert_eq!(results.len(), 4); + assert_eq!(results[0], Ok(serde_json::json!("HELLO"))); + assert_eq!(results[1], Ok(serde_json::json!(5))); + assert_eq!(results[2], Ok(serde_json::json!("WORLD"))); + assert_eq!(results[3], Ok(serde_json::json!(2))); + } + + #[test] + fn test_multi_script_unknown_path_error() { + let script = r#" +export function main(x: number): number { + return x; +} +"#; + let results = run_multi_script_worker_test( + &[("f/known", script)], + vec![MultiScriptJob { + script_path: "f/unknown".to_string(), + args: serde_json::json!({"x": 1}), + }], + ); + + assert_eq!(results.len(), 1); + assert!(results[0].is_err()); + assert!(results[0] + .as_ref() + .unwrap_err() + .contains("Script not found")); + } + + #[test] + fn test_multi_script_error_doesnt_break_other_scripts() { + let script_ok = r#" +export function main(x: number): number { + return x * 2; +} +"#; + let script_err = r#" +export function main(msg: string): never { + throw new Error(msg); +} +"#; + let results = run_multi_script_worker_test( + &[("f/ok", script_ok), ("f/err", script_err)], + vec![ + MultiScriptJob { + script_path: "f/ok".to_string(), + args: serde_json::json!({"x": 5}), + }, + MultiScriptJob { + script_path: "f/err".to_string(), + args: serde_json::json!({"msg": "boom"}), + }, + // Should still work after error in other script + MultiScriptJob { + script_path: "f/ok".to_string(), + args: serde_json::json!({"x": 10}), + }, + ], + ); + + assert_eq!(results.len(), 3); + assert_eq!(results[0], Ok(serde_json::json!(10))); + assert!(results[1].is_err()); + assert_eq!(results[1], Err("boom".to_string())); + assert_eq!(results[2], Ok(serde_json::json!(20))); + } + + // ==================== exec_preprocess Tests ==================== + + /// Raw protocol command to send to a dedicated worker + enum ProtocolCmd { + Exec { path: String, args: serde_json::Value }, + ExecPreprocess { path: String, args: serde_json::Value }, + } + + /// Run a multi-script worker test with raw protocol commands, returning all protocol lines + fn run_raw_protocol_test( + scripts: &[(&str, &str)], + commands: Vec, + ) -> Vec { + let temp_dir = tempfile::tempdir().unwrap(); + let wrapper_path = create_multi_script_worker_files(temp_dir.path(), scripts); + let wrapper_str = wrapper_path.to_str().unwrap(); + + let mut cmd_args: Vec<&str> = BUN_DEDICATED_WORKER_ARGS.to_vec(); + cmd_args.push(wrapper_str); + + let mut child = Command::new(BUN_PATH.as_str()) + .args(cmd_args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(temp_dir.path()) + .spawn() + .expect("Failed to spawn worker process"); + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout); + + let mut start_line = String::new(); + reader.read_line(&mut start_line).unwrap(); + assert_eq!( + parse_dedicated_worker_line(start_line.trim()), + DedicatedWorkerResult::Start, + ); + + let mut results = Vec::new(); + + for cmd in &commands { + let line = match cmd { + ProtocolCmd::Exec { path, args } => format!("exec:{}:{}", path, args), + ProtocolCmd::ExecPreprocess { path, args } => { + format!("exec_preprocess:{}:{}", path, args) + } + }; + writeln!(stdin, "{}", line).unwrap(); + stdin.flush().unwrap(); + + // exec_preprocess produces 2 response lines (preprocessed_args + success/error) + // exec produces 1 response line (success/error) + let expected_lines = match cmd { + ProtocolCmd::ExecPreprocess { .. } => 2, + ProtocolCmd::Exec { .. } => 1, + }; + + for _ in 0..expected_lines { + let mut response = String::new(); + reader.read_line(&mut response).unwrap(); + let parsed = parse_dedicated_worker_line(response.trim()); + // If it's an error, stop reading more lines for this command + if matches!(parsed, DedicatedWorkerResult::Error(_)) { + results.push(parsed); + break; + } + results.push(parsed); + } + } + + writeln!(stdin, "end").unwrap(); + stdin.flush().unwrap(); + let _ = child.wait().expect("Worker process failed to exit"); + + results + } + + #[test] + fn test_bun_exec_preprocess() { + let script = r#" +export function preprocessor(x: number) { + return { x: x * 10 }; +} +export function main(x: number): number { + return x + 1; +} +"#; + let results = run_raw_protocol_test( + &[("f/test/pre", script)], + vec![ProtocolCmd::ExecPreprocess { + path: "f/test/pre".to_string(), + args: serde_json::json!({"x": 5}), + }], + ); + // Should get preprocessed_args then success + assert_eq!(results.len(), 2); + assert_eq!( + results[0], + DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 50})) + ); + // main(50) => 51 + assert_eq!( + results[1], + DedicatedWorkerResult::Success(serde_json::json!(51)) + ); + } + + #[test] + fn test_bun_exec_preprocess_missing_preprocessor() { + let script = r#" +export function main(x: number): number { + return x; +} +"#; + let results = run_raw_protocol_test( + &[("f/test/nopre", script)], + vec![ProtocolCmd::ExecPreprocess { + path: "f/test/nopre".to_string(), + args: serde_json::json!({"x": 5}), + }], + ); + assert_eq!(results.len(), 1); + assert!(matches!(results[0], DedicatedWorkerResult::Error(_))); + } + + #[test] + fn test_bun_exec_preprocess_then_exec() { + let script = r#" +export function preprocessor(x: number) { + return { x: x * 2 }; +} +export function main(x: number): number { + return x + 100; +} +"#; + let results = run_raw_protocol_test( + &[("f/test/mixed", script)], + vec![ + ProtocolCmd::ExecPreprocess { + path: "f/test/mixed".to_string(), + args: serde_json::json!({"x": 5}), + }, + ProtocolCmd::Exec { + path: "f/test/mixed".to_string(), + args: serde_json::json!({"x": 7}), + }, + ], + ); + // preprocess: preprocessor(5) => {"x":10}, main(10) => 110 + // exec: main(7) => 107 + assert_eq!(results.len(), 3); + assert_eq!( + results[0], + DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 10})) + ); + assert_eq!( + results[1], + DedicatedWorkerResult::Success(serde_json::json!(110)) + ); + assert_eq!( + results[2], + DedicatedWorkerResult::Success(serde_json::json!(107)) + ); + } + + // ==================== Argument Transformation Tests ==================== + + #[test] + fn test_bun_date_arg_transformation() { + let script = r#" +export function main(d: Date): string { + return d instanceof Date ? d.toISOString() : typeof d; +} +"#; + let results = run_worker_test( + "bun", + script, + vec![serde_json::json!({"d": "2024-01-15T10:30:00.000Z"})], + ); + assert_eq!(results.len(), 1); + assert_eq!( + results[0], + Ok(serde_json::json!("2024-01-15T10:30:00.000Z")) + ); + } + + #[test] + fn test_bun_null_and_undefined_args() { + let script = r#" +export function main(x?: number): string { + return x === null ? "null" : x === undefined ? "undefined" : String(x); +} +"#; + let results = run_worker_test( + "bun", + script, + vec![ + serde_json::json!({"x": null}), + serde_json::json!({"x": 42}), + serde_json::json!({}), + ], + ); + assert_eq!(results.len(), 3); + assert_eq!(results[0], Ok(serde_json::json!("null"))); + assert_eq!(results[1], Ok(serde_json::json!("42"))); + // Missing arg should be undefined + assert_eq!(results[2], Ok(serde_json::json!("undefined"))); + } +} + +// ============================================================================ +// Deno Dedicated Worker Protocol Tests +// ============================================================================ + +mod dedicated_worker_protocol_deno { + use std::io::{BufRead, BufReader, Write}; + use std::process::{Command, Stdio}; + use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult}; + use windmill_worker::{generate_deno_dedicated_worker_wrapper, DENO_PATH}; + + const TEST_SCRIPT_PATH: &str = "f/test/script"; + + fn run_deno_worker_test( + script: &str, + jobs: Vec, + ) -> Vec> { + let temp_dir = tempfile::tempdir().unwrap(); + std::fs::write(temp_dir.path().join("main.ts"), script).unwrap(); + + let wrapper = generate_deno_dedicated_worker_wrapper(script).unwrap(); + std::fs::write(temp_dir.path().join("wrapper.ts"), &wrapper).unwrap(); + + let mut child = Command::new(DENO_PATH.as_str()) + .args([ + "run", + "--no-check", + "--unstable-unsafe-proto", + "--unstable-bare-node-builtins", + "-A", + "wrapper.ts", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(temp_dir.path()) + .spawn() + .expect("Failed to spawn deno process"); + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout); + + // Wait for "start" — deno outputs 'start\n' via console.log which adds + // its own newline, producing double newlines. Skip empty lines. + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line.trim().is_empty() { + continue; + } + assert_eq!( + parse_dedicated_worker_line(line.trim()), + DedicatedWorkerResult::Start, + "Expected 'start', got: {}", + line.trim() + ); + break; + } + + let mut results = Vec::new(); + for job_args in jobs { + writeln!(stdin, "exec:{}:{}", TEST_SCRIPT_PATH, job_args.to_string()).unwrap(); + stdin.flush().unwrap(); + + loop { + let mut response = String::new(); + reader.read_line(&mut response).unwrap(); + let trimmed = response.trim(); + if trimmed.is_empty() { + continue; + } + match parse_dedicated_worker_line(trimmed) { + DedicatedWorkerResult::Success(value) => results.push(Ok(value)), + DedicatedWorkerResult::Error(err) => { + let msg = err["message"] + .as_str() + .unwrap_or("Unknown error") + .to_string(); + results.push(Err(msg)); + } + other => panic!("Unexpected response: {:?}", other), + } + break; + } + } + + writeln!(stdin, "end").unwrap(); + stdin.flush().unwrap(); + let _ = child.wait().expect("Worker process failed to exit"); + results + } + + #[test] + fn test_deno_dedicated_worker_simple() { + let script = r#" +export function main(x: number, y: number): number { + return x + y; +} +"#; + let results = run_deno_worker_test(script, vec![serde_json::json!({"x": 5, "y": 3})]); + assert_eq!(results.len(), 1); + assert_eq!(results[0], Ok(serde_json::json!(8))); + } + + #[test] + fn test_deno_dedicated_worker_multiple_jobs() { + let script = r#" +export function main(n: number): number { + return n * 2; +} +"#; + let jobs: Vec = (1..=5).map(|i| serde_json::json!({"n": i})).collect(); + let results = run_deno_worker_test(script, jobs); + assert_eq!(results.len(), 5); + for (i, result) in results.iter().enumerate() { + assert_eq!(*result, Ok(serde_json::json!(((i + 1) * 2) as i64))); + } + } + + #[test] + fn test_deno_dedicated_worker_error() { + let script = r#" +export function main(msg: string): never { + throw new Error(msg); +} +"#; + let results = run_deno_worker_test(script, vec![serde_json::json!({"msg": "test error"})]); + assert_eq!(results.len(), 1); + assert!(results[0].is_err()); + assert_eq!(results[0], Err("test error".to_string())); + } + + // ==================== exec_preprocess Tests ==================== + + /// Run a raw deno protocol test, reading all output lines per command + fn run_deno_raw_protocol_test( + script: &str, + commands: Vec<(&str, serde_json::Value)>, // ("exec" or "exec_preprocess", args) + ) -> Vec { + let temp_dir = tempfile::tempdir().unwrap(); + std::fs::write(temp_dir.path().join("main.ts"), script).unwrap(); + + let wrapper = generate_deno_dedicated_worker_wrapper(script).unwrap(); + std::fs::write(temp_dir.path().join("wrapper.ts"), &wrapper).unwrap(); + + let mut child = Command::new(DENO_PATH.as_str()) + .args([ + "run", + "--no-check", + "--unstable-unsafe-proto", + "--unstable-bare-node-builtins", + "-A", + "wrapper.ts", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(temp_dir.path()) + .spawn() + .expect("Failed to spawn deno process"); + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout); + + // Wait for start, skip empty lines + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line.trim().is_empty() { + continue; + } + assert_eq!( + parse_dedicated_worker_line(line.trim()), + DedicatedWorkerResult::Start, + ); + break; + } + + let mut results = Vec::new(); + + for (cmd, args) in &commands { + writeln!(stdin, "{}:{}:{}", cmd, TEST_SCRIPT_PATH, args).unwrap(); + stdin.flush().unwrap(); + + let expected_lines = if *cmd == "exec_preprocess" { 2 } else { 1 }; + + for _ in 0..expected_lines { + loop { + let mut response = String::new(); + reader.read_line(&mut response).unwrap(); + if response.trim().is_empty() { + continue; + } + let parsed = parse_dedicated_worker_line(response.trim()); + if matches!(parsed, DedicatedWorkerResult::Error(_)) { + results.push(parsed); + break; + } + results.push(parsed); + break; + } + // If last result was an error, don't read more lines for this command + if matches!(results.last(), Some(DedicatedWorkerResult::Error(_))) { + break; + } + } + } + + writeln!(stdin, "end").unwrap(); + stdin.flush().unwrap(); + let _ = child.wait().expect("Worker process failed to exit"); + + results + } + + #[test] + fn test_deno_exec_preprocess() { + let script = r#" +export function preprocessor(x: number) { + return { x: x * 10 }; +} +export function main(x: number): number { + return x + 1; +} +"#; + let results = run_deno_raw_protocol_test( + script, + vec![("exec_preprocess", serde_json::json!({"x": 5}))], + ); + assert_eq!(results.len(), 2); + assert_eq!( + results[0], + DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 50})) + ); + assert_eq!( + results[1], + DedicatedWorkerResult::Success(serde_json::json!(51)) + ); + } + + // Note: no "missing preprocessor" test for Deno because the wrapper only generates + // the exec_preprocess handler when the script actually has a preprocessor function. + // Without one, exec_preprocess messages are unrecognized (by design — Rust never sends them). + + // ==================== Argument Transformation Tests ==================== + + #[test] + fn test_deno_date_arg_transformation() { + let script = r#" +export function main(d: Date): string { + return d instanceof Date ? d.toISOString() : typeof d; +} +"#; + let results = run_deno_worker_test( + script, + vec![serde_json::json!({"d": "2024-01-15T10:30:00.000Z"})], + ); + assert_eq!(results.len(), 1); + assert_eq!( + results[0], + Ok(serde_json::json!("2024-01-15T10:30:00.000Z")) + ); + } } // ============================================================================ diff --git a/backend/tests/python_jobs.rs b/backend/tests/python_jobs.rs index 2ffb85a418..f04e189315 100644 --- a/backend/tests/python_jobs.rs +++ b/backend/tests/python_jobs.rs @@ -1,9 +1,470 @@ use serde_json::json; +#[cfg(feature = "python")] use sqlx::postgres::Postgres; +#[cfg(feature = "python")] use sqlx::Pool; +#[cfg(feature = "python")] use windmill_common::scripts::ScriptLang; use windmill_test_utils::*; +// ============================================================================ +// Dedicated Worker Protocol Tests (Python) +// ============================================================================ + +#[cfg(feature = "python")] +mod dedicated_worker_protocol_python { + use std::io::{BufRead, BufReader, Write}; + use std::process::{Command, Stdio}; + use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult}; + use windmill_worker::{compute_py_codegen, generate_py_multi_script_wrapper, PyScriptEntry}; + + struct MultiScriptJob { + script_path: String, + args: serde_json::Value, + } + + /// Creates a multi-script Python wrapper, writes scripts to proper module paths + fn create_py_worker_files( + dir: &std::path::Path, + scripts: &[(&str, &str)], // (original_path, content) + ) -> std::path::PathBuf { + let mut codegens = Vec::new(); + for (path, content) in scripts { + let cg = compute_py_codegen(content, path); + let module_dir = dir.join(&cg.dirs); + std::fs::create_dir_all(&module_dir).unwrap(); + std::fs::write(module_dir.join(format!("{}.py", cg.module_name)), content).unwrap(); + codegens.push((path.to_string(), cg)); + } + + let entries: Vec> = codegens + .iter() + .map(|(path, cg)| PyScriptEntry { original_path: path.as_str(), codegen: cg }) + .collect(); + + let wrapper = generate_py_multi_script_wrapper(&entries, false, false); + let wrapper_path = dir.join("wrapper.py"); + std::fs::write(&wrapper_path, &wrapper).unwrap(); + wrapper_path + } + + fn run_py_multi_script_test( + scripts: &[(&str, &str)], + jobs: Vec, + ) -> Vec> { + let temp_dir = tempfile::tempdir().unwrap(); + create_py_worker_files(temp_dir.path(), scripts); + + let mut child = Command::new("python3") + .args(["-u", "-m", "wrapper"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(temp_dir.path()) + .spawn() + .expect("Failed to spawn python3 process"); + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout); + + let mut start_line = String::new(); + reader.read_line(&mut start_line).unwrap(); + assert_eq!( + parse_dedicated_worker_line(start_line.trim()), + DedicatedWorkerResult::Start, + "Expected 'start', got: {}", + start_line.trim() + ); + + let mut results = Vec::new(); + for job in &jobs { + writeln!(stdin, "exec:{}:{}", job.script_path, job.args.to_string()).unwrap(); + stdin.flush().unwrap(); + + let mut response = String::new(); + reader.read_line(&mut response).unwrap(); + + match parse_dedicated_worker_line(response.trim()) { + DedicatedWorkerResult::Success(value) => results.push(Ok(value)), + DedicatedWorkerResult::Error(err) => { + let msg = err["message"] + .as_str() + .unwrap_or("Unknown error") + .to_string(); + results.push(Err(msg)); + } + other => panic!("Unexpected response: {:?}", other), + } + } + + writeln!(stdin, "end").unwrap(); + stdin.flush().unwrap(); + let _ = child.wait().expect("Worker process failed to exit"); + results + } + + fn run_py_single_script_test( + script_path: &str, + content: &str, + jobs: Vec, + ) -> Vec> { + run_py_multi_script_test( + &[(script_path, content)], + jobs.into_iter() + .map(|args| MultiScriptJob { script_path: script_path.to_string(), args }) + .collect(), + ) + } + + #[test] + fn test_python_dedicated_worker_simple() { + let results = run_py_single_script_test( + "f/test/add", + "def main(a: int, b: int):\n return a + b\n", + vec![serde_json::json!({"a": 3, "b": 4})], + ); + assert_eq!(results.len(), 1); + assert_eq!(results[0], Ok(serde_json::json!(7))); + } + + #[test] + fn test_python_dedicated_worker_multiple_jobs() { + let results = run_py_single_script_test( + "f/test/double", + "def main(n: int):\n return n * 2\n", + (1..=5).map(|i| serde_json::json!({"n": i})).collect(), + ); + assert_eq!(results.len(), 5); + for (i, result) in results.iter().enumerate() { + assert_eq!(*result, Ok(serde_json::json!(((i + 1) * 2) as i64))); + } + } + + #[test] + fn test_python_multi_script_routing() { + let results = run_py_multi_script_test( + &[ + ( + "f/math/add", + "def main(a: int, b: int):\n return a + b\n", + ), + ( + "f/math/mul", + "def main(x: int, y: int):\n return x * y\n", + ), + ], + vec![ + MultiScriptJob { + script_path: "f/math/add".to_string(), + args: serde_json::json!({"a": 3, "b": 4}), + }, + MultiScriptJob { + script_path: "f/math/mul".to_string(), + args: serde_json::json!({"x": 5, "y": 6}), + }, + MultiScriptJob { + script_path: "f/math/add".to_string(), + args: serde_json::json!({"a": 10, "b": 20}), + }, + ], + ); + assert_eq!(results.len(), 3); + assert_eq!(results[0], Ok(serde_json::json!(7))); + assert_eq!(results[1], Ok(serde_json::json!(30))); + assert_eq!(results[2], Ok(serde_json::json!(30))); + } + + #[test] + fn test_python_multi_script_error_isolation() { + let results = run_py_multi_script_test( + &[ + ("f/ok", "def main(x: int):\n return x * 2\n"), + ("f/err", "def main(msg: str):\n raise Exception(msg)\n"), + ], + vec![ + MultiScriptJob { + script_path: "f/ok".to_string(), + args: serde_json::json!({"x": 5}), + }, + MultiScriptJob { + script_path: "f/err".to_string(), + args: serde_json::json!({"msg": "boom"}), + }, + MultiScriptJob { + script_path: "f/ok".to_string(), + args: serde_json::json!({"x": 10}), + }, + ], + ); + assert_eq!(results.len(), 3); + assert_eq!(results[0], Ok(serde_json::json!(10))); + assert!(results[1].is_err()); + assert_eq!(results[1], Err("boom".to_string())); + assert_eq!(results[2], Ok(serde_json::json!(20))); + } + + #[test] + fn test_python_multi_script_unknown_path() { + let results = run_py_multi_script_test( + &[("f/known", "def main(x: int):\n return x\n")], + vec![MultiScriptJob { + script_path: "f/unknown".to_string(), + args: serde_json::json!({"x": 1}), + }], + ); + assert_eq!(results.len(), 1); + assert!(results[0].is_err()); + assert!(results[0] + .as_ref() + .unwrap_err() + .contains("Script not found")); + } + + // ==================== exec_preprocess Tests ==================== + + /// Raw protocol command for Python + enum ProtocolCmd { + Exec { path: String, args: serde_json::Value }, + ExecPreprocess { path: String, args: serde_json::Value }, + } + + /// Run a Python worker test with raw protocol commands + fn run_py_raw_protocol_test( + scripts: &[(&str, &str)], + commands: Vec, + ) -> Vec { + let temp_dir = tempfile::tempdir().unwrap(); + create_py_worker_files(temp_dir.path(), scripts); + + let mut child = Command::new("python3") + .args(["-u", "-m", "wrapper"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(temp_dir.path()) + .spawn() + .expect("Failed to spawn python3 process"); + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout); + + let mut start_line = String::new(); + reader.read_line(&mut start_line).unwrap(); + assert_eq!( + parse_dedicated_worker_line(start_line.trim()), + DedicatedWorkerResult::Start, + ); + + let mut results = Vec::new(); + + for cmd in &commands { + let line = match cmd { + ProtocolCmd::Exec { path, args } => format!("exec:{}:{}", path, args), + ProtocolCmd::ExecPreprocess { path, args } => { + format!("exec_preprocess:{}:{}", path, args) + } + }; + writeln!(stdin, "{}", line).unwrap(); + stdin.flush().unwrap(); + + let expected_lines = match cmd { + ProtocolCmd::ExecPreprocess { .. } => 2, + ProtocolCmd::Exec { .. } => 1, + }; + + for _ in 0..expected_lines { + let mut response = String::new(); + reader.read_line(&mut response).unwrap(); + let parsed = parse_dedicated_worker_line(response.trim()); + if matches!(parsed, DedicatedWorkerResult::Error(_)) { + results.push(parsed); + break; + } + results.push(parsed); + } + } + + writeln!(stdin, "end").unwrap(); + stdin.flush().unwrap(); + let _ = child.wait().expect("Worker process failed to exit"); + + results + } + + #[test] + fn test_python_exec_preprocess() { + let script = r#" +def preprocessor(x: int): + return {"x": x * 10} + +def main(x: int): + return x + 1 +"#; + let results = run_py_raw_protocol_test( + &[("f/test/pre", script)], + vec![ProtocolCmd::ExecPreprocess { + path: "f/test/pre".to_string(), + args: serde_json::json!({"x": 5}), + }], + ); + assert_eq!(results.len(), 2); + assert_eq!( + results[0], + DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 50})) + ); + // main(50) => 51 + assert_eq!( + results[1], + DedicatedWorkerResult::Success(serde_json::json!(51)) + ); + } + + #[test] + fn test_python_exec_preprocess_missing_preprocessor() { + let script = "def main(x: int):\n return x\n"; + let results = run_py_raw_protocol_test( + &[("f/test/nopre", script)], + vec![ProtocolCmd::ExecPreprocess { + path: "f/test/nopre".to_string(), + args: serde_json::json!({"x": 5}), + }], + ); + assert_eq!(results.len(), 1); + assert!(matches!(results[0], DedicatedWorkerResult::Error(_))); + } + + #[test] + fn test_python_exec_preprocess_then_exec() { + let script = r#" +def preprocessor(x: int): + return {"x": x * 2} + +def main(x: int): + return x + 100 +"#; + let results = run_py_raw_protocol_test( + &[("f/test/mixed", script)], + vec![ + ProtocolCmd::ExecPreprocess { + path: "f/test/mixed".to_string(), + args: serde_json::json!({"x": 5}), + }, + ProtocolCmd::Exec { + path: "f/test/mixed".to_string(), + args: serde_json::json!({"x": 7}), + }, + ], + ); + // preprocess: preprocessor(5) => {"x":10}, main(10) => 110 + // exec: main(7) => 107 + assert_eq!(results.len(), 3); + assert_eq!( + results[0], + DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 10})) + ); + assert_eq!( + results[1], + DedicatedWorkerResult::Success(serde_json::json!(110)) + ); + assert_eq!( + results[2], + DedicatedWorkerResult::Success(serde_json::json!(107)) + ); + } + + // ==================== Argument Transformation Tests ==================== + + #[test] + fn test_python_datetime_arg_transformation() { + let script = r#" +from datetime import datetime + +def main(d: datetime): + return d.isoformat() +"#; + let results = run_py_single_script_test( + "f/test/dt", + script, + vec![serde_json::json!({"d": "2024-01-15T10:30:00+00:00"})], + ); + assert_eq!(results.len(), 1); + assert_eq!( + results[0], + Ok(serde_json::json!("2024-01-15T10:30:00+00:00")) + ); + } + + #[test] + fn test_python_bytes_arg_transformation() { + let script = r#" +def main(data: bytes): + return len(data) +"#; + // base64 of "hello" is "aGVsbG8=" + let results = run_py_single_script_test( + "f/test/bytes", + script, + vec![serde_json::json!({"data": "aGVsbG8="})], + ); + assert_eq!(results.len(), 1); + assert_eq!(results[0], Ok(serde_json::json!(5))); + } + + #[test] + fn test_python_kwargs_filtering() { + // Test that extra kwargs are filtered out and only declared args are passed + let script = "def main(a: int, b: int):\n return a + b\n"; + let results = run_py_single_script_test( + "f/test/kwargs", + script, + vec![serde_json::json!({"a": 1, "b": 2, "extra": 99})], + ); + assert_eq!(results.len(), 1); + assert_eq!(results[0], Ok(serde_json::json!(3))); + } + + #[test] + fn test_python_function_call_sentinel_removal() { + // Test that '' sentinel values are removed from args + let script = "def main(a: int, b: int = 10):\n return a + b\n"; + let results = run_py_single_script_test( + "f/test/sentinel", + script, + vec![serde_json::json!({"a": 5, "b": ""})], + ); + assert_eq!(results.len(), 1); + // b should be removed (sentinel), default 10 used + assert_eq!(results[0], Ok(serde_json::json!(15))); + } + + // ==================== Relative Import Tests ==================== + + #[test] + fn test_python_dedicated_worker_with_relative_import_detection() { + // Test that the wrapper includes 'import loader' when scripts have relative imports + let script_with_relative = "from f.helper import util\ndef main(x: int):\n return x\n"; + let cg = compute_py_codegen(script_with_relative, "f/test/rel"); + let entries = [PyScriptEntry { original_path: "f/test/rel", codegen: &cg }]; + let wrapper = generate_py_multi_script_wrapper(&entries, false, true); + assert!( + wrapper.contains("import loader"), + "wrapper should contain 'import loader' when any_relative_imports=true" + ); + + // Without relative imports + let script_no_relative = "def main(x: int):\n return x\n"; + let cg2 = compute_py_codegen(script_no_relative, "f/test/norel"); + let entries2 = [PyScriptEntry { original_path: "f/test/norel", codegen: &cg2 }]; + let wrapper2 = generate_py_multi_script_wrapper(&entries2, false, false); + assert!( + !wrapper2.contains("import loader"), + "wrapper should NOT contain 'import loader' when any_relative_imports=false" + ); + } +} + #[cfg(feature = "python")] #[sqlx::test(fixtures("base", "lockfile_python"))] async fn test_requirements_python(db: Pool) -> anyhow::Result<()> { diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 56de4d9892..54ac78fade 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -239,6 +239,7 @@ pub fn workspaced_service() -> Router { "/history_update/h/:hash/p/*path", post(update_script_history), ) + .route("/list_dedicated_with_deps", get(list_dedicated_with_deps)) // Temporary raw script storage for CLI lock generation .route("/raw_temp/store", post(store_raw_script_temp)) .route("/raw_temp/diff", post(diff_raw_scripts_with_deployed)) @@ -2480,6 +2481,62 @@ async fn guard_script_from_debounce_data(ns: &NewScript) -> Result<()> { } } +#[derive(Serialize)] +struct DedicatedScriptDeps { + path: String, + language: ScriptLang, + workspace_dep_names: Vec, +} + +async fn list_dedicated_with_deps( + authed: ApiAuthed, + Extension(user_db): Extension, + Path(w_id): Path, +) -> JsonResult> { + let mut tx = user_db.begin(&authed).await?; + + let rows = sqlx::query!( + "SELECT DISTINCT ON (path) path, language AS \"language: ScriptLang\", content FROM script + WHERE workspace_id = $1 + AND archived = false + AND dedicated_worker = true + AND language = ANY($2::SCRIPT_LANG[]) + ORDER BY path, created_at DESC", + &w_id, + &[ + ScriptLang::Python3, + ScriptLang::Bun, + ScriptLang::Bunnative, + ScriptLang::Deno, + ] as &[ScriptLang], + ) + .fetch_all(&mut *tx) + .await?; + + tx.commit().await?; + + let result = rows + .into_iter() + .map(|row| { + let dep_names = + windmill_common::scripts::extract_workspace_dependencies_annotated_refs( + &row.language, + &row.content, + &row.path, + ) + .map(|refs| refs.external) + .unwrap_or_default(); + DedicatedScriptDeps { + path: row.path, + language: row.language, + workspace_dep_names: dep_names, + } + }) + .collect(); + + Ok(Json(result)) +} + // ============================================================================ // Temporary Raw Script Storage for CLI Lock Generation // ============================================================================ @@ -2508,11 +2565,9 @@ async fn store_raw_script_temp( .await?; // Clean up old entries (1 week TTL) - sqlx::query!( - "DELETE FROM raw_script_temp WHERE created_at < NOW() - INTERVAL '1 week'" - ) - .execute(&db) - .await?; + sqlx::query!("DELETE FROM raw_script_temp WHERE created_at < NOW() - INTERVAL '1 week'") + .execute(&db) + .await?; Ok(Json(hash)) } @@ -2560,7 +2615,7 @@ async fn diff_raw_scripts_with_deployed( FROM script s \ WHERE s.path = local.path AND s.workspace_id = $3 AND s.archived = false \ ORDER BY s.created_at DESC LIMIT 1 \ - ) deployed ON deployed.deployed_hash = local.hash" + ) deployed ON deployed.deployed_hash = local.hash", ) .bind(&paths) .bind(&hashes) @@ -2582,7 +2637,7 @@ async fn diff_raw_scripts_with_deployed( AND wd.language = $3::SCRIPT_LANG \ AND wd.name IS NOT DISTINCT FROM $4 \ AND encode(sha256(convert_to(wd.content, 'UTF8')), 'hex') = $5 \ - )" + )", ) .bind(&dep.path) .bind(&w_id) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 825842f057..cb86c2a8b9 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -6940,6 +6940,60 @@ paths: schema: type: string + /w/{workspace}/scripts/list_dedicated_with_deps: + get: + summary: list dedicated worker scripts with workspace dependency annotations + operationId: listDedicatedWithDeps + tags: + - script + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: list of dedicated scripts with their workspace dependency names + content: + application/json: + schema: + type: array + items: + type: object + properties: + path: + type: string + language: + type: string + enum: + - python3 + - deno + - go + - bash + - powershell + - postgresql + - mysql + - bigquery + - snowflake + - mssql + - graphql + - nativets + - bun + - bunnative + - php + - rust + - ansible + - csharp + - oracledb + - duckdb + - java + - ruby + workspace_dep_names: + type: array + items: + type: string + required: + - path + - language + - workspace_dep_names + /w/{workspace}/scripts/raw/p/{path}: get: summary: raw script by path diff --git a/backend/windmill-common/src/ai_google.rs b/backend/windmill-common/src/ai_google.rs index ccf34685e5..5f8255c10a 100644 --- a/backend/windmill-common/src/ai_google.rs +++ b/backend/windmill-common/src/ai_google.rs @@ -9,7 +9,10 @@ use serde::{Deserialize, Serialize}; -use crate::ai_types::{ContentPart, ExtraContent, GoogleExtraContent, OpenAIContent, OpenAIMessage, ToolDef, UrlCitation}; +use crate::ai_types::{ + ContentPart, ExtraContent, GoogleExtraContent, OpenAIContent, OpenAIMessage, ToolDef, + UrlCitation, +}; use crate::error::Error; // ============================================================================ @@ -87,7 +90,10 @@ pub struct GeminiTextRequest { /// Tool definition — function declarations and/or Google Search grounding. #[derive(Serialize)] pub struct GeminiTool { - #[serde(rename = "functionDeclarations", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "functionDeclarations", + skip_serializing_if = "Option::is_none" + )] pub function_declarations: Option>, #[serde(rename = "googleSearch", skip_serializing_if = "Option::is_none")] pub google_search: Option, @@ -115,7 +121,10 @@ pub struct GeminiToolConfig { #[derive(Serialize)] pub struct GeminiFunctionCallingConfig { pub mode: String, - #[serde(rename = "allowedFunctionNames", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "allowedFunctionNames", + skip_serializing_if = "Option::is_none" + )] pub allowed_function_names: Option>, } @@ -341,10 +350,8 @@ pub fn convert_content_to_gemini_parts(content: &OpenAIContent) -> Vec { - parse_data_url(&image_url.url).map(|(mime_type, data)| { - GeminiPart::InlineData { - inline_data: GeminiInlineData { mime_type, data }, - } + parse_data_url(&image_url.url).map(|(mime_type, data)| GeminiPart::InlineData { + inline_data: GeminiInlineData { mime_type, data }, }) } // S3Objects are handled by the worker @@ -372,15 +379,12 @@ pub fn openai_messages_to_gemini( if let Some(content) = &msg.content { let parts = convert_content_to_gemini_parts(content); if !parts.is_empty() { - system_instruction = - Some(GeminiContentMessage { role: None, parts }); + system_instruction = Some(GeminiContentMessage { role: None, parts }); } } } "tool" => { - if let (Some(tool_call_id), Some(content)) = - (&msg.tool_call_id, &msg.content) - { + if let (Some(tool_call_id), Some(content)) = (&msg.tool_call_id, &msg.content) { let func_name = find_gemini_function_name(messages, tool_call_id); let response_text = match content { OpenAIContent::Text(text) => text.clone(), @@ -435,10 +439,8 @@ pub fn openai_messages_to_gemini( } if !parts.is_empty() { - contents.push(GeminiContentMessage { - role: Some(gemini_role.to_string()), - parts, - }); + contents + .push(GeminiContentMessage { role: Some(gemini_role.to_string()), parts }); } } } @@ -469,10 +471,8 @@ pub fn openai_tools_to_gemini( .collect(); if !declarations.is_empty() { - gemini_tools.push(GeminiTool { - function_declarations: Some(declarations), - google_search: None, - }); + gemini_tools + .push(GeminiTool { function_declarations: Some(declarations), google_search: None }); } if has_websearch { diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index 8b20d331c0..b1ba371ea2 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -1531,6 +1531,16 @@ pub fn dedicated_worker_tag(workspace_id: &str, path: &str) -> String { ) } +/// Configuration for a runner group — a single long-lived subprocess +/// that can execute multiple scripts sharing the same workspace dependency. +/// Auto-detected from script content annotations at worker startup. +#[derive(Clone, PartialEq, Debug)] +pub struct RunnerGroupConfig { + pub workspace_id: String, + pub dep_name: String, + pub language: String, +} + pub async fn load_worker_config( db: &DB, killpill_tx: KillpillSender, @@ -1638,7 +1648,7 @@ pub async fn load_worker_config( let worker_tags = config .worker_tags .or_else(|| { - // Check for multiple dedicated workers first + // Check for multiple dedicated workers if let Some(ref dws) = dedicated_workers.as_ref() { let mut dedi_tags: Vec = dws .iter() diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index 8e83eae1da..ef2bf66513 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -899,6 +899,8 @@ pub enum DedicatedWorkerResult { Start, /// Worker returned a successful result Success(serde_json::Value), + /// Worker returned preprocessed args (from exec_preprocess) + PreprocessedArgs(serde_json::Value), /// Worker returned an error result Error(serde_json::Value), /// Line is not a protocol message (e.g., logs) @@ -908,6 +910,7 @@ pub enum DedicatedWorkerResult { /// Parse a line from dedicated worker stdout according to the protocol: /// - "start" -> Ready signal /// - "wm_res[success]:JSON" -> Success with result +/// - "wm_res[preprocessed_args]:JSON" -> Preprocessed args /// - "wm_res[error]:JSON" -> Error with details /// - anything else -> Other (logs) pub fn parse_dedicated_worker_line(line: &str) -> DedicatedWorkerResult { @@ -922,6 +925,13 @@ pub fn parse_dedicated_worker_line(line: &str) -> DedicatedWorkerResult { } } + if let Some(json_str) = line.strip_prefix("wm_res[preprocessed_args]:") { + match serde_json::from_str(json_str) { + Ok(value) => return DedicatedWorkerResult::PreprocessedArgs(value), + Err(_) => return DedicatedWorkerResult::Other(line.to_string()), + } + } + if let Some(json_str) = line.strip_prefix("wm_res[error]:") { match serde_json::from_str(json_str) { Ok(value) => return DedicatedWorkerResult::Error(value), diff --git a/backend/windmill-worker/loader.bun.js b/backend/windmill-worker/loader.bun.js index f2a00de0cf..d03f19b6ba 100644 --- a/backend/windmill-worker/loader.bun.js +++ b/backend/windmill-worker/loader.bun.js @@ -21,7 +21,7 @@ const p = { // On Windows, normalize path to POSIX format to match args.path from Bun's resolver const cdirPosix = cdir.replace(/\\/g, "/").replace(/^[a-zA-Z]:/, ""); const filterResolve = new RegExp( - `^(?!\\.\/main\\.ts)(?!${cdir}\/main\\.ts)(?!${cdirPosix}\/main\\.ts)(?!(?:/private)?${cdirNoPrivate}\/wrapper\\.mjs).*\\.ts$` + `^(?!\\.\/main\\.ts)(?!\\.\/_wm_)(?!${cdir}\/main\\.ts)(?!${cdir}\/_wm_)(?!${cdirPosix}\/main\\.ts)(?!${cdirPosix}\/_wm_)(?!(?:/private)?${cdirNoPrivate}\/wrapper\\.mjs).*\\.ts$` ); let cdirNodeModules = `${cdir}/node_modules/`; diff --git a/backend/windmill-worker/loader.bun.windows.js b/backend/windmill-worker/loader.bun.windows.js index 7d14ffcbc3..e89b70c26c 100644 --- a/backend/windmill-worker/loader.bun.windows.js +++ b/backend/windmill-worker/loader.bun.windows.js @@ -27,7 +27,7 @@ const p = { const cdirFwd = cdir.replace(/\\/g, "/"); const cdirPosix = cdirFwd.replace(/^[a-zA-Z]:/, ""); const filterResolve = new RegExp( - `^(?!\\.\/main\\.ts)(?!${cdirFwd}\/main\\.ts)(?!${cdirPosix}\/main\\.ts)(?!(?:/private)?${cdirNoPrivate}\/wrapper\\.mjs).*\\.ts$` + `^(?!\\.\/main\\.ts)(?!\\.\/_wm_)(?!${cdirFwd}\/main\\.ts)(?!${cdirFwd}\/_wm_)(?!${cdirPosix}\/main\\.ts)(?!${cdirPosix}\/_wm_)(?!(?:/private)?${cdirNoPrivate}\/wrapper\\.mjs).*\\.ts$` ); let cdirNodeModules = `${cdirFwd}/node_modules/`; diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 7dbaa6723d..6bc6cacc32 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -77,95 +77,236 @@ pub const EMPTY_FILE: &str = ""; /// Bun args for dedicated worker (without the script path) pub const BUN_DEDICATED_WORKER_ARGS: &[&str] = &["run", "-i", "--prefer-offline"]; -/// Generate the dedicated worker wrapper content. -/// - `arg_names`: The argument names for the main function (e.g., ["x", "y"]) -/// - `main_import`: The import path for the main module (e.g., "./main.ts") -/// - `date_conversions`: Optional date conversion statements for Datetime args -/// - `preprocessor_spread`: If the script has a preprocessor function, the comma-separated arg names for it -pub fn generate_dedicated_worker_wrapper( - arg_names: &[&str], - main_import: &str, - date_conversions: Option<&str>, - preprocessor_spread: Option<&str>, -) -> String { - let spread = arg_names.join(","); - let dates = date_conversions.unwrap_or(""); +/// Pre-computed codegen data for a TypeScript/Bun/Deno script. +/// Computed in Rust from the parsed signature, then baked into the wrapper template. +#[cfg(any(feature = "private", test))] +pub struct TsScriptCodegen { + pub spread: String, + pub date_conversions: String, + pub preprocessor_spread: Option, + pub preprocessor_date_conversions: Option, +} + +/// Parse a TS script and compute the codegen data (arg spread, date conversions, preprocessor). +/// This is the same logic that was used on main in `start_worker`. +#[cfg(any(feature = "private", test))] +pub fn compute_ts_codegen(content: &str) -> TsScriptCodegen { + let sig = + windmill_parser_ts::parse_deno_signature(content, true, false, None).unwrap_or_default(); + let arg_names: Vec<&str> = sig.args.iter().map(|a| a.name.as_str()).collect(); + let spread = arg_names.join(", "); + + let dates = sig + .args + .iter() + .filter(|a| matches!(a.typ, Typ::Datetime)) + .map(|a| { + format!( + "{name} = {name} ? new Date({name}) : undefined", + name = a.name + ) + }) + .join("\n "); + + let pre_sig = windmill_parser_ts::parse_deno_signature( + content, + true, + false, + Some("preprocessor".to_string()), + ) + .ok() + .filter(|s| !s.args.is_empty()); + + let preprocessor_spread = pre_sig + .as_ref() + .map(|s| s.args.iter().map(|a| a.name.as_str()).join(", ")); + let preprocessor_date_conversions = pre_sig.as_ref().map(|s| { + s.args + .iter() + .filter(|a| matches!(a.typ, Typ::Datetime)) + .map(|a| { + format!( + "{name} = {name} ? new Date({name}) : undefined", + name = a.name + ) + }) + .join("\n ") + }); + + TsScriptCodegen { + spread, + date_conversions: dates, + preprocessor_spread, + preprocessor_date_conversions, + } +} + +/// Script entry for the unified wrapper generator. +/// `import_name`: the file stem used in the import path (e.g., "main" → `./main.ts`, or "f__script" → `./f__script.ts`) +#[cfg(any(feature = "private", test))] +pub struct TsScriptEntry<'a> { + pub import_name: &'a str, + pub original_path: &'a str, + pub codegen: &'a TsScriptCodegen, +} + +/// Generate a wrapper for dedicated workers and runner groups. +/// All scripts are baked in at codegen time with static imports and inline arg handling. +/// Protocol: +/// exec:: -> wm_res[success]: | wm_res[error]: +/// exec_preprocess:: -> wm_res[preprocessed_args]: then wm_res[success]: | wm_res[error]: +/// end -> exit +#[cfg(any(feature = "private", test))] +pub fn generate_multi_script_wrapper(scripts: &[TsScriptEntry<'_>], ext: &str) -> String { let is_debug = std::env::var("RUST_LOG").is_ok_and(|x| x == "windmill=debug"); let print_lines = if is_debug { - r#"console.log(line);"# + r#"console.log("[debug] " + line);"# } else { "" }; - let preprocessor_logic = if let Some(pre_spread) = preprocessor_spread { - format!( + let imports: String = scripts + .iter() + .enumerate() + .map(|(i, e)| { + format!( + "import * as _s{i} from \"./{import_name}.{ext}\";", + import_name = e.import_name + ) + }) + .collect::>() + .join("\n"); + + // Generate per-script getArgs / getPreArgs functions + let mut functions = String::new(); + let mut registrations = String::new(); + + for (i, entry) in scripts.iter().enumerate() { + let cg = entry.codegen; + let spread = &cg.spread; + let dates = &cg.date_conversions; + + functions.push_str(&format!( r#" - if (rawLine.startsWith("preprocess:")) {{ - const preInput = rawLine.slice("preprocess:".length); - const parsedArgs = JSON.parse(preInput); - if (Main.preprocessor === undefined || typeof Main.preprocessor !== 'function') {{ - console.log("wm_res[error]:" + JSON.stringify({{ message: "preprocessor function is missing", name: "Error" }})); - continue; - }} - try {{ - function preArgsObjToArr({{ {pre_spread} }}) {{ - return [ {pre_spread} ]; - }} - const preprocessedArgs = await Main.preprocessor(...preArgsObjToArr(parsedArgs)); - console.log("wm_res[preprocessed_args]:" + JSON.stringify(preprocessedArgs ?? {{}}, (key, value) => typeof value === 'undefined' ? null : value)); - // Now call main with preprocessed args - const mainArgs = getArgs(JSON.stringify(preprocessedArgs ?? {{}})); - const res = await Main.main(...mainArgs); - console.log("wm_res[success]:" + JSON.stringify(res ?? null, (key, value) => typeof value === 'undefined' ? null : value)); - }} catch (e) {{ - console.log("wm_res[error]:" + JSON.stringify({{ message: e.message, name: e.name, stack: e.stack, line: rawLine }})); - }} - continue; - }}"# - ) - } else { - String::new() - }; +function getArgs_{i}(line) {{ + let {{ {spread} }} = JSON.parse(line); + {dates} + return [ {spread} ]; +}} +"# + )); + + let pre_fn = if let Some(ref pre_spread) = cg.preprocessor_spread { + let pre_dates = cg.preprocessor_date_conversions.as_deref().unwrap_or(""); + functions.push_str(&format!( + r#" +function getPreArgs_{i}(line) {{ + let {{ {pre_spread} }} = JSON.parse(line); + {pre_dates} + return [ {pre_spread} ]; +}} +"# + )); + format!("getPreArgs_{i}") + } else { + "null".to_string() + }; + + registrations.push_str(&format!( + "scripts.set(\"{path}\", {{ module: _s{i}, getArgs: getArgs_{i}, getPreArgs: {pre_fn} }});\n", + path = entry.original_path, + )); + } format!( r#" -import * as Main from "{main_import}"; +{imports} import * as Readline from "node:readline" BigInt.prototype.toJSON = function () {{ return this.toString(); }}; -console.log('start'); +const scripts = new Map(); +{functions} +{registrations} -function getArgs(line) {{ - let {{ {spread} }} = JSON.parse(line) - {dates} - return [ {spread} ]; -}} +console.log('start'); for await (const line of Readline.createInterface({{ input: process.stdin }})) {{ {print_lines} - const rawLine = line; - if (rawLine === "end") {{ + if (line === "end") {{ process.exit(0); }} - {preprocessor_logic} - try {{ - const args = getArgs(rawLine); - const res = await Main.main(...args); - console.log("wm_res[success]:" + JSON.stringify(res ?? null, (key, value) => typeof value === 'undefined' ? null : value)); - }} catch (e) {{ - console.log("wm_res[error]:" + JSON.stringify({{ message: e.message, name: e.name, stack: e.stack, line: rawLine }})); + + if (line.startsWith("exec_preprocess:")) {{ + const rest = line.slice("exec_preprocess:".length); + const colonIdx = rest.indexOf(":"); + if (colonIdx === -1) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: "Malformed exec_preprocess command: missing colon separator", name: "Error" }})); + continue; + }} + const scriptPath = rest.slice(0, colonIdx); + const argsJson = rest.slice(colonIdx + 1); + + const entry = scripts.get(scriptPath); + if (!entry) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: "Script not found: " + scriptPath, name: "Error" }})); + continue; + }} + + try {{ + if (!entry.getPreArgs) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: "preprocessor function is missing", name: "Error" }})); + continue; + }} + const preArgs = entry.getPreArgs(argsJson); + const preprocessedArgs = await entry.module.preprocessor(...preArgs); + console.log("wm_res[preprocessed_args]:" + JSON.stringify(preprocessedArgs ?? {{}}, (key, value) => typeof value === 'undefined' ? null : value)); + const mainArgs = entry.getArgs(JSON.stringify(preprocessedArgs ?? {{}})); + const res = await entry.module.main(...mainArgs); + console.log("wm_res[success]:" + JSON.stringify(res ?? null, (key, value) => typeof value === 'undefined' ? null : value)); + }} catch (e) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: e.message, name: e.name, stack: e.stack, line: argsJson }})); + }} + continue; }} + + if (line.startsWith("exec:")) {{ + const rest = line.slice("exec:".length); + const colonIdx = rest.indexOf(":"); + if (colonIdx === -1) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: "Malformed exec command: missing colon separator", name: "Error" }})); + continue; + }} + const scriptPath = rest.slice(0, colonIdx); + const argsJson = rest.slice(colonIdx + 1); + + const entry = scripts.get(scriptPath); + if (!entry) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: "Script not found: " + scriptPath, name: "Error" }})); + continue; + }} + + try {{ + const args = entry.getArgs(argsJson); + const res = await entry.module.main(...args); + console.log("wm_res[success]:" + JSON.stringify(res ?? null, (key, value) => typeof value === 'undefined' ? null : value)); + }} catch (e) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: e.message, name: e.name, stack: e.stack, line: argsJson }})); + }} + continue; + }} + + console.error("Unknown command:", line); }} "# ) } /// Returns (package.json, bun.lock(b), is_empty, is_binary) -fn split_lockfile(lockfile: &str) -> (&str, Option<&str>, bool, bool) { +pub(crate) fn split_lockfile(lockfile: &str) -> (&str, Option<&str>, bool, bool) { if let Some(index) = lockfile.find(BUN_LOCK_SPLIT) { // Split using "\n//bun.lock\n" let (before, after_with_sep) = lockfile.split_at(index); @@ -3587,53 +3728,18 @@ pub async fn start_worker( let main_code = remove_pinned_imports(inner_content)?; let _ = write_file(job_dir, "main.ts", &main_code)?; + let codegen = compute_ts_codegen(inner_content); + let wrapper_ext = if codebase.is_some() { "js" } else { "ts" }; { - // let mut start = Instant::now(); - let args = windmill_parser_ts::parse_deno_signature(inner_content, true, false, None)?.args; - let dates = args - .iter() - .filter_map(|x| { - if matches!(x.typ, Typ::Datetime) { - Some(x.name.clone()) - } else { - None - } - }) - .map(|x| return format!("{x} = {x} ? new Date({x}) : undefined")) - .join("\n"); - - let arg_names: Vec<&str> = args.iter().map(|x| x.name.as_str()).collect(); - - // Parse preprocessor signature if it exists - let pre_spread = windmill_parser_ts::parse_deno_signature( - inner_content, - true, - false, - Some("preprocessor".to_string()), - ) - .ok() - .filter(|sig| !sig.args.is_empty()) - .map(|sig| sig.args.into_iter().map(|x| x.name).join(",")); - - // logs.push_str(format!("infer args: {:?}\n", start.elapsed().as_micros()).as_str()); - // we cannot use Bun.read and Bun.write because it results in an EBADF error on cloud - - let main_import = if codebase.is_some() { - "./main.js" - } else { - "./main.ts" - }; - let dates_opt = if dates.is_empty() { - None - } else { - Some(dates.as_str()) - }; - let wrapper_content = generate_dedicated_worker_wrapper( - &arg_names, - main_import, - dates_opt, - pre_spread.as_deref(), - ); + let scripts = + [ + TsScriptEntry { + import_name: "main", + original_path: script_path, + codegen: &codegen, + }, + ]; + let wrapper_content = generate_multi_script_wrapper(&scripts, wrapper_ext); write_file(job_dir, "wrapper.mjs", &wrapper_content)?; } @@ -3832,4 +3938,48 @@ lockfile-content"#; assert!(!is_empty); assert!(!is_binary); } + + #[test] + fn test_compute_ts_codegen_basic_args() { + let code = r#"export function main(x: string, y: number) { return x; }"#; + let cg = compute_ts_codegen(code); + assert_eq!(cg.spread, "x, y"); + assert!(cg.date_conversions.is_empty()); + assert!(cg.preprocessor_spread.is_none()); + } + + #[test] + fn test_compute_ts_codegen_with_datetime() { + let code = r#"export function main(name: string, created_at: Date, count: number) { return name; }"#; + let cg = compute_ts_codegen(code); + assert_eq!(cg.spread, "name, created_at, count"); + assert!(cg.date_conversions.contains("created_at")); + assert!(cg.date_conversions.contains("new Date")); + } + + #[test] + fn test_compute_ts_codegen_with_preprocessor() { + let code = r#" +export function main(x: string, ts: Date) { return x; } +export function preprocessor(input: string, when: Date) { return { x: input, ts: when }; } +"#; + let cg = compute_ts_codegen(code); + assert_eq!(cg.spread, "x, ts"); + assert!(cg.date_conversions.contains("ts")); + assert_eq!(cg.preprocessor_spread.as_deref(), Some("input, when")); + assert!(cg + .preprocessor_date_conversions + .as_ref() + .unwrap() + .contains("when")); + } + + #[test] + fn test_compute_ts_codegen_no_args() { + let code = r#"export function main() { return 42; }"#; + let cg = compute_ts_codegen(code); + assert!(cg.spread.is_empty()); + assert!(cg.date_conversions.is_empty()); + assert!(cg.preprocessor_spread.is_none()); + } } diff --git a/backend/windmill-worker/src/deno_executor.rs b/backend/windmill-worker/src/deno_executor.rs index 46f42af3bf..a0fd9fdde8 100644 --- a/backend/windmill-worker/src/deno_executor.rs +++ b/backend/windmill-worker/src/deno_executor.rs @@ -27,6 +27,16 @@ use windmill_common::{ }; use windmill_parser::Typ; +pub const DENO_UNSTABLE_ARGS: &[&str] = &[ + "--unstable-unsafe-proto", + "--unstable-bare-node-builtins", + "--unstable-webgpu", + "--unstable-ffi", + "--unstable-fs", + "--unstable-worker-options", + "--unstable-http", +]; + lazy_static::lazy_static! { static ref DENO_FLAGS: Option> = std::env::var("DENO_FLAGS") @@ -172,22 +182,14 @@ pub async fn generate_deno_lock( let mut child_cmd = Command::new(DENO_PATH.as_str()); child_cmd .current_dir(job_dir) - .args(vec![ - "cache", - "--unstable-unsafe-proto", - "--unstable-bare-node-builtins", - "--unstable-webgpu", - "--unstable-ffi", - "--unstable-fs", - "--unstable-worker-options", - "--unstable-http", + .args(["cache"].iter().chain(DENO_UNSTABLE_ARGS).chain(&[ "--lock=lock.json", "--frozen=false", "--allow-import", "--import-map", - &import_map_path, + import_map_path.as_str(), "main.ts", - ]) + ])) .envs(deno_envs) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -442,13 +444,7 @@ try {{ args.push("--import-map"); args.push(&import_map_path); args.push(&reload); - args.push("--unstable-unsafe-proto"); - args.push("--unstable-bare-node-builtins"); - args.push("--unstable-webgpu"); - args.push("--unstable-ffi"); - args.push("--unstable-fs"); - args.push("--unstable-worker-options"); - args.push("--unstable-http"); + args.extend_from_slice(DENO_UNSTABLE_ARGS); if !*DISABLE_DENO_LOCK { if let Some(reqs) = requirements_o { @@ -547,7 +543,7 @@ try {{ read_result(job_dir, handle_result.result_stream).await } -async fn build_import_map( +pub(crate) async fn build_import_map( w_id: &str, script_path: &str, base_internal_url: &str, @@ -592,6 +588,127 @@ async fn build_import_map( #[cfg(feature = "private")] use crate::{dedicated_worker_oss::handle_dedicated_process, JobCompletedSender}; +/// Generate the dedicated worker wrapper for Deno. +/// Parses the script signature and bakes in arg destructuring, date conversions, +/// and preprocessor logic. Uses the `exec::` protocol. +#[cfg(any(feature = "private", test))] +pub fn generate_dedicated_worker_wrapper(inner_content: &str) -> Result { + let args = windmill_parser_ts::parse_deno_signature(inner_content, true, false, None)?.args; + let dates = args + .iter() + .filter_map(|x| { + if matches!(x.typ, Typ::Datetime) { + Some(x.name.clone()) + } else { + None + } + }) + .map(|x| format!("{x} = {x} ? new Date({x}) : undefined")) + .join("\n"); + + let spread = args.into_iter().map(|x| x.name).join(","); + + let pre_spread = windmill_parser_ts::parse_deno_signature( + inner_content, + true, + false, + Some("preprocessor".to_string()), + ) + .ok() + .filter(|sig| !sig.args.is_empty()) + .map(|sig| sig.args.into_iter().map(|x| x.name).join(",")); + + let preprocessor_import = if pre_spread.is_some() { + r#"import { preprocessor } from "./main.ts";"# + } else { + "" + }; + + let preprocessor_logic = if let Some(ref pre_spread) = pre_spread { + format!( + r#" + if (line.startsWith("exec_preprocess:")) {{ + const rest = line.slice("exec_preprocess:".length); + const colonIdx = rest.indexOf(":"); + if (colonIdx === -1) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: "Malformed exec_preprocess command: missing colon separator", name: "Error" }}) + '\n'); + continue; + }} + const argsJson = rest.slice(colonIdx + 1); + const parsedArgs = JSON.parse(argsJson); + if (typeof preprocessor !== 'function') {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: "preprocessor function is missing", name: "Error" }}) + '\n'); + continue; + }} + try {{ + function preArgsObjToArr({{ {pre_spread} }}: any) {{ + return [ {pre_spread} ]; + }} + const preprocessedArgs: any = await preprocessor(...preArgsObjToArr(parsedArgs)); + console.log("wm_res[preprocessed_args]:" + JSON.stringify(preprocessedArgs ?? {{}}, (key, value) => typeof value === 'undefined' ? null : value) + '\n'); + let {{ {spread} }} = preprocessedArgs ?? {{}}; + {dates} + let res: any = await main(...[ {spread} ]); + console.log("wm_res[success]:" + JSON.stringify(res ?? null, (key, value) => typeof value === 'undefined' ? null : value) + '\n'); + }} catch (e) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: e.message, name: e.name, stack: e.stack, line: line }}) + '\n'); + }} + continue; + }}"# + ) + } else { + String::new() + }; + + Ok(format!( + r#" +import {{ main }} from "./main.ts"; +{preprocessor_import} + +BigInt.prototype.toJSON = function () {{ + return this.toString(); +}}; + +console.log('start\n'); + +const decoder = new TextDecoder(); +for await (const chunk of Deno.stdin.readable) {{ + const lines = decoder.decode(chunk); + let exit = false; + for (const line of lines.trim().split("\n")) {{ + if (line === "end") {{ + exit = true; + break; + }} + {preprocessor_logic} + if (line.startsWith("exec:")) {{ + const rest = line.slice("exec:".length); + const colonIdx = rest.indexOf(":"); + if (colonIdx === -1) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: "Malformed exec command: missing colon separator", name: "Error" }}) + '\n'); + continue; + }} + const argsJson = rest.slice(colonIdx + 1); + try {{ + let {{ {spread} }} = JSON.parse(argsJson) + {dates} + let res: any = await main(...[ {spread} ]); + console.log("wm_res[success]:" + JSON.stringify(res ?? null, (key, value) => typeof value === 'undefined' ? null : value) + '\n'); + }} catch (e) {{ + console.log("wm_res[error]:" + JSON.stringify({{ message: e.message, name: e.name, stack: e.stack, line: argsJson }}) + '\n'); + }} + continue; + }} + console.error("Unknown command:", line); + }} + if (exit) {{ + break; + }} +}} +"#, + )) +} + #[cfg(feature = "private")] use tokio::sync::mpsc::Receiver; #[cfg(feature = "private")] @@ -650,115 +767,19 @@ pub async fn start_worker( let context_envs = build_envs_map(context.to_vec()).await; { - // let mut start = Instant::now(); - let args = windmill_parser_ts::parse_deno_signature(inner_content, true, false, None)?.args; - let dates = args - .iter() - .filter_map(|x| { - if matches!(x.typ, Typ::Datetime) { - Some(x.name.clone()) - } else { - None - } - }) - .map(|x| return format!("{x} = {x} ? new Date({x}) : undefined")) - .join("\n"); - - let spread = args.into_iter().map(|x| x.name).join(","); - - // Parse preprocessor signature if it exists - let pre_spread = windmill_parser_ts::parse_deno_signature( - inner_content, - true, - false, - Some("preprocessor".to_string()), - ) - .ok() - .filter(|sig| !sig.args.is_empty()) - .map(|sig| sig.args.into_iter().map(|x| x.name).join(",")); - - let preprocessor_import = if pre_spread.is_some() { - r#"import { preprocessor } from "./main.ts";"# - } else { - "" - }; - - let preprocessor_logic = if let Some(ref pre_spread) = pre_spread { - format!( - r#" - if (line.startsWith("preprocess:")) {{ - const preInput = line.slice("preprocess:".length); - const parsedArgs = JSON.parse(preInput); - if (typeof preprocessor !== 'function') {{ - console.log("wm_res[error]:" + JSON.stringify({{ message: "preprocessor function is missing", name: "Error" }}) + '\n'); - continue; - }} - try {{ - function preArgsObjToArr({{ {pre_spread} }}: any) {{ - return [ {pre_spread} ]; - }} - const preprocessedArgs: any = await preprocessor(...preArgsObjToArr(parsedArgs)); - console.log("wm_res[preprocessed_args]:" + JSON.stringify(preprocessedArgs ?? {{}}, (key, value) => typeof value === 'undefined' ? null : value) + '\n'); - // Now call main with preprocessed args - let {{ {spread} }} = preprocessedArgs ?? {{}}; - {dates} - let res: any = await main(...[ {spread} ]); - console.log("wm_res[success]:" + JSON.stringify(res ?? null, (key, value) => typeof value === 'undefined' ? null : value) + '\n'); - }} catch (e) {{ - console.log("wm_res[error]:" + JSON.stringify({{ message: e.message, name: e.name, stack: e.stack, line: line }}) + '\n'); - }} - continue; - }}"# - ) - } else { - String::new() - }; - - // logs.push_str(format!("infer args: {:?}\n", start.elapsed().as_micros()).as_str()); - // we cannot use Bun.read and Bun.write because it results in an EBADF error on cloud - let wrapper_content: String = format!( - r#" -import {{ main }} from "./main.ts"; -{preprocessor_import} - -BigInt.prototype.toJSON = function () {{ - return this.toString(); -}}; - -{dates} - -console.log('start\n'); - -const decoder = new TextDecoder(); -for await (const chunk of Deno.stdin.readable) {{ - const lines = decoder.decode(chunk); - let exit = false; - for (const line of lines.trim().split("\n")) {{ - if (line === "end") {{ - exit = true; - break; - }} - {preprocessor_logic} - try {{ - let {{ {spread} }} = JSON.parse(line) - {dates} - let res: any = await main(...[ {spread} ]); - console.log("wm_res[success]:" + JSON.stringify(res ?? null, (key, value) => typeof value === 'undefined' ? null : value) + '\n'); - }} catch (e) {{ - console.log("wm_res[error]:" + JSON.stringify({{ message: e.message, name: e.name, stack: e.stack, line: line }}) + '\n'); - }} - }} - if (exit) {{ - break; - }} -}} -"#, - ); + let wrapper_content = generate_dedicated_worker_wrapper(inner_content)?; write_file(job_dir, "wrapper.ts", &wrapper_content)?; } build_import_map(w_id, script_path, base_internal_url, job_dir).await?; + let import_map = format!("{job_dir}/import_map.json"); + let reload = format!("--reload={base_internal_url}"); + let wrapper = format!("{job_dir}/wrapper.ts"); + let mut deno_args = vec!["run", "--no-check", "--import-map", &import_map, &reload]; + deno_args.extend_from_slice(DENO_UNSTABLE_ARGS); + deno_args.extend_from_slice(&["-A", &wrapper]); + handle_dedicated_process( &*DENO_PATH, job_dir, @@ -766,22 +787,7 @@ for await (const chunk of Deno.stdin.readable) {{ envs, context, common_deno_proc_envs, - vec![ - "run", - "--no-check", - "--import-map", - &format!("{job_dir}/import_map.json"), - &format!("--reload={base_internal_url}"), - "--unstable-unsafe-proto", - "--unstable-bare-node-builtins", - "--unstable-webgpu", - "--unstable-ffi", - "--unstable-fs", - "--unstable-worker-options", - "--unstable-http", - "-A", - &format!("{job_dir}/wrapper.ts"), - ], + deno_args, killpill_rx, job_completed_tx, token, diff --git a/backend/windmill-worker/src/lib.rs b/backend/windmill-worker/src/lib.rs index 2fe56f50b9..e97ec8ff9a 100644 --- a/backend/windmill-worker/src/lib.rs +++ b/backend/windmill-worker/src/lib.rs @@ -86,12 +86,23 @@ 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, generate_dedicated_worker_wrapper, - 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, 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, }; -pub use deno_executor::generate_deno_lock; +#[cfg(any(feature = "private", test))] +pub use bun_executor::{ + compute_ts_codegen, generate_multi_script_wrapper, TsScriptCodegen, TsScriptEntry, +}; +#[cfg(any(feature = "private", test))] +pub use deno_executor::generate_dedicated_worker_wrapper as generate_deno_dedicated_worker_wrapper; +pub use deno_executor::{generate_deno_lock, DENO_UNSTABLE_ARGS}; pub use prepare_deps::run_prepare_deps_cli; +#[cfg(all(feature = "python", any(feature = "private", test)))] +pub use python_executor::{ + compute_py_codegen, generate_multi_script_wrapper as generate_py_multi_script_wrapper, + PyScriptCodegen, PyScriptEntry, +}; #[cfg(feature = "python")] pub use python_versions::PyV; diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 3b6507a7e0..78d3d54e15 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -117,7 +117,12 @@ async fn handle_piptar_uploads(mut rx: tokio::sync::mpsc::UnboundedReceiver bool { + RELATIVE_IMPORT_REGEX.is_match(content) +} #[cfg(all(feature = "enterprise", feature = "parquet", unix))] use crate::global_cache::pull_from_tar; @@ -1084,6 +1089,320 @@ fn python_preprocessor_spread(sig: windmill_parser::MainArgSignature, indent: &s } } +/// Pre-computed codegen data for a Python script. +/// Computed in Rust from the parsed signature, then baked into the wrapper template. +#[cfg(any(feature = "private", test))] +pub struct PyScriptCodegen { + /// Python module directory dot notation (e.g., "f.my") + pub module_dir_dot: String, + /// Python module name / last path component (e.g., "script") + pub module_name: String, + /// Directory path for the module (e.g., "f/my") + pub dirs: String, + /// Inline Python code for type transforms (dates, bytes, etc.) + pub transforms: String, + /// Inline Python code for arg spread / filtering + pub spread: String, + /// Inline Python code for preprocessor arg spread (if applicable) + pub pre_spread: Option, +} + +/// Parse a Python script and compute the codegen data. +/// This reuses the same logic that was used on main in `prepare_wrapper`. +#[cfg(any(feature = "private", test))] +pub fn compute_py_codegen(content: &str, script_path: &str) -> PyScriptCodegen { + let dirs = compute_python_module_dir(script_path); + let last = script_path + .split("/") + .map(|x| { + if x.starts_with(|x: char| x.is_ascii_digit()) { + format!("_{}", x) + } else { + x.to_string() + } + }) + .last() + .unwrap() + .replace("-", "_") + .replace(" ", "_") + .to_lowercase(); + + let sig = windmill_parser_py::parse_python_signature(content, None, false).unwrap_or_default(); + let pre_sig = windmill_parser_py::parse_python_signature( + content, + Some("preprocessor".to_string()), + false, + ) + .ok() + .filter(|s| !s.args.is_empty()); + + let init_sig = pre_sig.as_ref().unwrap_or(&sig); + + let transforms = init_sig + .args + .iter() + .map(|x| match x.typ { + windmill_parser::Typ::Bytes => { + let name = &x.name; + format!( + "if \"{name}\" in kwargs and kwargs[\"{name}\"] is not None:\n \ + kwargs[\"{name}\"] = base64.b64decode(kwargs[\"{name}\"])\n", + ) + } + windmill_parser::Typ::Datetime => { + let name = &x.name; + format!( + "if \"{name}\" in kwargs and kwargs[\"{name}\"] is not None:\n \ + kwargs[\"{name}\"] = datetime.fromisoformat(kwargs[\"{name}\"])\n", + ) + } + windmill_parser::Typ::Date => { + let name = &x.name; + format!( + "if \"{name}\" in kwargs and kwargs[\"{name}\"] is not None:\n \ + try:\n \ + kwargs[\"{name}\"] = date.fromisoformat(kwargs[\"{name}\"])\n \ + except ValueError:\n \ + for _fmt in (\"%d-%m-%Y\", \"%m/%d/%Y\", \"%d/%m/%Y\", \"%Y/%m/%d\"):\n \ + try:\n \ + kwargs[\"{name}\"] = datetime.strptime(kwargs[\"{name}\"], _fmt).date()\n \ + break\n \ + except ValueError:\n \ + continue\n", + ) + } + _ => "".to_string(), + }) + .collect::>() + .join(""); + + let spread = if sig.star_kwargs { + "args = kwargs".to_string() + } else { + sig.args + .into_iter() + .map(|x| { + let name = &x.name; + if x.default.is_none() { + format!("args[\"{name}\"] = kwargs.get(\"{name}\")") + } else { + format!( + r#"args["{name}"] = kwargs.get("{name}") + if args["{name}"] is None: + del args["{name}"]"# + ) + } + }) + .join("\n ") + }; + + let pre_spread = pre_sig.map(|sig| python_preprocessor_spread(sig, " ")); + + let module_dir_dot = dirs.replace("/", ".").replace("-", "_"); + + PyScriptCodegen { module_dir_dot, module_name: last, dirs, transforms, spread, pre_spread } +} + +/// Script entry for the Python unified wrapper generator. +#[cfg(any(feature = "private", test))] +pub struct PyScriptEntry<'a> { + pub original_path: &'a str, + pub codegen: &'a PyScriptCodegen, +} + +/// Generate a wrapper for Python dedicated workers and runner groups. +/// All scripts are baked in at codegen time with proper Python imports and inline arg handling. +/// Protocol: +/// exec:: -> wm_res[success]: | wm_res[error]: +/// exec_preprocess:: -> wm_res[preprocessed_args]: then wm_res[success]: | wm_res[error]: +/// end -> exit +#[cfg(any(feature = "private", test))] +pub fn generate_multi_script_wrapper( + scripts: &[PyScriptEntry<'_>], + skip_result_postprocessing: bool, + any_relative_imports: bool, +) -> String { + let postprocessor = get_result_postprocessor(skip_result_postprocessing); + let res_to_json_body = python_res_to_json_body(postprocessor); + + let imports: String = scripts + .iter() + .enumerate() + .map(|(i, e)| { + format!( + "from {module_dir_dot} import {module_name} as _s{i}", + module_dir_dot = e.codegen.module_dir_dot, + module_name = e.codegen.module_name, + ) + }) + .collect::>() + .join("\n"); + + let mut functions = String::new(); + let mut registrations = String::new(); + + for (i, entry) in scripts.iter().enumerate() { + let cg = entry.codegen; + let indented_transforms = cg + .transforms + .split('\n') + .map(|line| { + if line.is_empty() { + String::new() + } else { + format!(" {}", line) + } + }) + .collect::>() + .join("\n"); + + functions.push_str(&format!( + r#" +def transform_{i}(kwargs): +{indented_transforms} + args = dict() + {spread} + for k, v in list(args.items()): + if v == '': + del args[k] + return args +"#, + spread = cg.spread + )); + + let pre_fn = if let Some(ref pre_spread) = cg.pre_spread { + functions.push_str(&format!( + r#" +def pre_transform_{i}(kwargs): + pre_args = dict() + {pre_spread} + for k, v in list(pre_args.items()): + if v == '': + del pre_args[k] + return pre_args +"#, + )); + format!("pre_transform_{i}") + } else { + "None".to_string() + }; + + registrations.push_str(&format!( + "scripts[\"{path}\"] = {{ 'mod': _s{i}, 'transform': transform_{i}, 'pre_transform': {pre_fn} }}\n", + path = entry.original_path, + )); + } + + let import_loader = if any_relative_imports { + "import loader" + } else { + "" + }; + + format!( + r#" +import json +import sys +import traceback +import re +import base64 +from datetime import datetime, date +{import_loader} + +{imports} + +scripts = {{}} + +def to_b_64(v: bytes): + b64 = base64.b64encode(v) + return b64.decode('ascii') + +replace_invalid_fields = re.compile(r'(?:\bNaN\b|\\u0000|Infinity|\-Infinity)') + +def res_to_json(res, typ): +{res_to_json_body} +{functions} +{registrations} + +sys.stdout.write('start\n') +sys.stdout.flush() + +for line in sys.stdin: + line = line.strip() + if line == 'end': + break + + if line.startswith('exec_preprocess:'): + try: + rest = line[len('exec_preprocess:'):] + colon_idx = rest.index(':') + script_path = rest[:colon_idx] + args_json = rest[colon_idx + 1:] + + entry = scripts.get(script_path) + if not entry: + err_json = json.dumps({{ "message": "Script not found: " + script_path, "name": "Error" }}, separators=(',', ':'), default=str).replace('\n', '') + sys.stdout.write("wm_res[error]:" + err_json + "\n") + sys.stdout.flush() + continue + + mod = entry['mod'] + if not hasattr(mod, 'preprocessor') or not callable(mod.preprocessor): + err_json = json.dumps({{"message": "preprocessor function is missing", "name": "Error"}}, separators=(',', ':'), default=str).replace('\n', '') + sys.stdout.write("wm_res[error]:" + err_json + "\n") + sys.stdout.flush() + continue + kwargs = json.loads(args_json, strict=False) + pre_args = entry['pre_transform'](kwargs) + preprocessed = mod.preprocessor(**pre_args) + preprocessed_json = json.dumps(preprocessed, separators=(',', ':'), default=str).replace('\n', '') + sys.stdout.write("wm_res[preprocessed_args]:" + preprocessed_json + "\n") + main_args = entry['transform'](preprocessed if preprocessed else {{}}) + res = mod.main(**main_args) + typ = type(res) + res_json = res_to_json(res, typ) + sys.stdout.write("wm_res[success]:" + res_json + "\n") + except BaseException as e: + exc_type, exc_value, exc_traceback = sys.exc_info() + tb = traceback.format_tb(exc_traceback) + err_json = json.dumps({{ "message": str(e), "name": e.__class__.__name__, "stack": '\n'.join(tb[1:]) }}, separators=(',', ':'), default=str).replace('\n', '') + sys.stdout.write("wm_res[error]:" + err_json + "\n") + sys.stdout.flush() + continue + + if line.startswith('exec:'): + try: + rest = line[len('exec:'):] + colon_idx = rest.index(':') + script_path = rest[:colon_idx] + args_json = rest[colon_idx + 1:] + + entry = scripts.get(script_path) + if not entry: + err_json = json.dumps({{ "message": "Script not found: " + script_path, "name": "Error" }}, separators=(',', ':'), default=str).replace('\n', '') + sys.stdout.write("wm_res[error]:" + err_json + "\n") + sys.stdout.flush() + continue + + kwargs = json.loads(args_json, strict=False) + args = entry['transform'](kwargs) + res = entry['mod'].main(**args) + typ = type(res) + res_json = res_to_json(res, typ) + sys.stdout.write("wm_res[success]:" + res_json + "\n") + except BaseException as e: + exc_type, exc_value, exc_traceback = sys.exc_info() + tb = traceback.format_tb(exc_traceback) + err_json = json.dumps({{ "message": str(e), "name": e.__class__.__name__, "stack": '\n'.join(tb[1:]) }}, separators=(',', ':'), default=str).replace('\n', '') + sys.stdout.write("wm_res[error]:" + err_json + "\n") + sys.stdout.flush() + continue + + sys.stderr.write("Unknown command: " + line + "\n") +"# + ) +} + async fn prepare_wrapper( job_dir: &str, job_flow_step_id: Option<&str>, @@ -1303,7 +1622,7 @@ async fn replace_pip_secret( } } -async fn handle_python_deps( +pub(crate) async fn handle_python_deps( job_dir: &str, requirements_o: Option<&String>, inner_content: &str, @@ -2419,6 +2738,22 @@ pub async fn start_worker( use crate::PyV; tracing::info!("script path: {}", script_path); + let codegen = compute_py_codegen(inner_content, script_path); + + // Write script to proper module path (e.g., f/my/script.py) + let module_dir = format!("{}/{}", job_dir, codegen.dirs); + tokio::fs::create_dir_all(&module_dir).await?; + write_file( + &module_dir, + &format!("{}.py", codegen.module_name), + inner_content, + )?; + + let any_relative_imports = RELATIVE_IMPORT_REGEX.is_match(inner_content); + if any_relative_imports { + let _ = write_file(job_dir, "loader.py", RELATIVE_PYTHON_LOADER)?; + } + let mut mem_peak: i32 = 0; let mut canceled_by: Option = None; let context = variables::get_reserved_variables( @@ -2463,122 +2798,12 @@ pub async fn start_worker( ) .await?; - let ( - import_loader, - import_base64, - import_datetime, - module_dir_dot, - _dirs, - last, - transforms, - spread, - _, - _, - ) = prepare_wrapper(job_dir, None, None, None, inner_content, script_path).await?; - - // Parse preprocessor signature if the script has one - let pre_spread = windmill_parser_py::parse_python_signature( - inner_content, - Some("preprocessor".to_string()), - false, - ) - .ok() - .filter(|sig| !sig.args.is_empty()) - .map(|sig| python_preprocessor_spread(sig, " ")); - { - let postprocessor = get_result_postprocessor(annotations.skip_result_postprocessing); - let indented_transforms = transforms - .lines() - .map(|x| format!(" {}", x)) - .collect::>() - .join("\n"); - - let preprocessor_logic = if let Some(ref pre_spread) = pre_spread { - format!( - r#" - if line.startswith('preprocess:'): - pre_input = line[len('preprocess:'):] - kwargs = json.loads(pre_input, strict=False) - if not hasattr(inner_script, 'preprocessor') or not callable(inner_script.preprocessor): - err_json = json.dumps({{"message": "preprocessor function is missing", "name": "Error"}}, separators=(',', ':'), default=str).replace('\n', '') - sys.stdout.write("wm_res[error]:" + err_json + "\n") - sys.stdout.flush() - continue - try: - pre_args = {{}} - {pre_spread} - for k, v in list(pre_args.items()): - if v == '': - del pre_args[k] - preprocessed_kwargs = inner_script.preprocessor(**pre_args) - preprocessed_json = json.dumps(preprocessed_kwargs, separators=(',', ':'), default=str).replace('\n', '') - sys.stdout.write("wm_res[preprocessed_args]:" + preprocessed_json + "\n") - transform_and_run(preprocessed_kwargs) - except BaseException as e: - exc_type, exc_value, exc_traceback = sys.exc_info() - tb = traceback.format_tb(exc_traceback) - err_json = json.dumps({{ "message": str(e), "name": e.__class__.__name__, "stack": '\n'.join(tb[1:]) }}, separators=(',', ':'), default=str).replace('\n', '') - sys.stdout.write("wm_res[error]:" + err_json + "\n") - sys.stdout.flush() - continue -"# - ) - } else { - String::new() - }; - - let res_to_json_body = python_res_to_json_body(postprocessor); - let wrapper_content: String = format!( - r#" -import json -{import_loader} -{import_base64} -{import_datetime} -import traceback -import sys -from {module_dir_dot} import {last} as inner_script -import re - - -def to_b_64(v: bytes): - import base64 - b64 = base64.b64encode(v) - return b64.decode('ascii') - -def res_to_json(res, typ): -{res_to_json_body} - -def transform_and_run(kwargs): - args = {{}} -{indented_transforms} - {spread} - for k, v in list(args.items()): - if v == '': - del args[k] - res = inner_script.main(**args) - typ = type(res) - res_json = res_to_json(res, typ) - sys.stdout.write("wm_res[success]:" + res_json + "\n") - -replace_invalid_fields = re.compile(r'(?:\bNaN\b|\\u0000|Infinity|\-Infinity)') -sys.stdout.write('start\n') - -for line in sys.stdin: - if line == 'end\n': - break - line = line.strip() - {preprocessor_logic} - kwargs = json.loads(line, strict=False) - try: - transform_and_run(kwargs) - except BaseException as e: - exc_type, exc_value, exc_traceback = sys.exc_info() - tb = traceback.format_tb(exc_traceback) - err_json = json.dumps({{ "message": str(e), "name": e.__class__.__name__, "stack": '\n'.join(tb[1:]) }}, separators=(',', ':'), default=str).replace('\n', '') - sys.stdout.write("wm_res[error]:" + err_json + "\n") - sys.stdout.flush() -"#, + let scripts = [PyScriptEntry { original_path: script_path, codegen: &codegen }]; + let wrapper_content = generate_multi_script_wrapper( + &scripts, + annotations.skip_result_postprocessing, + any_relative_imports, ); write_file(job_dir, "wrapper.py", &wrapper_content)?; } @@ -2632,6 +2857,7 @@ for line in sys.stdin: &mut None, ) .await?; + handle_dedicated_process( &python_path, job_dir, @@ -2704,4 +2930,43 @@ mod tests { // @ is replaced with . assert_eq!(compute_python_module_dir("u/@admin/script"), "u/.admin"); } + + #[test] + fn test_compute_py_codegen_basic_args() { + let code = "def main(x: str, y: int):\n return x\n"; + let cg = compute_py_codegen(code, "f/test/script"); + assert!(cg.spread.contains("args[\"x\"]")); + assert!(cg.spread.contains("args[\"y\"]")); + assert!(cg.transforms.is_empty()); + assert!(cg.pre_spread.is_none()); + assert_eq!(cg.module_name, "script"); + } + + #[test] + fn test_compute_py_codegen_with_datetime_and_bytes() { + let code = "import datetime\n\ndef main(name: str, created_at: datetime.datetime, file: bytes):\n return name\n"; + let cg = compute_py_codegen(code, "f/my/handler"); + assert!(cg.transforms.contains("datetime.fromisoformat")); + assert!(cg.transforms.contains("base64.b64decode")); + assert!(cg.spread.contains("args[\"name\"]")); + assert_eq!(cg.module_dir_dot, "f.my"); + assert_eq!(cg.module_name, "handler"); + } + + #[test] + fn test_compute_py_codegen_star_kwargs() { + let code = "def main(**kwargs):\n return kwargs\n"; + let cg = compute_py_codegen(code, "f/test/star"); + assert_eq!(cg.spread, "args = kwargs"); + } + + #[test] + fn test_compute_py_codegen_with_preprocessor() { + let code = "import datetime\n\ndef main(x: str, ts: datetime.datetime):\n return x\n\ndef preprocessor(input: str, when: datetime.datetime):\n return {\"x\": input, \"ts\": when}\n"; + let cg = compute_py_codegen(code, "f/test/pre"); + assert!(cg.spread.contains("args[\"x\"]")); + assert!(cg.pre_spread.is_some()); + let pre = cg.pre_spread.as_ref().unwrap(); + assert!(pre.contains("pre_args[\"input\"]")); + } } diff --git a/frontend/src/lib/components/DedicatedWorkersSelector.svelte b/frontend/src/lib/components/DedicatedWorkersSelector.svelte index 1ed9d36a7c..94086982fb 100644 --- a/frontend/src/lib/components/DedicatedWorkersSelector.svelte +++ b/frontend/src/lib/components/DedicatedWorkersSelector.svelte @@ -1,6 +1,22 @@ +{#snippet depBadge(dep: string)} + {#if existingDeps.has(dep)} + + {dep} + + + {:else} + + Workspace dependency '{dep}' not found. Create it in workspace settings to enable shared + runners. + + + + {dep} + + {/if} +{/snippet} + +{#snippet tagRow(tag: string, info: SelectedTagInfo | undefined)} +
    +
    + {#if info?.type === 'flow' && info.runners && info.runners.length > 0} + + {:else} +
    + {/if} +
    + {#if info} + {#if info.type === 'flow'} + + {:else} + + {/if} + {info.path} + ({info.workspace}) + + {#if !tagRunnerGroup.has(tag)} + {#if info.workspaceDeps} + {#each info.workspaceDeps as dep} + {@render depBadge(dep)} + {/each} + {/if} + {#if info.type === 'flow' && info.runners} + + {info.runners.length} runner{info.runners.length !== 1 ? 's' : ''} + + {:else if info.language} + {info.language} + {/if} + {/if} + {:else} + {tag} + {/if} +
    + {#if !disabled} + + {/if} +
    + + {#if info?.type === 'flow' && info.expanded && info.runners} +
    + {#each info.runners as runner (runner.stepId)} +
    + {runner.stepId} + {#if runner.stepSummary} + {runner.stepSummary} + {/if} + + {runner.isInline ? runner.language : runner.scriptPath} + +
    + {/each} +
    + {/if} +
    +{/snippet} +
    - {#if selectedTags.length > 0}
    -
    - {#each selectedTags as tag (tag)} - {@const info = selectedTagsInfo.get(tag)} +
    + + {#each runnerGroups as group (`${group.depName}:${group.language}`)}
    -
    - {#if info?.type === 'flow' && info.runners && info.runners.length > 0} - - {:else} -
    - {/if} -
    -
    - {#if info} - {#if info.type === 'flow'} - - {:else} - - {/if} - {info.path} - ({info.workspace}) - {#if info.type === 'flow' && info.runners} - - {info.runners.length} runner{info.runners.length !== 1 ? 's' : ''} - - {:else if info.type === 'script'} - 1 runner - {/if} - {:else} - {tag} - {/if} -
    -
    - {#if !disabled} - - {/if} +
    + + Shared runner + + {@render depBadge(group.depName)} + {group.language}
    +
    + {#each group.tags as tag (tag)} + {@const info = selectedTagsInfo.get(tag)} + {@render tagRow(tag, info)} + {/each} +
    +
    + {/each} - {#if info?.type === 'flow' && info.expanded && info.runners} -
    - {#each info.runners as runner (runner.stepId)} -
    - {runner.stepId} - {#if runner.stepSummary} - {runner.stepSummary} - {/if} - - {runner.isInline ? runner.language : runner.scriptPath} - -
    - {/each} -
    + + {#each standaloneTags as tag (tag)} + {@const info = selectedTagsInfo.get(tag)} +
    + {#if info?.type === 'flow'} + + {:else} + + {/if} + {info?.path ?? tag} + ({info?.workspace ?? ''}) + + {#if info?.workspaceDeps} + {#each info.workspaceDeps as dep} + {@render depBadge(dep)} + {/each} + {/if} + {#if info?.type === 'flow' && info.runners} + + {info.runners.length} runner{info.runners.length !== 1 ? 's' : ''} + + {:else if info?.language} + {info.language} + {/if} + {#if !disabled} + {/if}
    {/each} @@ -585,15 +824,18 @@ {/if}
    - {runnable.displayName} + {runnable.displayName} {#if runnable.type === 'flow' && runnable.runners} {runnable.runners.length} {/if} - + {#if runnable.workspaceDeps} + {#each runnable.workspaceDeps as dep} + {@render depBadge(dep)} + {/each} + {/if} + {runnable.type === 'flow' ? 'flow' : runnable.language} From 7f48704cfdbc2b2b0391f9643a35ed1d7d49c641 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 15:50:45 +0000 Subject: [PATCH 189/204] add missing grants on app_bundles for windmill_user and windmill_admin (#8527) Co-authored-by: Claude Opus 4.6 (1M context) --- backend/migrations/20260325000000_app_bundles_grants.down.sql | 3 +++ backend/migrations/20260325000000_app_bundles_grants.up.sql | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 backend/migrations/20260325000000_app_bundles_grants.down.sql create mode 100644 backend/migrations/20260325000000_app_bundles_grants.up.sql diff --git a/backend/migrations/20260325000000_app_bundles_grants.down.sql b/backend/migrations/20260325000000_app_bundles_grants.down.sql new file mode 100644 index 0000000000..92e34705fe --- /dev/null +++ b/backend/migrations/20260325000000_app_bundles_grants.down.sql @@ -0,0 +1,3 @@ +-- Revoke grants for app_bundles table +REVOKE ALL ON app_bundles FROM windmill_user; +REVOKE ALL ON app_bundles FROM windmill_admin; diff --git a/backend/migrations/20260325000000_app_bundles_grants.up.sql b/backend/migrations/20260325000000_app_bundles_grants.up.sql new file mode 100644 index 0000000000..2ad56563db --- /dev/null +++ b/backend/migrations/20260325000000_app_bundles_grants.up.sql @@ -0,0 +1,3 @@ +-- Add grants for app_bundles table +GRANT ALL ON app_bundles TO windmill_user; +GRANT ALL ON app_bundles TO windmill_admin; From 0bd756839c0261f255111d62088bdaaecb838085 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 25 Mar 2026 18:10:20 +0100 Subject: [PATCH 190/204] feat: SCIM user deprovisioning (active:false) + instance-level user disable (#8484) * [ee] feat: handle active:false in SCIM user PATCH/PUT for deprovisioning Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref for SCIM active:false deprovision fix Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * nit sqlx * [ee] feat: add password.disabled column for SCIM user deactivation Co-Authored-By: Claude Opus 4.6 (1M context) * [ee] feat: enforce password.disabled in auth checks Co-Authored-By: Claude Opus 4.6 (1M context) * [ee] refactor: use scim_deactivated_user table instead of password.disabled Co-Authored-By: Claude Opus 4.6 (1M context) * [ee] fix: apply SCIM filters to deactivated users, add name column Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * chore: add down migration for scim_deactivated_user Co-Authored-By: Claude Opus 4.6 (1M context) * fix: rename migration to avoid timestamp conflict, update sqlx cache Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * [ee] refactor: use password.disabled for SCIM deactivation, block login for disabled users Co-Authored-By: Claude Opus 4.6 (1M context) * [ee] feat: show disabled toggle in superadmin user list, add disabled field to API Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add confirmation modal when disabling instance user Co-Authored-By: Claude Opus 4.6 (1M context) * fix: improve disable user confirmation text Co-Authored-By: Claude Opus 4.6 (1M context) * fix: revert toggle state when disable confirmation is cancelled Co-Authored-By: Claude Opus 4.6 (1M context) * fix: properly revert toggle on disable cancel using reset key Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: move disable/enable to dropdown menu, add disabled badge on email Co-Authored-By: Claude Opus 4.6 (1M context) * fix: rename 'Show active users only' to 'Recently active only' to avoid confusion with disabled state Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * chore: remove accidentally committed gen files Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use .catch() for enable user error handling in dropdown action Co-Authored-By: Claude Opus 4.6 (1M context) * fix: delete tokens on user removal, improve confirmation modal texts Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update sqlx cache for non-enterprise code paths Co-Authored-By: Claude Opus 4.6 (1M context) * fix: restore sqlx cache files deleted by incorrect prepare run Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add missing sqlx cache for non-enterprise git sync query Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to a1274aa11a83f608eacc32c0d449ca3527d98c15 This commit updates the EE repository reference after PR #473 was merged in windmill-ee-private. Previous ee-repo-ref: 30f8c53b101b9e25107e793cdc038b0e07061739 New ee-repo-ref: a1274aa11a83f608eacc32c0d449ca3527d98c15 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...1410d32a8d672cfa4929e9e3763c51daa1bc.json} | 10 +- ...024d9826a328bf0416c22daf06fff5ced08f6.json | 14 +++ ...ef901cd3c417b9f3af03f35009213143bd443.json | 28 ++++++ ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...7d18161eb6f5ec1ba9f1b55feffe8b6518c67.json | 15 +++ ...2d14755474cba82b3b388a47585a8bb325b1a.json | 17 ---- ...5b31f0efc6d8ef73f691009c73f833dcee10.json} | 10 +- ...bdc0e1934d67d3f2b14047d434b77d370af21.json | 22 +++++ ...ac9019767074158e0c027988e5b0d51a3656.json} | 4 +- ...504b7b5cb7a39538ab9abeb44f781c711493.json} | 10 +- ...78447d0aa3d143e94e49924ff7ac8b7abf924.json | 22 +++++ backend/ee-repo-ref.txt | 2 +- ...60324000000_scim_deactivated_user.down.sql | 1 + ...0260324000000_scim_deactivated_user.up.sql | 1 + backend/windmill-api-users/src/users.rs | 32 ++++++- backend/windmill-api/openapi.yaml | 5 + .../components/SuperadminSettingsInner.svelte | 96 ++++++++++++++++--- 17 files changed, 248 insertions(+), 43 deletions(-) rename backend/.sqlx/{query-05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab.json => query-115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc.json} (83%) create mode 100644 backend/.sqlx/query-192ddae8c3c82a8f099a4944483024d9826a328bf0416c22daf06fff5ced08f6.json create mode 100644 backend/.sqlx/query-23b9c862d050b00aaa332527b62ef901cd3c417b9f3af03f35009213143bd443.json create mode 100644 backend/.sqlx/query-8bd266705fc8272f3d8941922ad7d18161eb6f5ec1ba9f1b55feffe8b6518c67.json delete mode 100644 backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json rename backend/.sqlx/{query-60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce.json => query-a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10.json} (82%) create mode 100644 backend/.sqlx/query-ccc49a2a6e11f874825365de758bdc0e1934d67d3f2b14047d434b77d370af21.json rename backend/.sqlx/{query-638d3c2ba1198dce5b5b0e47df59a92ff8011e19fbefcc3960d6f0fe167e55b6.json => query-daa1a6bf3d4a1001da88301932a7ac9019767074158e0c027988e5b0d51a3656.json} (59%) rename backend/.sqlx/{query-65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9.json => query-f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493.json} (85%) create mode 100644 backend/.sqlx/query-fc6c6310ae8ac5eb351d7e2af1678447d0aa3d143e94e49924ff7ac8b7abf924.json create mode 100644 backend/migrations/20260324000000_scim_deactivated_user.down.sql create mode 100644 backend/migrations/20260324000000_scim_deactivated_user.up.sql diff --git a/backend/.sqlx/query-05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab.json b/backend/.sqlx/query-115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc.json similarity index 83% rename from backend/.sqlx/query-05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab.json rename to backend/.sqlx/query-115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc.json index 53a3863587..d2c85b0e53 100644 --- a/backend/.sqlx/query-05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab.json +++ b/backend/.sqlx/query-115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT $1 OFFSET $2", + "query": "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT $1 OFFSET $2", "describe": { "columns": [ { @@ -57,6 +57,11 @@ "ordinal": 10, "name": "role_source", "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "disabled", + "type_info": "Bool" } ], "parameters": { @@ -76,8 +81,9 @@ true, null, false, + false, false ] }, - "hash": "05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab" + "hash": "115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc" } diff --git a/backend/.sqlx/query-192ddae8c3c82a8f099a4944483024d9826a328bf0416c22daf06fff5ced08f6.json b/backend/.sqlx/query-192ddae8c3c82a8f099a4944483024d9826a328bf0416c22daf06fff5ced08f6.json new file mode 100644 index 0000000000..dc7c41cfd3 --- /dev/null +++ b/backend/.sqlx/query-192ddae8c3c82a8f099a4944483024d9826a328bf0416c22daf06fff5ced08f6.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM token WHERE email = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "192ddae8c3c82a8f099a4944483024d9826a328bf0416c22daf06fff5ced08f6" +} diff --git a/backend/.sqlx/query-23b9c862d050b00aaa332527b62ef901cd3c417b9f3af03f35009213143bd443.json b/backend/.sqlx/query-23b9c862d050b00aaa332527b62ef901cd3c417b9f3af03f35009213143bd443.json new file mode 100644 index 0000000000..d6946e80d0 --- /dev/null +++ b/backend/.sqlx/query-23b9c862d050b00aaa332527b62ef901cd3c417b9f3af03f35009213143bd443.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email, disabled FROM password WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "disabled", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "23b9c862d050b00aaa332527b62ef901cd3c417b9f3af03f35009213143bd443" +} diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 713ccb9dd3..36ddb8ab9f 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - null + true ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-8bd266705fc8272f3d8941922ad7d18161eb6f5ec1ba9f1b55feffe8b6518c67.json b/backend/.sqlx/query-8bd266705fc8272f3d8941922ad7d18161eb6f5ec1ba9f1b55feffe8b6518c67.json new file mode 100644 index 0000000000..fc86915946 --- /dev/null +++ b/backend/.sqlx/query-8bd266705fc8272f3d8941922ad7d18161eb6f5ec1ba9f1b55feffe8b6518c67.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE password SET disabled = $1 WHERE email = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bool", + "Text" + ] + }, + "nullable": [] + }, + "hash": "8bd266705fc8272f3d8941922ad7d18161eb6f5ec1ba9f1b55feffe8b6518c67" +} diff --git a/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json b/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json deleted file mode 100644 index 25a32e5338..0000000000 --- a/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO deployment_metadata (workspace_id, path, script_hash, deployment_msg) VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, script_hash) WHERE script_hash IS NOT NULL DO UPDATE SET deployment_msg = EXCLUDED.deployment_msg", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Int8", - "Text" - ] - }, - "nullable": [] - }, - "hash": "9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a" -} diff --git a/backend/.sqlx/query-60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce.json b/backend/.sqlx/query-a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10.json similarity index 82% rename from backend/.sqlx/query-60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce.json rename to backend/.sqlx/query-a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10.json index 6d2382f494..dab14d9f1d 100644 --- a/backend/.sqlx/query-60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce.json +++ b/backend/.sqlx/query-a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10.json @@ -1,6 +1,6 @@ { "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, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n ORDER BY super_admin DESC, devops DESC\n LIMIT $1 OFFSET $2", + "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, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source, disabled\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n ORDER BY super_admin DESC, devops DESC\n LIMIT $1 OFFSET $2", "describe": { "columns": [ { @@ -57,6 +57,11 @@ "ordinal": 10, "name": "role_source", "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "disabled", + "type_info": "Bool" } ], "parameters": { @@ -76,8 +81,9 @@ true, true, false, + false, false ] }, - "hash": "60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce" + "hash": "a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10" } diff --git a/backend/.sqlx/query-ccc49a2a6e11f874825365de758bdc0e1934d67d3f2b14047d434b77d370af21.json b/backend/.sqlx/query-ccc49a2a6e11f874825365de758bdc0e1934d67d3f2b14047d434b77d370af21.json new file mode 100644 index 0000000000..a9348dfa8b --- /dev/null +++ b/backend/.sqlx/query-ccc49a2a6e11f874825365de758bdc0e1934d67d3f2b14047d434b77d370af21.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT super_admin FROM password WHERE email = $1 AND disabled = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "super_admin", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ccc49a2a6e11f874825365de758bdc0e1934d67d3f2b14047d434b77d370af21" +} diff --git a/backend/.sqlx/query-638d3c2ba1198dce5b5b0e47df59a92ff8011e19fbefcc3960d6f0fe167e55b6.json b/backend/.sqlx/query-daa1a6bf3d4a1001da88301932a7ac9019767074158e0c027988e5b0d51a3656.json similarity index 59% rename from backend/.sqlx/query-638d3c2ba1198dce5b5b0e47df59a92ff8011e19fbefcc3960d6f0fe167e55b6.json rename to backend/.sqlx/query-daa1a6bf3d4a1001da88301932a7ac9019767074158e0c027988e5b0d51a3656.json index d37bc73dad..5b18e66ed4 100644 --- a/backend/.sqlx/query-638d3c2ba1198dce5b5b0e47df59a92ff8011e19fbefcc3960d6f0fe167e55b6.json +++ b/backend/.sqlx/query-daa1a6bf3d4a1001da88301932a7ac9019767074158e0c027988e5b0d51a3656.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO password (email, login_type, verified, username, name) VALUES ($1, 'saml', true, $2, $3) ON CONFLICT DO NOTHING", + "query": "INSERT INTO password (email, login_type, verified, username, name) VALUES ($1, 'saml', true, $2, $3) ON CONFLICT (email) DO UPDATE SET disabled = false", "describe": { "columns": [], "parameters": { @@ -12,5 +12,5 @@ }, "nullable": [] }, - "hash": "638d3c2ba1198dce5b5b0e47df59a92ff8011e19fbefcc3960d6f0fe167e55b6" + "hash": "daa1a6bf3d4a1001da88301932a7ac9019767074158e0c027988e5b0d51a3656" } diff --git a/backend/.sqlx/query-65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9.json b/backend/.sqlx/query-f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493.json similarity index 85% rename from backend/.sqlx/query-65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9.json rename to backend/.sqlx/query-f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493.json index 2ccf7bfdc8..c1d113ee27 100644 --- a/backend/.sqlx/query-65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9.json +++ b/backend/.sqlx/query-f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password WHERE email = $1", + "query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password WHERE email = $1", "describe": { "columns": [ { @@ -57,6 +57,11 @@ "ordinal": 10, "name": "role_source", "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "disabled", + "type_info": "Bool" } ], "parameters": { @@ -75,8 +80,9 @@ true, null, false, + false, false ] }, - "hash": "65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9" + "hash": "f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493" } diff --git a/backend/.sqlx/query-fc6c6310ae8ac5eb351d7e2af1678447d0aa3d143e94e49924ff7ac8b7abf924.json b/backend/.sqlx/query-fc6c6310ae8ac5eb351d7e2af1678447d0aa3d143e94e49924ff7ac8b7abf924.json new file mode 100644 index 0000000000..c85c557a90 --- /dev/null +++ b/backend/.sqlx/query-fc6c6310ae8ac5eb351d7e2af1678447d0aa3d143e94e49924ff7ac8b7abf924.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT disabled FROM password WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "disabled", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "fc6c6310ae8ac5eb351d7e2af1678447d0aa3d143e94e49924ff7ac8b7abf924" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 263ec1de9a..7a74b353a3 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -182943e5ad9bf2a905ccdf07d4e346437fb329a9 +a1274aa11a83f608eacc32c0d449ca3527d98c15 diff --git a/backend/migrations/20260324000000_scim_deactivated_user.down.sql b/backend/migrations/20260324000000_scim_deactivated_user.down.sql new file mode 100644 index 0000000000..1af6e77c62 --- /dev/null +++ b/backend/migrations/20260324000000_scim_deactivated_user.down.sql @@ -0,0 +1 @@ +ALTER TABLE password DROP COLUMN IF EXISTS disabled; diff --git a/backend/migrations/20260324000000_scim_deactivated_user.up.sql b/backend/migrations/20260324000000_scim_deactivated_user.up.sql new file mode 100644 index 0000000000..7410481bdd --- /dev/null +++ b/backend/migrations/20260324000000_scim_deactivated_user.up.sql @@ -0,0 +1 @@ +ALTER TABLE password ADD COLUMN disabled BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 75db0e2d24..8790a9c541 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -157,6 +157,7 @@ pub struct GlobalUserInfo { operator_only: Option, first_time_user: bool, role_source: String, + disabled: bool, } #[derive(Serialize, Debug)] @@ -213,6 +214,7 @@ pub struct EditUser { pub is_super_admin: Option, pub is_devops: Option, pub name: Option, + pub disabled: Option, } #[derive(Deserialize)] @@ -396,7 +398,7 @@ async fn list_users_as_super_admin( GlobalUserInfo, "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, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source + SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source, disabled FROM password WHERE email IN (SELECT email FROM active_users) ORDER BY super_admin DESC, devops DESC @@ -409,7 +411,7 @@ async fn list_users_as_super_admin( } else { sqlx::query_as!( GlobalUserInfo, - "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT \ + "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT \ $1 OFFSET $2", per_page as i32, offset as i32 @@ -657,7 +659,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 FROM password WHERE \ + "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password WHERE \ email = $1", email ) @@ -680,6 +682,7 @@ async fn global_whoami( operator_only: None, first_time_user: false, role_source: "manual".to_string(), + disabled: false, })) } else { Err(user.unwrap_err()) @@ -1439,6 +1442,22 @@ async fn update_user( .await?; } + if let Some(d) = eu.disabled { + sqlx::query_scalar!( + "UPDATE password SET disabled = $1 WHERE email = $2", + d, + &email_to_update + ) + .execute(&mut *tx) + .await?; + if d { + // Delete all tokens for immediate session revocation + sqlx::query!("DELETE FROM token WHERE email = $1", &email_to_update) + .execute(&mut *tx) + .await?; + } + } + audit_log( &mut *tx, &authed, @@ -1461,6 +1480,9 @@ async fn delete_user( require_super_admin(&db, &authed.email).await?; let mut tx = db.begin().await?; + sqlx::query!("DELETE FROM token WHERE email = $1", &email_to_delete) + .execute(&mut *tx) + .await?; sqlx::query!("DELETE FROM password WHERE email = $1", &email_to_delete) .execute(&mut *tx) .await?; @@ -1719,7 +1741,7 @@ async fn login( }; let email_w_h: Option<(String, String, bool)> = sqlx::query_as( "SELECT email, password_hash, super_admin FROM password WHERE email = $1 AND login_type = \ - 'password'", + 'password' AND disabled = false", ) .bind(&email) .fetch_optional(&mut *tx) @@ -1808,7 +1830,7 @@ async fn refresh_token( } let super_admin = sqlx::query_scalar!( - "SELECT super_admin FROM password WHERE email = $1", + "SELECT super_admin FROM password WHERE email = $1 AND disabled = false", &authed.email ) .fetch_optional(&mut *tx) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index cb86c2a8b9..7b90c64d12 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -588,6 +588,8 @@ paths: type: boolean name: type: string + disabled: + type: boolean responses: "200": description: user updated @@ -23470,6 +23472,8 @@ components: role_source: type: string enum: ["manual", "instance_group"] + disabled: + type: boolean required: - email @@ -23478,6 +23482,7 @@ components: - verified - first_time_user - role_source + - disabled Flow: allOf: diff --git a/frontend/src/lib/components/SuperadminSettingsInner.svelte b/frontend/src/lib/components/SuperadminSettingsInner.svelte index 4a1d376f8c..683db57724 100644 --- a/frontend/src/lib/components/SuperadminSettingsInner.svelte +++ b/frontend/src/lib/components/SuperadminSettingsInner.svelte @@ -17,7 +17,7 @@ import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import { userStore, workspaceStore } from '$lib/stores' - import { ExternalLink, Pencil, UserMinus, UserPlus } from 'lucide-svelte' + import { Ban, CheckCircle2, ExternalLink, Pencil, UserMinus, UserPlus } from 'lucide-svelte' import DropdownV2 from './DropdownV2.svelte' import Popover from './meltComponents/Popover.svelte' import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte' @@ -67,6 +67,8 @@ let filteredUsers: GlobalUserInfo[] = $state([]) let deleteConfirmedCallback: (() => void) | undefined = $state(undefined) let deleteUserEmail: string = $state('') + let disableConfirmedCallback: (() => void) | undefined = $state(undefined) + let disableUserEmail: string = $state('') let editWrappers: Record = $state({}) let activeOnly = $state(false) @@ -293,9 +295,9 @@ /> @@ -347,13 +349,25 @@ {#if filteredUsers && users} - {#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, role_source }, i (email)} - - {email} + {#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, role_source, disabled }, i (email)} + + +
    + {email} + {#if disabled} + Disabled + {/if} +
    +
    {#if automateUsernameCreation} {#if username} @@ -514,6 +528,39 @@ if (btn instanceof HTMLElement) btn.click() } }, + { + displayName: disabled ? 'Enable' : 'Disable', + icon: disabled ? CheckCircle2 : Ban, + action: () => { + if (!disabled) { + disableUserEmail = email + disableConfirmedCallback = async () => { + try { + await UserService.globalUserUpdate({ + email, + requestBody: { disabled: true } + }) + sendUserToast('User disabled') + listUsers(activeOnly) + } catch (e) { + sendUserToast('Failed to disable user', true) + } + } + } else { + UserService.globalUserUpdate({ + email, + requestBody: { disabled: false } + }) + .then(() => { + sendUserToast('User enabled') + listUsers(activeOnly) + }) + .catch(() => { + sendUserToast('Failed to enable user', true) + }) + } + } + }, { displayName: 'Remove', icon: UserMinus, @@ -578,6 +625,33 @@ }} >
    - Are you sure you want to remove {deleteUserEmail}? + Are you sure you want to remove {deleteUserEmail}? They will be removed from all + workspaces and instance groups, and all their sessions and tokens will be revoked. This action + is irreversible. Their workspace content (scripts, flows, apps) will not be deleted. +
    + + { + disableConfirmedCallback = undefined + listUsers(activeOnly) + }} + on:confirmed={() => { + if (disableConfirmedCallback) { + disableConfirmedCallback() + } + disableConfirmedCallback = undefined + }} +> +
    + Are you sure you want to disable {disableUserEmail}? All their active sessions and + tokens will be revoked immediately. They will be unable to log in until re-enabled. Their + workspace memberships and content will be preserved.
    From ead1ea73af59215a84fb08a58b5d30115acb529f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 17:51:37 +0000 Subject: [PATCH 191/204] sqlx --- ...3e69e4ef8821c6cbf3b4f296b3853d95692af.json | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json diff --git a/backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json b/backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json deleted file mode 100644 index a78e67067f..0000000000 --- a/backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af" -} From 9e8d4af458a01d03822144c796330cb3f995723e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 21:12:09 +0000 Subject: [PATCH 192/204] sqlx nits --- ...1c2d14755474cba82b3b388a47585a8bb325b1a.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json diff --git a/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json b/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json new file mode 100644 index 0000000000..25a32e5338 --- /dev/null +++ b/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO deployment_metadata (workspace_id, path, script_hash, deployment_msg) VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, script_hash) WHERE script_hash IS NOT NULL DO UPDATE SET deployment_msg = EXCLUDED.deployment_msg", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a" +} From 1ff14e3f45e704ebc3250c9c643ca88db15427a3 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 21:12:24 +0000 Subject: [PATCH 193/204] sqlx nits --- 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 7a74b353a3..ad995f1326 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -a1274aa11a83f608eacc32c0d449ca3527d98c15 +a1274aa11a83f608eacc32c0d449ca3527d98c15 \ No newline at end of file From 2e2dd511f750dc2f592f10965f9747a5c96a0039 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 21:32:00 +0000 Subject: [PATCH 194/204] sqlx nits --- ...3e69e4ef8821c6cbf3b4f296b3853d95692af.json | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json diff --git a/backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json b/backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json new file mode 100644 index 0000000000..a78e67067f --- /dev/null +++ b/backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af" +} From 5501b7a729a0e5289803097671a7f033570ce7ca Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 21:33:17 +0000 Subject: [PATCH 195/204] replace host docker socket with dind sidecar for isolation (#8531) * feat: replace host docker socket with dind sidecar for isolation Co-Authored-By: Claude Opus 4.6 (1M context) * chore: comment out dind sidecar by default to avoid wasting resources Co-Authored-By: Claude Opus 4.6 (1M context) * fix: enable dind by default, comment out insecure host socket mount Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- backend/windmill-worker/src/bash_executor.rs | 32 ++++++++++++++++-- docker-compose.yml | 34 +++++++++++++++++--- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/backend/windmill-worker/src/bash_executor.rs b/backend/windmill-worker/src/bash_executor.rs index a9947faaf5..011a72fe15 100644 --- a/backend/windmill-worker/src/bash_executor.rs +++ b/backend/windmill-worker/src/bash_executor.rs @@ -180,6 +180,17 @@ exit $exit_status let _ = write_file(job_dir, "result.out", "")?; let _ = write_file(job_dir, "result2.out", "")?; + // Forward DOCKER_HOST to the bash script when in docker mode so the docker CLI + // connects to the right daemon (e.g. a dind sidecar instead of /var/run/docker.sock) + let docker_envs: Vec<(&str, String)> = if annotation.docker { + ["DOCKER_HOST", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH"] + .iter() + .filter_map(|k| std::env::var(k).ok().map(|v| (*k, v))) + .collect() + } else { + vec![] + }; + // Check if this is a regular job (not init or periodic script) // Init/periodic scripts need full system access without isolation let is_regular_job = job @@ -225,6 +236,7 @@ exit $exit_status ) .env("PATH", PATH_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) + .envs(docker_envs.iter().cloned()) .args(cmd_args) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -255,6 +267,7 @@ exit $exit_status .env("PATH", PATH_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) .env("HOME", HOME_ENV.as_str()) + .envs(docker_envs.iter().cloned()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -337,6 +350,19 @@ async fn rm_container(client: &bollard::Docker, container_id: &str) { } } +#[cfg(feature = "dind")] +/// Connect to the Docker daemon, respecting DOCKER_HOST if set (e.g. for dind sidecar), +/// otherwise falling back to the default unix socket at /var/run/docker.sock. +fn connect_docker() -> Result { + if std::env::var("DOCKER_HOST").is_ok() { + // DOCKER_HOST is set — use it (e.g. tcp://dind:2375 for docker-in-docker) + bollard::Docker::connect_with_defaults() + } else { + // No DOCKER_HOST — use the unix socket (backward compatible default) + bollard::Docker::connect_with_unix_defaults() + } +} + #[cfg(feature = "dind")] async fn handle_docker_job( job_id: Uuid, @@ -351,7 +377,7 @@ async fn handle_docker_job( ) -> Result, Error> { use crate::job_logger::append_logs_with_compaction; - let client = bollard::Docker::connect_with_unix_defaults().map_err(to_anyhow)?; + let client = connect_docker().map_err(to_anyhow)?; let container_id = job_id.to_string(); let inspected = client.inspect_container(&container_id, None).await; @@ -396,7 +422,7 @@ async fn handle_docker_job( let workspace_id2 = workspace_id.to_string(); let mut killpill_rx = killpill_rx.resubscribe(); let logs = tokio::spawn(async move { - let client = bollard::Docker::connect_with_unix_defaults().map_err(to_anyhow); + let client = connect_docker().map_err(to_anyhow); if let Ok(client) = client { let mut log_stream = client.logs( &ncontainer_id, @@ -464,7 +490,7 @@ async fn handle_docker_job( } }); - let mem_client = bollard::Docker::connect_with_unix_defaults().map_err(to_anyhow); + let mem_client = connect_docker().map_err(to_anyhow); let ncontainer_id = container_id.clone(); let result = run_future_with_polling_update_job_poller( job_id, diff --git a/docker-compose.yml b/docker-compose.yml index 7152b8bf55..ffec3d5dda 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,25 @@ services: logging: *default-logging + # Docker-in-Docker sidecar: provides an isolated Docker daemon so user scripts + # can run containers without accessing the host Docker socket. + dind: + image: docker:dind + privileged: true + restart: unless-stopped + environment: + DOCKER_TLS_CERTDIR: "" + volumes: + - dind-data:/var/lib/docker + expose: + - 2375 + healthcheck: + test: ["CMD", "docker", "info"] + interval: 10s + timeout: 5s + retries: 5 + logging: *default-logging + windmill_worker: image: ${WM_IMAGE} pull_policy: always @@ -71,15 +90,23 @@ services: # If running with non-root/non-windmill UID (e.g., user: "1001:1001"), # add: - HOME=/tmp - FAVOR_UNSHARE_PID=true + # Connect to the dind sidecar instead of the host Docker socket + - DOCKER_HOST=tcp://dind:2375 depends_on: db: condition: service_healthy + dind: + condition: service_healthy # to mount the worker folder to debug, KEEP_JOB_DIR=true and mount /tmp/windmill volumes: - # mount the docker socket to allow to run docker containers from within the workers - - /var/run/docker.sock:/var/run/docker.sock - worker_dependency_cache:/tmp/windmill/cache - worker_logs:/tmp/windmill/logs + ## WARNING: mounting the host Docker socket grants user scripts full access to + ## the host Docker daemon, enabling host filesystem access and privilege escalation. + ## Only use this if you fully trust all users who can run scripts. + ## To use it, remove the DOCKER_HOST env var and dind depends_on above, + ## and uncomment the line below: + # - /var/run/docker.sock:/var/run/docker.sock logging: *default-logging @@ -138,8 +165,6 @@ services: # condition: service_healthy # # to mount the worker folder to debug, KEEP_JOB_DIR=true and mount /tmp/windmill # volumes: - # # mount the docker socket to allow to run docker containers from within the workers - # - /var/run/docker.sock:/var/run/docker.sock # - worker_dependency_cache:/tmp/windmill/cache # - worker_logs:/tmp/windmill/logs @@ -216,3 +241,4 @@ volumes: windmill_index: null lsp_cache: null caddy_data: null + dind-data: null From b7475c73094a28f520f798f6cb1a0c6b4807ccb7 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 25 Mar 2026 22:33:39 +0100 Subject: [PATCH 196/204] fix: consider wmill.yaml environments alias in git sync (#8532) --- backend/windmill-common/src/workspaces.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 6dc910e0d4..444df60a0c 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -149,7 +149,7 @@ pub enum ObjectType { WorkspaceDependencies, } -pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28160/sync-script-to-git-repo-windmill"; +pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28180/sync-script-to-git-repo-windmill"; #[derive(Serialize, Deserialize, Debug)] pub struct GitRepositorySettings { From 36a81004dcdd9b9faea0402f431f36678063624e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 21:51:51 +0000 Subject: [PATCH 197/204] buffer stdin lines in deno dedicated worker wrapper to prevent chunk splitting (#8533) Co-authored-by: Claude Opus 4.6 (1M context) --- backend/windmill-worker/src/deno_executor.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/windmill-worker/src/deno_executor.rs b/backend/windmill-worker/src/deno_executor.rs index a0fd9fdde8..3013d10689 100644 --- a/backend/windmill-worker/src/deno_executor.rs +++ b/backend/windmill-worker/src/deno_executor.rs @@ -672,10 +672,15 @@ BigInt.prototype.toJSON = function () {{ console.log('start\n'); const decoder = new TextDecoder(); +let _buffer = ""; for await (const chunk of Deno.stdin.readable) {{ - const lines = decoder.decode(chunk); + _buffer += decoder.decode(chunk, {{ stream: true }}); + const _parts = _buffer.split("\n"); + _buffer = _parts.pop() ?? ""; let exit = false; - for (const line of lines.trim().split("\n")) {{ + for (const _part of _parts) {{ + const line = _part.trim(); + if (!line) continue; if (line === "end") {{ exit = true; break; From 9b3e558d84f15052e9c32695a467f8ef7e4ad1f5 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 25 Mar 2026 22:54:36 +0100 Subject: [PATCH 198/204] feat: add instance setting to enforce workspace prefix for HTTP routes (#8528) * feat: add instance-level setting to enforce workspace prefix for HTTP routes Add `http_route_workspaced_route` instance setting that forces all HTTP routes to use workspace prefix (`/api/r/{workspace_id}/{route}`), mirroring the existing `app_workspaced_route` setting for apps. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: bump http trigger version on setting change to invalidate route cache The route cache is version-based, not TTL-based. Without bumping the version sequence when the instance setting changes, cached routes would continue serving with the old prefix behavior until a route is created/updated/deleted or the server restarts. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: immediately refresh HTTP routers on setting change The route cache polls every 60 seconds, but bumping the version sequence only makes the next poll pick up changes. Explicitly call refresh_routers after the setting reload so routes are rebuilt immediately. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- ...190fb90df980a19c900452f9b1a19c3620e30.json | 32 ++++++++ backend/src/main.rs | 29 ++++++-- backend/src/monitor.rs | 45 +++++++++++- backend/windmill-api-settings/src/lib.rs | 73 ++++++++++++++++++- .../windmill-common/src/global_settings.rs | 8 ++ .../windmill-common/src/instance_config.rs | 2 + backend/windmill-trigger-http/src/handler.rs | 24 ++++-- backend/windmill-trigger-http/src/lib.rs | 13 ++-- .../src/lib/components/instanceSettings.ts | 10 +++ .../http/RouteEditorConfigSection.svelte | 28 ++++++- .../triggers/http/RoutesGenerator.svelte | 23 +++++- .../(root)/(logged)/routes/+page.svelte | 22 +++++- 12 files changed, 281 insertions(+), 28 deletions(-) create mode 100644 backend/.sqlx/query-87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30.json diff --git a/backend/.sqlx/query-87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30.json b/backend/.sqlx/query-87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30.json new file mode 100644 index 0000000000..aaad5153bd --- /dev/null +++ b/backend/.sqlx/query-87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n route_path,\n workspace_id,\n http_method::TEXT AS \"http_method!\"\n FROM\n http_trigger\n WHERE\n workspaced_route IS FALSE\n AND route_path_key IN (\n SELECT\n route_path_key\n FROM\n http_trigger\n WHERE\n workspaced_route IS FALSE\n GROUP BY\n route_path_key, http_method\n HAVING COUNT(*) > 1\n )\n ORDER BY route_path_key\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "route_path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "http_method!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + null + ] + }, + "hash": "87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30" +} diff --git a/backend/src/main.rs b/backend/src/main.rs index bba51ada6b..ad5b9e14b8 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -44,8 +44,8 @@ use windmill_common::{ CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING, - HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING, - INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING, + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, + INDEXER_SETTING, INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING, @@ -104,10 +104,10 @@ use crate::monitor::{ reload_base_url_setting, reload_bunfig_install_scopes_setting, reload_critical_alert_mute_ui_setting, reload_critical_alerts_on_token_expiry_setting, reload_critical_error_channels_setting, reload_extra_pip_index_url_setting, - reload_hub_api_secret_setting, reload_hub_base_url_setting, - reload_instance_events_webhook_setting, reload_job_default_timeout_setting, - reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key, - reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting, + reload_http_route_workspaced_route_setting, reload_hub_api_secret_setting, + reload_hub_base_url_setting, reload_instance_events_webhook_setting, + reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting, + reload_license_key, reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting, reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting, reload_smtp_config, reload_uv_index_strategy_setting, reload_worker_config, MonitorIteration, }; @@ -1814,6 +1814,23 @@ async fn process_notify_event( tracing::error!(error = %e, "Could not reload app workspaced route setting"); } } + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING => { + if let Err(e) = reload_http_route_workspaced_route_setting(db).await { + tracing::error!(error = %e, "Could not reload http route workspaced route setting"); + } + #[cfg(feature = "http_trigger")] + match windmill_api::triggers::http::refresh_routers(db).await { + Ok((true, _)) => { + tracing::info!( + "Refreshed HTTP routers (http workspaced route setting change)" + ); + } + Err(err) => { + tracing::error!("Error refreshing HTTP routers (http workspaced route setting change): {err:#}"); + } + _ => {} + } + } AI_CONFIG_SETTING => { tracing::info!("AI config setting changed, bumping instance AI cache revision"); bump_instance_ai_config_revision(); diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 082789ef4f..d7f5784b50 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -88,7 +88,13 @@ use windmill_common::{ MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED, OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED, SERVICE_LOG_RETENTION_SECS, }; -use windmill_common::{client::AuthedClient, global_settings::APP_WORKSPACED_ROUTE_SETTING}; +use windmill_common::{ + client::AuthedClient, + global_settings::{ + APP_WORKSPACED_ROUTE_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE, + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, + }, +}; #[cfg(feature = "parquet")] use windmill_object_store::reload_object_store_setting; use windmill_queue::{cancel_job, get_queued_job_v2, SameWorkerPayload}; @@ -296,6 +302,10 @@ pub async fn initial_load( if let Err(e) = reload_app_workspaced_route_setting(db).await { tracing::error!("Error reloading app workspaced route: {:?}", e) } + + if let Err(e) = reload_http_route_workspaced_route_setting(db).await { + tracing::error!("Error reloading http route workspaced route: {:?}", e) + } } #[cfg(feature = "parquet")] @@ -3390,6 +3400,39 @@ pub async fn reload_app_workspaced_route_setting(conn: &DB) -> error::Result<()> Ok(()) } +pub async fn reload_http_route_workspaced_route_setting(conn: &DB) -> error::Result<()> { + let http_route_workspaced_route = + load_value_from_global_settings(conn, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING).await?; + + let ws_route = match http_route_workspaced_route { + Some(serde_json::Value::Bool(ws_route)) => ws_route, + None => false, + _ => { + tracing::error!( + "Expected {} to be a boolean got: {:?}. Defaulting to false", + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, + http_route_workspaced_route + ); + false + } + }; + + let mut l = HTTP_ROUTE_WORKSPACED_ROUTE.write().await; + + if *l != ws_route { + *l = ws_route; + drop(l); + // Bump the HTTP trigger version so the route cache is rebuilt with + // the updated workspaced_route behavior on the next request. + sqlx::query!("SELECT nextval('http_trigger_version_seq')") + .fetch_one(conn) + .await?; + } else { + *l = ws_route; + } + Ok(()) +} + pub async fn reload_critical_alerts_on_db_oversize(conn: &DB) -> error::Result<()> { #[derive(Deserialize)] struct DBOversize { diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index d1b52a8cc3..a1ea80f173 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -45,8 +45,8 @@ use windmill_common::{ global_settings::{ AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING, - EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, - WS_BASE_URL_SETTING, + EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, + HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, WS_BASE_URL_SETTING, }, instance_config::{self, ApplyMode, InstanceConfig}, server::Smtp, @@ -424,6 +424,74 @@ async fn run_setting_pre_write_hook( } } } + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING => { + let serde_json::Value::Bool(workspaced_route) = value else { + return Err(error::Error::BadRequest(format!( + "{} setting expected to be boolean", + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING + ))); + }; + + if !*workspaced_route { + #[derive(Debug, Deserialize, Serialize)] + #[allow(unused)] + struct DuplicateRoute { + route_path: String, + workspace_id: String, + http_method: String, + } + let duplicate_routes = sqlx::query_as!( + DuplicateRoute, + r#" + SELECT + route_path, + workspace_id, + http_method::TEXT AS "http_method!" + FROM + http_trigger + WHERE + workspaced_route IS FALSE + AND route_path_key IN ( + SELECT + route_path_key + FROM + http_trigger + WHERE + workspaced_route IS FALSE + GROUP BY + route_path_key, http_method + HAVING COUNT(*) > 1 + ) + ORDER BY route_path_key + "# + ) + .fetch_all(db) + .await?; + + if !duplicate_routes.is_empty() { + tracing::error!( + "Cannot disable {} setting as duplicate http routes were found: {:?}", + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, + &duplicate_routes + ); + + #[derive(Serialize)] + struct ErrorResponse { + error: String, + details: Vec, + } + + let error_response = ErrorResponse { + error: "Duplicate HTTP route paths detected".to_string(), + details: duplicate_routes, + }; + + return Err(error::Error::JsonErr( + serde_json::to_value(error_response).unwrap(), + )); + } + } + } _ => {} } Ok(()) @@ -541,6 +609,7 @@ pub async fn get_global_setting( && key != DISABLE_HUB_SETTING && key != EMAIL_DOMAIN_SETTING && key != APP_WORKSPACED_ROUTE_SETTING + && key != HTTP_ROUTE_WORKSPACED_ROUTE_SETTING && key != WS_BASE_URL_SETTING { require_super_admin(&db, &authed.email).await?; diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index a473c06ebc..31aadb8210 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -58,12 +58,20 @@ pub const EMAIL_DOMAIN_SETTING: &str = "email_domain"; pub const OTEL_SETTING: &str = "otel"; pub const OTEL_TRACING_PROXY_SETTING: &str = "otel_tracing_proxy"; pub const APP_WORKSPACED_ROUTE_SETTING: &str = "app_workspaced_route"; +pub const HTTP_ROUTE_WORKSPACED_ROUTE_SETTING: &str = "http_route_workspaced_route"; pub const SECRET_BACKEND_SETTING: &str = "secret_backend"; pub const MIN_KEEP_ALIVE_VERSION_SETTING: &str = "min_keep_alive_version"; pub const GITHUB_ENTERPRISE_APP_SETTING: &str = "github_enterprise_app"; pub const INSTANCE_EVENTS_WEBHOOK_SETTING: &str = "instance_events_webhook"; pub const WORKSPACE_REGISTRIES_SETTING: &str = "workspace_registries"; +use std::sync::Arc; +use tokio::sync::RwLock; + +lazy_static::lazy_static! { + pub static ref HTTP_ROUTE_WORKSPACED_ROUTE: Arc> = Arc::new(RwLock::new(false)); +} + pub const ENV_SETTINGS: &[&str] = &[ "DISABLE_NSJAIL", "MODE", diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 63dde895fa..c843cc6621 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -237,6 +237,8 @@ pub struct GlobalSettings { #[serde(skip_serializing_if = "Option::is_none")] pub app_workspaced_route: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub http_route_workspaced_route: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub no_default_maven: Option, #[serde(skip_serializing_if = "Option::is_none")] pub default_tags_per_workspace: Option, diff --git a/backend/windmill-trigger-http/src/handler.rs b/backend/windmill-trigger-http/src/handler.rs index c7ca7431fd..f6ca4739da 100644 --- a/backend/windmill-trigger-http/src/handler.rs +++ b/backend/windmill-trigger-http/src/handler.rs @@ -8,6 +8,7 @@ use sqlx::PgConnection; use std::collections::HashSet; use windmill_api_auth::ApiAuthed; use windmill_audit::{audit_oss::audit_log, ActionKind}; +use windmill_common::global_settings::HTTP_ROUTE_WORKSPACED_ROUTE; use windmill_common::{ db::UserDB, error::{Error, Result}, @@ -61,11 +62,12 @@ pub async fn route_path_key_exists( .await? .unwrap_or(false) } else { - let route_path_key = match workspaced_route { - Some(true) => { - std::borrow::Cow::Owned(format!("{}/{}", w_id, route_path_key.trim_matches('/'))) - } - _ => std::borrow::Cow::Borrowed(route_path_key), + let http_route_workspaced = *HTTP_ROUTE_WORKSPACED_ROUTE.read().await; + let effective_workspaced = workspaced_route.unwrap_or(false) || http_route_workspaced; + let route_path_key = if effective_workspaced { + std::borrow::Cow::Owned(format!("{}/{}", w_id, route_path_key.trim_matches('/'))) + } else { + std::borrow::Cow::Borrowed(route_path_key) }; sqlx::query_scalar!( @@ -146,6 +148,10 @@ pub async fn insert_new_trigger_into_db( ) -> Result<()> { require_admin(authed.is_admin, &authed.username)?; + let http_route_workspaced = *HTTP_ROUTE_WORKSPACED_ROUTE.read().await; + let effective_workspaced = + trigger.config.workspaced_route.unwrap_or(false) || http_route_workspaced; + let request_type = trigger.config.request_type; let resolved_edited_by = trigger.base.resolve_edited_by(authed); let resolved_permissioned_as = trigger.base.resolve_permissioned_as(authed); @@ -186,7 +192,7 @@ pub async fn insert_new_trigger_into_db( trigger.base.path, trigger.config.route_path, route_path_key, - trigger.config.workspaced_route.unwrap_or(false), + effective_workspaced, trigger.config.authentication_resource_path, trigger.config.wrap_body.unwrap_or(false), trigger.config.raw_string.unwrap_or(false), @@ -445,6 +451,10 @@ impl TriggerCrud for HttpTrigger { let route_path_key = check_if_route_exist(db, &trigger.config, workspace_id, Some(path)).await?; + let http_route_workspaced = *HTTP_ROUTE_WORKSPACED_ROUTE.read().await; + let effective_workspaced = + trigger.config.workspaced_route.unwrap_or(false) || http_route_workspaced; + let request_type = trigger.config.request_type; sqlx::query!( @@ -481,7 +491,7 @@ impl TriggerCrud for HttpTrigger { "#, route_path, &route_path_key, - trigger.config.workspaced_route, + Some(effective_workspaced), trigger.config.wrap_body, trigger.config.raw_string, trigger.config.authentication_resource_path, diff --git a/backend/windmill-trigger-http/src/lib.rs b/backend/windmill-trigger-http/src/lib.rs index f5fd563ab3..f851dfc12e 100644 --- a/backend/windmill-trigger-http/src/lib.rs +++ b/backend/windmill-trigger-http/src/lib.rs @@ -7,6 +7,7 @@ use tokio::sync::{RwLock, RwLockReadGuard}; use windmill_common::{ error::{Error, Result}, flows::Retry, + global_settings::HTTP_ROUTE_WORKSPACED_ROUTE, utils::ExpiringCacheEntry, worker::CLOUD_HOSTED, DB, @@ -273,13 +274,15 @@ pub async fn refresh_routers(db: &DB) -> Result<(bool, RwLockReadGuard<'_, Route .await?; let mut router = matchit::Router::new(); + let http_route_workspaced = *HTTP_ROUTE_WORKSPACED_ROUTE.read().await; for trigger in triggers { - let full_path = if trigger.workspaced_route || *CLOUD_HOSTED { - format!("/{}/{}", trigger.workspace_id, trigger.route_path) - } else { - format!("/{}", trigger.route_path) - }; + let full_path = + if trigger.workspaced_route || *CLOUD_HOSTED || http_route_workspaced { + format!("/{}/{}", trigger.workspace_id, trigger.route_path) + } else { + format!("/{}", trigger.route_path) + }; if trigger.is_static_website { router diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 438c09fdb5..c6096fab7b 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -196,6 +196,16 @@ export const settings: Record = { ee_only: '', hideInQuickSetup: true }, + { + label: 'HTTP route workspace prefix', + description: + 'When enabled HTTP routes will be accessible at /api/r/{workspace_id}/{route} instead of /api/r/{route} allowing you to define same route path in different workspaces without conflict', + key: 'http_route_workspaced_route', + fieldType: 'boolean', + storage: 'setting', + ee_only: '', + hideInQuickSetup: true + }, { label: 'Audit log retention (days)', key: 'audit_log_retention_days', diff --git a/frontend/src/lib/components/triggers/http/RouteEditorConfigSection.svelte b/frontend/src/lib/components/triggers/http/RouteEditorConfigSection.svelte index cbbaef9395..5642ea784a 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorConfigSection.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorConfigSection.svelte @@ -5,7 +5,7 @@ import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import { userStore, workspaceStore } from '$lib/stores' - import { HttpTriggerService } from '$lib/gen' + import { HttpTriggerService, SettingService } from '$lib/gen' // import { page } from '$app/state' import { getHttpRoute } from './utils' import { isCloudHosted } from '$lib/cloud' @@ -106,6 +106,26 @@ let userIsAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin) let userCanEditConfig = $derived(userIsAdmin || isDraftOnly) // User can edit config if they are admin or if the trigger is a draft which will not be saved + + let globalHttpWorkspacedRoute = $state(false) + + async function loadGlobalHttpWorkspacedRouteSetting() { + try { + const setting = await SettingService.getGlobal({ key: 'http_route_workspaced_route' }) + globalHttpWorkspacedRoute = (setting as boolean) ?? false + } catch (error) { + globalHttpWorkspacedRoute = false + } + } + + loadGlobalHttpWorkspacedRouteSetting() + + $effect.pre(() => { + if (globalHttpWorkspacedRoute && !workspaced_route) { + workspaced_route = true + dirtyRoutePath = true + } + })
    @@ -172,13 +192,15 @@ { workspaced_route = !workspaced_route dirtyRoutePath = true }} options={{ - right: 'Prefix with workspace', + right: globalHttpWorkspacedRoute + ? 'Prefix with workspace (enforced by instance setting)' + : 'Prefix with workspace', rightTooltip: 'Prefixes the route with the workspace ID (e.g., {base_url}/api/r/{workspace_id}/{route}). Note: deploying the HTTP trigger to another workspace updates the route workspace prefix accordingly.', rightDocumentationLink: diff --git a/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte b/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte index b8f4a39759..1fe13d01d4 100644 --- a/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte +++ b/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte @@ -1,5 +1,11 @@