diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 0b517c26f7..a6316f6ef6 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.831.0" + ".": "1.832.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 98686e7917..67733a3aca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.832.0](https://github.com/windmill-labs/windmill/compare/v1.831.0...v1.832.0) (2026-10-13) + + +### Features + +* record each instance's python version and start new ones on 3.13 ([#11656](https://github.com/windmill-labs/windmill/issues/11656)) ([7558ce2](https://github.com/windmill-labs/windmill/commit/7558ce27a9736bef1a9b9db9b0799150dccb11a5)) + + +### Bug Fixes + +* badge the unavailable sign-in option in the oauth resource form ([#11728](https://github.com/windmill-labs/windmill/issues/11728)) ([9c44f5a](https://github.com/windmill-labs/windmill/commit/9c44f5a917fc628e870073c126bf9480c98c7f73)) +* serve s3 proxy objects with nosniff and a sandbox csp ([#11731](https://github.com/windmill-labs/windmill/issues/11731)) ([e29a0dc](https://github.com/windmill-labs/windmill/commit/e29a0dc5dfed4bb62b894e059c9506873d32a62c)) +* widen variable.value without breaking live servers' cached queries ([#11737](https://github.com/windmill-labs/windmill/issues/11737)) ([89e6bdb](https://github.com/windmill-labs/windmill/commit/89e6bdbcf50e065f6906e1ab93b3c4109aba3ab6)) + ## [1.831.0](https://github.com/windmill-labs/windmill/compare/v1.830.0...v1.831.0) (2026-10-13) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 9ee5635c37..164a72d9ae 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -15486,7 +15486,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-nats", @@ -15576,7 +15576,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.831.0" +version = "1.832.0" dependencies = [ "async-stream", "async-trait", @@ -15611,7 +15611,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -15624,7 +15624,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "argon2", @@ -15767,7 +15767,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -15790,7 +15790,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -15807,7 +15807,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "axum", @@ -15833,7 +15833,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.831.0" +version = "1.832.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15843,7 +15843,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -15860,7 +15860,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "base64 0.22.1", @@ -15882,7 +15882,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "axum", @@ -15905,7 +15905,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -15921,7 +15921,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -15943,7 +15943,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -15965,7 +15965,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -15979,7 +15979,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-nats", @@ -16015,7 +16015,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "axum", @@ -16041,7 +16041,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -16069,7 +16069,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "axum", @@ -16091,7 +16091,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -16111,7 +16111,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "axum", @@ -16153,7 +16153,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "axum", @@ -16182,7 +16182,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.831.0" +version = "1.832.0" dependencies = [ "lazy_static", "serde", @@ -16194,7 +16194,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.831.0" +version = "1.832.0" dependencies = [ "argon2", "axum", @@ -16218,7 +16218,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -16232,7 +16232,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -16271,7 +16271,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.831.0" +version = "1.832.0" dependencies = [ "chrono", "lazy_static", @@ -16286,7 +16286,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "axum", @@ -16305,7 +16305,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.831.0" +version = "1.832.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -16421,7 +16421,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.831.0" +version = "1.832.0" dependencies = [ "chrono", "futures", @@ -16441,7 +16441,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.831.0" +version = "1.832.0" dependencies = [ "regex", "serde", @@ -16458,7 +16458,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16485,7 +16485,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "futures", @@ -16502,7 +16502,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.831.0" +version = "1.832.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16518,7 +16518,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -16540,7 +16540,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -16571,7 +16571,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "arc-swap", @@ -16596,7 +16596,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-stream", @@ -16632,7 +16632,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "futures", @@ -16651,7 +16651,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.831.0" +version = "1.832.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16660,7 +16660,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -16672,7 +16672,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "serde_json", @@ -16684,7 +16684,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "gosyn", @@ -16696,7 +16696,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -16708,7 +16708,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "serde_json", @@ -16720,7 +16720,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "nu-parser", @@ -16731,7 +16731,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16742,7 +16742,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16754,7 +16754,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16765,7 +16765,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-recursion", @@ -16788,7 +16788,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "serde_json", @@ -16800,7 +16800,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -16814,7 +16814,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16831,7 +16831,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -16844,7 +16844,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "serde", @@ -16856,7 +16856,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -16874,7 +16874,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16890,7 +16890,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16907,7 +16907,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -16921,7 +16921,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-recursion", @@ -16960,7 +16960,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "const_format", @@ -16998,7 +16998,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.831.0" +version = "1.832.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17009,7 +17009,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-recursion", @@ -17044,7 +17044,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17068,7 +17068,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17101,7 +17101,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17128,7 +17128,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17161,7 +17161,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17181,7 +17181,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17215,7 +17215,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17252,7 +17252,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17275,7 +17275,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17299,7 +17299,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-nats", @@ -17323,7 +17323,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17358,7 +17358,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17386,7 +17386,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17411,7 +17411,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -17430,7 +17430,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-once-cell", @@ -17549,7 +17549,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.831.0" +version = "1.832.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index a72480cab2..5b9ff24831 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.831.0" +version = "1.832.0" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.831.0" +version = "1.832.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index aba51ba117..c8c0108c7b 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -9cd6a70486df465294f23071fbf383f21ef47ba9 +581b18cac39a4bee2671fd1aa86f91b9e702e8f5 diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index b5ebd80e2a..a29aecb92d 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -446,7 +446,7 @@ ], "req_body_auth": true }, - "outlook": { + "ms_outlook": { "req_body_auth": true, "scopes": ["offline_access", "User.Read", "Mail.ReadWrite", "Mail.Send", "Calendars.ReadWrite", "Contacts.ReadWrite"], "scope_options": [ diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index c0d6aa95d4..f8d4ae6c0d 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6340,7 +6340,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.831.0" +version = "1.832.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -6430,7 +6430,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.831.0" +version = "1.832.0" dependencies = [ "proc-macro2", "quote", @@ -6442,7 +6442,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.831.0" +version = "1.832.0" dependencies = [ "convert_case", "serde", @@ -6451,7 +6451,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "serde_json", @@ -6475,7 +6475,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "gosyn", @@ -6487,7 +6487,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -6499,7 +6499,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "serde_json", @@ -6511,7 +6511,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "nu-parser", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6533,7 +6533,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6545,7 +6545,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6556,7 +6556,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-recursion", @@ -6579,7 +6579,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "serde_json", @@ -6591,7 +6591,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -6605,7 +6605,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "convert_case", @@ -6622,7 +6622,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -6635,7 +6635,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "serde", @@ -6647,7 +6647,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -6665,7 +6665,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6681,7 +6681,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6698,7 +6698,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6730,7 +6730,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -6744,7 +6744,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index d72119afd4..d72c4d87d0 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.831.0" +version = "1.832.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/src/db_connect.rs b/backend/src/db_connect.rs index d4dee19f82..ab12ae6f6e 100644 --- a/backend/src/db_connect.rs +++ b/backend/src/db_connect.rs @@ -153,6 +153,9 @@ pub async fn connect( pool_options = pool_options.idle_timeout(Duration::from_secs(60)); } pool_options + .before_acquire(|_, meta| { + Box::pin(async move { Ok(!windmill_common::db::connection_is_retired(meta.age)) }) + }) .after_connect(move |conn, _| { if worker_mode { Box::pin(async move { diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 23ab2eaed1..4fae6f34ab 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -4469,12 +4469,14 @@ pub async fn monitor_db( // Re-encrypt the legacy-format secret variables once new values are written in AES-256-GCM. // Every ~5 min until one pass has gone through every workspace; spawned so a large instance - // does not hold up the join below. + // does not hold up the join below. The column widening it starts with is tried every + // iteration: a v2 value too long for the narrow column fails to save until it is done. let legacy_secret_upgrade_f = async { if server_mode && iteration.is_some() - && iteration.as_ref().unwrap().should_run(30) - && windmill_common::variables::legacy_secret_upgrade_pending() + && (windmill_common::variables::variable_value_widen_pending() + || (iteration.as_ref().unwrap().should_run(30) + && windmill_common::variables::legacy_secret_upgrade_pending())) { if let Some(db) = conn.as_sql() { let db = db.clone(); diff --git a/backend/tests/ai_sessions.rs b/backend/tests/ai_sessions.rs index 6b33a4141e..b2b8406db3 100644 --- a/backend/tests/ai_sessions.rs +++ b/backend/tests/ai_sessions.rs @@ -1580,3 +1580,163 @@ async fn test_backups_fall_back_to_the_instance_storage(db: Pool) -> a Ok(()) } + +/// Runs its closure when dropped, so process-wide state is put back even when an assertion +/// fails. +struct OnDrop(F); +impl Drop for OnDrop { + fn drop(&mut self) { + (self.0)() + } +} + +/// Once every worker reads AES-256-GCM and this server has been up long enough for any server +/// of the previous release to be gone, objects are written in it: a session backed up as CBC +/// before the switch still restores whole with the GCM pieces added after it, a head at its +/// cap and a push token still read back under the larger ciphertext, and relocated GCM +/// ciphertext does not decrypt for another user. +#[sqlx::test(fixtures("base"))] +async fn test_backups_switch_to_gcm_once_every_worker_reads_it( + db: Pool, +) -> anyhow::Result<()> { + use windmill_common::min_version::{ + MIN_VERSION, MIN_VERSION_SUPPORTS_AEAD_SECRETS, MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES, + }; + use windmill_common::workspace_crypt::V2_MAGIC; + + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let base = format!( + "http://localhost:{}/api/w/test-workspace", + server.addr.port() + ); + let storage_dir = tempfile::tempdir()?; + configure_primary_lfs(&db, &storage_dir.path().to_string_lossy()).await?; + let before = MIN_VERSION.load_full(); + let _restore = OnDrop(move || MIN_VERSION.store(before.clone())); + let _uptime = OnDrop(|| windmill_common::db::set_process_uptime_offset(Default::default())); + let is_gcm = |b: &[u8]| b.starts_with(&V2_MAGIC); + let object = |path: &str| -> Vec { + std::fs::read(user_root(storage_dir.path(), "test@windmill.dev").join(path)).unwrap() + }; + + MIN_VERSION.store(std::sync::Arc::new( + MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES.version().clone(), + )); + let resp = push( + &base, + "SECRET_TOKEN", + json!({ + "owner": "test@windmill.dev", + "sessions": [{ + "id": "s1", + "whole": true, + "head": { "id": "s1", "workspace_id": "test-workspace", "createdAt": 1, "chatId": "c1" }, + "chats": [{ "id": "c1", "record": { "id": "c1", "title": "BEFORE" } }] + }] + }), + ) + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let cbc_chat = object("sessions/s1/chats/c1.json"); + assert!(!cbc_chat.is_empty() && !is_gcm(&cbc_chat)); + + MIN_VERSION.store(std::sync::Arc::new( + MIN_VERSION_SUPPORTS_AEAD_SECRETS.version().clone(), + )); + // Every worker reads it, but this server was only just started: still CBC. + let resp = push( + &base, + "SECRET_TOKEN", + json!({ + "owner": "test@windmill.dev", + "sessions": [{ "id": "s1", "chats": [{ "id": "c0", "record": { "id": "c0", "title": "FRESH" } }] }] + }), + ) + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let fresh_chat = object("sessions/s1/chats/c0.json"); + assert!(!fresh_chat.is_empty() && !is_gcm(&fresh_chat)); + + windmill_common::db::set_process_uptime_offset( + windmill_common::db::PREVIOUS_RELEASE_ROLLOUT_UPTIME, + ); + let resp = push( + &base, + "SECRET_TOKEN", + json!({ + "owner": "test@windmill.dev", + "sessions": [{ + "id": "s1", + "chats": [{ "id": "c2", "record": { "id": "c2", "title": "AFTER" } }], + "images": [{ "chat_id": "c2", "id": "i1", "data_url": "data:image/png;base64,AAAA" }] + }] + }), + ) + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + assert_eq!( + object("sessions/s1/chats/c1.json"), + cbc_chat, + "not rewritten" + ); + assert!(is_gcm(&object("sessions/s1/chats/c2.json"))); + assert!(is_gcm(&object("images/s1/c2/i1"))); + + let pulled = pull(&base, "SECRET_TOKEN", &["s1"]).await?; + let mut titles: Vec = pulled["sessions"][0]["chats"] + .as_array() + .unwrap() + .iter() + .map(|c| c["record"]["title"].as_str().unwrap().to_string()) + .collect(); + titles.sort(); + assert_eq!(titles, ["AFTER", "BEFORE", "FRESH"], "{pulled}"); + assert_eq!( + pulled["sessions"][0]["images"][0]["data_url"], + "data:image/png;base64,AAAA" + ); + + // A head at its cap, pushed in two parts: the second reads back the push token. + let mut head = json!({ "id": "s2", "workspace_id": "test-workspace", "createdAt": 2, "chatId": "c", "pad": "" }); + let pad = 1024 * 1024 - head.to_string().len(); + head["pad"] = json!("x".repeat(pad)); + assert_eq!(head.to_string().len(), 1024 * 1024); + for part in [ + json!({ "id": "s2", "whole": true, "push": "p2", "opens": true, "partial": true, "head": head }), + json!({ "id": "s2", "whole": true, "push": "p2", "chats": [{ "id": "c", "record": { "id": "c" } }] }), + ] { + let resp = push( + &base, + "SECRET_TOKEN", + json!({ "owner": "test@windmill.dev", "sessions": [part] }), + ) + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let pushed: Value = resp.json().await?; + assert_eq!(pushed["results"], json!([{ "id": "s2" }]), "{pushed}"); + } + assert!(is_gcm(&object("sessions/s2/head.json"))); + let pulled = pull(&base, "SECRET_TOKEN", &["s2"]).await?; + assert_eq!( + pulled["sessions"][0]["head"]["pad"].as_str().map(str::len), + Some(pad) + ); + assert_eq!(pulled["sessions"][0]["chats"][0]["id"], "c", "{pulled}"); + + let first = user_root(storage_dir.path(), "test@windmill.dev"); + copy_dir( + &first, + &first + .parent() + .unwrap() + .join(calculate_hash("test2@windmill.dev")), + )?; + assert_eq!( + pull(&base, "SECRET_TOKEN_2", &["s1", "s2"]).await?["sessions"], + json!([]), + "relocated ciphertext must not decrypt for another user" + ); + + Ok(()) +} diff --git a/backend/tests/s3_proxy_headers.rs b/backend/tests/s3_proxy_headers.rs new file mode 100644 index 0000000000..8f43f8c761 --- /dev/null +++ b/backend/tests/s3_proxy_headers.rs @@ -0,0 +1,29 @@ +//! Every s3_proxy response carries `nosniff` and the sandbox CSP, error responses included, +//! since the presigned route serves workspace objects from the app origin without a login. +#![cfg(all(feature = "enterprise", feature = "private", feature = "parquet"))] + +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +#[sqlx::test(fixtures("base"))] +async fn test_s3_proxy_sets_content_security_headers(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let resp = reqwest::Client::new() + .get(format!( + "http://localhost:{port}/api/w/test-workspace/s3_proxy/_default_/x.html?exp=9999999999&sig=00" + )) + .send() + .await?; + + assert!(resp.status().is_client_error(), "status: {}", resp.status()); + assert_eq!(resp.headers()["x-content-type-options"], "nosniff"); + assert_eq!( + resp.headers()["content-security-policy"], + "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads" + ); + Ok(()) +} diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 1359e8c142..f9ba1ff86f 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -132,6 +132,15 @@ async fn get_instance_ui( )) } +/// Whether email notifications can go out, for the workspace admins who configure email +/// handlers and cannot read `smtp_settings` itself (it holds the password). Asks the same +/// resolver the senders use, so SMTP set through `SMTP_*` env vars counts too. +async fn get_smtp_configured(Extension(db): Extension, _authed: ApiAuthed) -> JsonResult { + Ok(Json( + SMTP_ENABLED && windmill_common::server::is_smtp_configured(&db).await?, + )) +} + pub fn global_service() -> Router { #[warn(unused_mut)] let r = Router::new() @@ -152,6 +161,7 @@ pub fn global_service() -> Router { ) .route("/instance_config/yaml", get(get_instance_config_yaml)) .route("/test_smtp", post(test_email)) + .route("/smtp_configured", get(get_smtp_configured)) .route("/test_license_key", post(test_license_key)) .route("/send_stats", post(send_stats)) .route("/get_stats", get(get_stats)) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 5295c5765f..2acb854832 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.831.0 + version: 1.832.0 title: Windmill API contact: @@ -1971,6 +1971,24 @@ paths: application/json: schema: {} + /settings/smtp_configured: + get: + summary: whether instance SMTP is configured + description: | + Readable by any authenticated user. True when this build can send email + and SMTP is set, either in the smtp_settings instance setting or through + the SMTP_* environment variables. + operationId: getSmtpConfigured + tags: + - setting + responses: + "200": + description: whether email notifications can be sent + content: + application/json: + schema: + type: boolean + /settings/test_smtp: post: summary: test smtp diff --git a/backend/windmill-api/src/ai_sessions.rs b/backend/windmill-api/src/ai_sessions.rs index 9e980a54f4..097e5c4862 100644 --- a/backend/windmill-api/src/ai_sessions.rs +++ b/backend/windmill-api/src/ai_sessions.rs @@ -24,7 +24,6 @@ use axum::{ Extension, Json, Router, }; use futures::{StreamExt, TryStreamExt}; -use magic_crypt::MagicCryptTrait; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use std::sync::Arc; @@ -33,9 +32,11 @@ use windmill_api_workspaces::ai_session_backups::{ fallback_store, generation_prefix, primary_store, storage_id, MAX_OBJECT_BYTES, }; use windmill_api_workspaces::workspaces::sessions_retention_days; +use windmill_common::db::{process_uptime, PREVIOUS_RELEASE_ROLLOUT_UPTIME}; use windmill_common::error::{Error, JsonResult, Result}; use windmill_common::utils::calculate_hash; use windmill_common::variables::{crypt_from_key_with_suffix, get_workspace_key, WorkspaceCrypt}; +use windmill_common::workspace_crypt::{CipherFormat, MAX_BYTES_OVERHEAD}; use windmill_object_store::object_store_reexports::{ ObjectStore, ObjectStoreError, Path as ObjectPath, PutPayload, }; @@ -47,9 +48,9 @@ const PULL_BODY_LIMIT: usize = 64 * 1024; /// A pull answer larger than this hands the remaining ids back as `deferred`. const PULL_RESPONSE_BUDGET: usize = 32 * 1024 * 1024; const MAX_HEAD_BYTES: usize = 1024 * 1024; -/// What the cipher adds to a plaintext at most (a block of padding): an object stored at a -/// cap is that much larger than the cap when read back. -const CIPHER_PADDING: usize = 16; +/// What the cipher adds to a plaintext at most: an object stored at a cap is that much larger +/// than the cap when read back. +const CIPHER_PADDING: usize = MAX_BYTES_OVERHEAD; /// The browser bounds an image to a 1568 px edge and re-encodes past 700 KB; this is /// well above what that produces. const MAX_IMAGE_BYTES: usize = 4 * 1024 * 1024; @@ -120,6 +121,15 @@ pub fn workspaced_service() -> Router { ) } +fn backup_format(current: CipherFormat, uptime: std::time::Duration) -> CipherFormat { + // A previous-release server reads only CBC here: GCM waits for it to be rolled out. + if current == CipherFormat::V2 && uptime >= PREVIOUS_RELEASE_ROLLOUT_UPTIME { + CipherFormat::V2 + } else { + CipherFormat::LegacyCbc + } +} + /// What reading an object yields. `Gone`: not there (deleted since the listing, or never /// pushed). `Grown`: larger than expected, so replaced since the listing (or planted), and /// left unread; a pull answers with a page ending before it rather than without it. @@ -214,7 +224,8 @@ impl Backend { } fn seal(&self, plaintext: &[u8]) -> Vec { - self.mc.encrypt_bytes_to_bytes(plaintext) + let format = backup_format(CipherFormat::current(), process_uptime()); + self.mc.encrypt_bytes_in(plaintext, format) } /// Bytes written. @@ -255,7 +266,7 @@ impl Backend { // apart beyond the cipher's padding check, which a wrong key passes now and then. match self .mc - .decrypt_bytes_to_bytes(&bytes) + .decrypt_bytes(&bytes) .ok() .and_then(|plaintext| String::from_utf8(plaintext).ok()) { @@ -1632,3 +1643,32 @@ async fn push( results, })) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backups_are_written_in_gcm_only_once_the_previous_release_is_rolled_out() { + let just_short = PREVIOUS_RELEASE_ROLLOUT_UPTIME - std::time::Duration::from_secs(1); + for (current, uptime, expected) in [ + ( + CipherFormat::V2, + PREVIOUS_RELEASE_ROLLOUT_UPTIME, + CipherFormat::V2, + ), + (CipherFormat::V2, just_short, CipherFormat::LegacyCbc), + ( + CipherFormat::LegacyCbc, + PREVIOUS_RELEASE_ROLLOUT_UPTIME * 4, + CipherFormat::LegacyCbc, + ), + ] { + assert_eq!( + backup_format(current, uptime), + expected, + "{current:?} {uptime:?}" + ); + } + } +} diff --git a/backend/windmill-api/src/db.rs b/backend/windmill-api/src/db.rs index 21b738891e..db99a07c92 100644 --- a/backend/windmill-api/src/db.rs +++ b/backend/windmill-api/src/db.rs @@ -113,6 +113,12 @@ lazy_static::lazy_static! { (20260909163047, include_str!( "../../migrations/20260909163047_workspace_delete_cascade_indexes.up.sql" ).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY").replace("DROP INDEX", "DROP INDEX CONCURRENTLY")), + // The widening fails every query the previous release's still-running + // servers prepared on the column; `variables::widen_variable_value` does it + // once they are gone. + (20261012213357, include_str!( + "../../migrations/20261012213357_widen_variable_value.up.sql" + ).replace("ALTER TABLE variable ALTER COLUMN value TYPE VARCHAR(15040);", "")), ].into_iter().collect(); } diff --git a/backend/windmill-common/src/db.rs b/backend/windmill-common/src/db.rs index 0048abf438..dc22b37157 100644 --- a/backend/windmill-common/src/db.rs +++ b/backend/windmill-common/src/db.rs @@ -290,3 +290,62 @@ impl<'b> DbExecutor<'b> for &'b mut PgConnection { &mut **self } } + +lazy_static::lazy_static! { + static ref PROCESS_START: std::time::Instant = std::time::Instant::now(); +} + +/// Milliseconds after [`PROCESS_START`] before which every pooled connection is retired; +/// 0 retires none. +static RETIRE_CONNECTIONS_OPENED_BEFORE_MS: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + +/// Postgres refuses a statement prepared before its result columns changed type (even a +/// varchar length) with this error, on every execution, for as long as the connection lives. +/// The routine is matched as well as the message, which `lc_messages` may translate. +pub fn is_stale_cached_plan(e: &sqlx::Error) -> bool { + let sqlx::Error::Database(d) = e else { + return false; + }; + d.code().as_deref() == Some("0A000") + && (d + .try_downcast_ref::() + .is_some_and(|pg| pg.routine() == Some("RevalidateCachedQuery")) + || d.message() + .contains("cached plan must not change result type")) +} + +/// Retire every pooled connection opened up to now, so a schema change that poisoned their +/// prepared statements costs one failed query instead of failing until they expire. +pub fn retire_connections_opened_before_now() { + let now = PROCESS_START.elapsed().as_millis() as u64 + 1; + RETIRE_CONNECTIONS_OPENED_BEFORE_MS.fetch_max(now, std::sync::atomic::Ordering::Relaxed); +} + +/// For the pool's `before_acquire`: whether a connection of this age must be closed. +pub fn connection_is_retired(age: std::time::Duration) -> bool { + let cutoff = RETIRE_CONNECTIONS_OPENED_BEFORE_MS.load(std::sync::atomic::Ordering::Relaxed); + cutoff != 0 && (PROCESS_START.elapsed().saturating_sub(age).as_millis() as u64) < cutoff +} + +/// Servers report no version, so the worker version gate alone cannot see a server of the +/// previous release; its rollout is assumed done once this server has been up this long. +pub const PREVIOUS_RELEASE_ROLLOUT_UPTIME: std::time::Duration = + std::time::Duration::from_secs(30 * 60); + +static PROCESS_UPTIME_OFFSET_SECS: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + +/// Time since this process first used its database pool. +pub fn process_uptime() -> std::time::Duration { + PROCESS_START.elapsed() + + std::time::Duration::from_secs( + PROCESS_UPTIME_OFFSET_SECS.load(std::sync::atomic::Ordering::Relaxed), + ) +} + +/// For tests of what waits on [`process_uptime`]: adds `offset` to it. +#[doc(hidden)] +pub fn set_process_uptime_offset(offset: std::time::Duration) { + PROCESS_UPTIME_OFFSET_SECS.store(offset.as_secs(), std::sync::atomic::Ordering::Relaxed); +} diff --git a/backend/windmill-common/src/error.rs b/backend/windmill-common/src/error.rs index c06defaab5..a54e9eb399 100644 --- a/backend/windmill-common/src/error.rs +++ b/backend/windmill-common/src/error.rs @@ -153,6 +153,10 @@ impl From for Error { impl From for Error { #[track_caller] fn from(e: sqlx::Error) -> Self { + if crate::db::is_stale_cached_plan(&e) { + tracing::warn!("a schema change invalidated cached statements, reopening the database connections: {e}"); + crate::db::retire_connections_opened_before_now(); + } Self::SqlErr { error: e, location: prettify_location(std::panic::Location::caller()) } } } diff --git a/backend/windmill-common/src/min_version.rs b/backend/windmill-common/src/min_version.rs index 05b664b23d..70878e3713 100644 --- a/backend/windmill-common/src/min_version.rs +++ b/backend/windmill-common/src/min_version.rs @@ -27,6 +27,8 @@ pub const MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES: VC = vc(1, 822, 0, "Restricted // stored secret variables. Reading it ships one release earlier (1.831), so a rollback to // that release still reads every secret; below it, secrets written after the switch stay // unreadable until rolled forward. Only worker versions are seen here, not servers. +// Also gates widening `variable.value`, which needs every process to retire the connections +// the widening poisons (`db::retire_connections_opened_before_now`): that also ships in 1.832. pub const MIN_VERSION_SUPPORTS_AEAD_SECRETS: VC = vc(1, 832, 0, "AES-GCM secret encryption"); pub const MIN_VERSION_SUPPORTS_NODE_DEBOUNCING: VC = vc(1, 658, 0, "Flow node debouncing"); pub const MIN_VERSION_SUPPORTS_TOKEN_HASH: VC = vc(1, 659, 0, "Token hash storage"); diff --git a/backend/windmill-common/src/server.rs b/backend/windmill-common/src/server.rs index 19da3222e9..52ef7d79cb 100644 --- a/backend/windmill-common/src/server.rs +++ b/backend/windmill-common/src/server.rs @@ -31,9 +31,24 @@ pub async fn load_smtp_config(db: &DB) -> error::Result> { Ok(parse_smtp_config(value)) } +/// Whether SMTP is set, without the "not configured" warning: any signed-in user can ask, and +/// "no" is an ordinary answer there rather than something to log. +pub async fn is_smtp_configured(db: &DB) -> error::Result { + let value = crate::global_settings::load_value_from_global_settings(db, SMTP_SETTING).await?; + Ok(resolve_smtp_config(value).is_some()) +} + /// The half of [`load_smtp_config`] after the read, so a batched settings pass can parse a /// value it already fetched. pub fn parse_smtp_config(value: Option) -> Option { + let smtp = resolve_smtp_config(value); + if smtp.is_none() { + tracing::warn!("SMTP not configured"); + } + smtp +} + +fn resolve_smtp_config(value: Option) -> Option { let config: SmtpConfigOpt = value .map(|x| serde_json::from_value(x).ok()) .flatten() @@ -57,7 +72,7 @@ pub fn parse_smtp_config(value: Option) -> Option { } else { None }; - let smtp = config_smtp.or( + config_smtp.or( if let (Some(host), username, password) = ( std::env::var("SMTP_HOST").ok(), std::env::var("SMTP_USERNAME").ok(), @@ -86,12 +101,7 @@ pub fn parse_smtp_config(value: Option) -> Option { } else { None }, - ); - if smtp.is_none() { - tracing::warn!("SMTP not configured"); - } - - smtp + ) } impl Default for SmtpConfigOpt { diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index 58766cf25b..b7a2f19d86 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -400,7 +400,61 @@ pub fn legacy_secret_upgrade_pending() -> bool { && !LEGACY_SECRET_UPGRADE_DONE.load(Ordering::Relaxed) } -/// Re-encrypt the secret variables still in the legacy format in v2, workspace by workspace. +/// AES-256-GCM ciphertext is 32 bytes (magic, nonce, tag) longer than legacy CBC before base64, +/// so the longest CBC value that fits 15000 chars is exactly 15040 chars once re-encrypted. +const VARIABLE_VALUE_WIDTH: i32 = 15040; +static VARIABLE_VALUE_WIDENED: AtomicBool = AtomicBool::new(false); + +/// Whether this process has seen `variable.value` wide enough for every v2 value. Only servers +/// look, so it stays false in a worker. +pub fn variable_value_widened() -> bool { + VARIABLE_VALUE_WIDENED.load(Ordering::Relaxed) +} + +/// Whether `variable.value` may still have to be widened to fit v2 values. +pub fn variable_value_widen_pending() -> bool { + CipherFormat::current() == CipherFormat::V2 && !VARIABLE_VALUE_WIDENED.load(Ordering::Relaxed) +} + +/// Widen `variable.value` to [`VARIABLE_VALUE_WIDTH`], or `false` when it is too early to. +/// Not a migration: changing a column's type, even its length, fails every statement a live +/// process prepared on it until its connection closes. It waits for v2 writes, gated on a +/// worker version that retires such connections on the first failure, so that no process too +/// old to recover is still running. +async fn widen_variable_value(db: &DB) -> error::Result { + if VARIABLE_VALUE_WIDENED.load(Ordering::Relaxed) { + return Ok(true); + } + // varchar's typmod is its length + 4, -1 when unbounded. + let typmod: i32 = sqlx::query_scalar( + "SELECT atttypmod FROM pg_attribute WHERE attrelid = 'variable'::regclass AND attname = 'value'", + ) + .fetch_one(db) + .await?; + if typmod >= 0 && typmod - 4 < VARIABLE_VALUE_WIDTH { + if crate::db::process_uptime() < crate::db::PREVIOUS_RELEASE_ROLLOUT_UPTIME { + return Ok(false); + } + tracing::info!("widening variable.value to VARCHAR({VARIABLE_VALUE_WIDTH})"); + let mut tx = db.begin().await?; + // Queued behind a long transaction, the ALTER would block every variable read meanwhile. + sqlx::query("SET LOCAL lock_timeout = '3s'") + .execute(&mut *tx) + .await?; + sqlx::query(&format!( + "ALTER TABLE variable ALTER COLUMN value TYPE VARCHAR({VARIABLE_VALUE_WIDTH})" + )) + .execute(&mut *tx) + .await?; + tx.commit().await?; + crate::db::retire_connections_opened_before_now(); + } + VARIABLE_VALUE_WIDENED.store(true, Ordering::Relaxed); + Ok(true) +} + +/// Widen `variable.value` for v2 values, then re-encrypt the secret variables still in the +/// legacy format in v2, workspace by workspace. /// Idempotent, and emits no git-sync event: the plaintexts do not change. pub async fn upgrade_legacy_secret_variables(db: &DB) { struct Running; @@ -409,14 +463,26 @@ pub async fn upgrade_legacy_secret_variables(db: &DB) { LEGACY_SECRET_UPGRADE_RUNNING.store(false, Ordering::Release); } } - if !legacy_secret_upgrade_pending() + if !(variable_value_widen_pending() || legacy_secret_upgrade_pending()) || LEGACY_SECRET_UPGRADE_RUNNING.swap(true, Ordering::AcqRel) { return; } // Cleared on drop, so a pass that panics does not stop every later one in this process. let _running = Running; - upgrade_legacy_secret_variables_pass(db).await; + match widen_variable_value(db).await { + Ok(true) => {} + Ok(false) => return, + Err(e) => { + tracing::warn!( + "secret encryption upgrade: widening variable.value failed, retrying later: {e:#}" + ); + return; + } + } + if legacy_secret_upgrade_pending() { + upgrade_legacy_secret_variables_pass(db).await; + } } async fn upgrade_legacy_secret_variables_pass(db: &DB) { diff --git a/backend/windmill-common/src/workspace_crypt.rs b/backend/windmill-common/src/workspace_crypt.rs index a561060e32..6bdea28391 100644 --- a/backend/windmill-common/src/workspace_crypt.rs +++ b/backend/windmill-common/src/workspace_crypt.rs @@ -30,6 +30,10 @@ pub const V2_MAGIC: [u8; 4] = *b"wmA2"; const NONCE_LEN: usize = 12; const TAG_LEN: usize = 16; const BLOCK: usize = 16; +const NARROW_VARIABLE_VALUE_WIDTH: usize = 15000; + +/// The most `encrypt_bytes_in` adds to a plaintext, in either format. +pub const MAX_BYTES_OVERHEAD: usize = V2_MAGIC.len() + NONCE_LEN + BLOCK + TAG_LEN; const AEAD_KEY_INFO: &[u8] = b"windmill workspace secret aes-256-gcm v1"; @@ -77,8 +81,8 @@ pub struct WorkspaceCrypt { aead: Aes256Gcm, } -/// Code that drives `magic_crypt` directly on this cipher (app link secrets, AI session -/// objects) keeps the legacy format it always had. +/// Code that drives `magic_crypt` directly on this cipher (app link secrets) keeps the legacy +/// format it always had. impl std::ops::Deref for WorkspaceCrypt { type Target = MagicCrypt256; fn deref(&self) -> &MagicCrypt256 { @@ -102,7 +106,23 @@ impl WorkspaceCrypt { } pub fn encrypt_str(&self, plaintext: &str) -> String { - self.encrypt_str_in(plaintext, CipherFormat::current()) + self.encrypt_str_fitting( + plaintext, + CipherFormat::current(), + crate::variables::variable_value_widened(), + ) + } + + /// A v2 value is up to 44 chars longer than CBC: until `variable.value` is known widened + /// for it, one past the narrow width is written in CBC, which fits. + fn encrypt_str_fitting(&self, plaintext: &str, format: CipherFormat, widened: bool) -> String { + if format == CipherFormat::V2 { + let v2 = self.encrypt_str_in(plaintext, CipherFormat::V2); + if widened || v2.len() <= NARROW_VARIABLE_VALUE_WIDTH { + return v2; + } + } + self.encrypt_str_in(plaintext, CipherFormat::LegacyCbc) } pub fn encrypt_str_in(&self, plaintext: &str, format: CipherFormat) -> String { @@ -136,7 +156,14 @@ impl WorkspaceCrypt { (self.decrypt_str(&upgraded).ok()? == plaintext).then_some(upgraded) } - fn decrypt_bytes(&self, bytes: &[u8]) -> Result, DecryptFailure> { + pub fn encrypt_bytes_in(&self, plaintext: &[u8], format: CipherFormat) -> Vec { + match format { + CipherFormat::V2 => self.seal(plaintext), + CipherFormat::LegacyCbc => self.legacy.encrypt_bytes_to_bytes(plaintext), + } + } + + pub fn decrypt_bytes(&self, bytes: &[u8]) -> Result, DecryptFailure> { match self.open(bytes) { Some(plaintext) => Ok(plaintext), // Also for a value with the magic: a CBC ciphertext starts with it one time in 2^32. @@ -242,6 +269,32 @@ mod tests { ); } + #[test] + fn bytes_round_trip_within_the_overhead_in_both_formats() { + let wc = WorkspaceCrypt::new(KEY); + for len in 0..64 { + let plain = vec![b'x'; len]; + for format in [CipherFormat::V2, CipherFormat::LegacyCbc] { + let sealed = wc.encrypt_bytes_in(&plain, format); + assert_eq!(wc.open(&sealed).is_some(), format == CipherFormat::V2); + assert!(sealed.len() <= len + MAX_BYTES_OVERHEAD, "len {len}"); + assert_eq!(wc.decrypt_bytes(&sealed).unwrap(), plain); + } + } + } + + #[test] + fn v2_too_long_for_a_narrow_column_is_written_in_cbc() { + let wc = WorkspaceCrypt::new(KEY); + // The longest plaintext whose CBC value fits 15000 chars. + let long = "x".repeat(11231); + let narrow = wc.encrypt_str_fitting(&long, CipherFormat::V2, false); + assert!(!wc.is_v2(&narrow) && narrow.len() <= NARROW_VARIABLE_VALUE_WIDTH); + assert_eq!(wc.decrypt_str(&narrow).unwrap(), long); + assert!(wc.is_v2(&wc.encrypt_str_fitting(&long, CipherFormat::V2, true))); + assert!(wc.is_v2(&wc.encrypt_str_fitting("short", CipherFormat::V2, false))); + } + #[test] fn v2_passes_the_block_multiple_ciphertext_shape_check() { let wc = WorkspaceCrypt::new(KEY); diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 63e9a8a5d4..884f0a69b9 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.831.0"; +export const VERSION = "v1.832.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/chat-sdk/package-lock.json b/chat-sdk/package-lock.json index 91e9b9a081..b1c9148bbe 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/chat-sdk", - "version": "1.831.0", + "version": "1.832.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/chat-sdk", - "version": "1.831.0", + "version": "1.832.0", "license": "Apache-2.0", "devDependencies": { "@ai-sdk/react": "^4.0.102", diff --git a/chat-sdk/package.json b/chat-sdk/package.json index 296be0e680..efae991922 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@windmill-labs/chat-sdk", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.831.0", + "version": "1.832.0", "author": "Ruben Fiszel", "license": "Apache-2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme", diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index b6c029daa2..5afde16493 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.831.0"; +export const VERSION = "1.832.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3b5ecd97be..c93f513b25 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.831.0", + "version": "1.832.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.831.0", + "version": "1.832.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 33c24f43bd..7a9a3f0777 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.831.0", + "version": "1.832.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte b/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte index c624b5a148..6d95ad0c82 100644 --- a/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte +++ b/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte @@ -1,6 +1,4 @@
- + {#snippet children({ item })} @@ -418,21 +290,21 @@
- {#if handlerSelected === 'custom'} + {#if draft.selected === 'custom'}
- {#if !handlerPath} + {#if !draft.custom.path}
- {#if handlerPath} + {#if draft.custom.path}

Extra arguments

{#await import('$lib/components/SchemaForm.svelte')} @@ -465,7 +337,7 @@ @@ -475,7 +347,7 @@ {/if}
{/if} - {:else if handlerSelected === 'slack'} + {:else if draft.selected === 'slack'} { - if (e.detail && errorOrRecovery === 'error') { - handlerPath = slackHandlerScriptPath - } else if (e.detail && errorOrRecovery === 'recovery') { - handlerPath = slackRecoveryHandler - } else if (e.detail && errorOrRecovery === 'success') { - handlerPath = slackSuccessHandler - } else { - handlerPath = undefined - } + on:change={(e) => { + draft.slack.path = e.detail ? builtinHubPath(errorOrRecovery, 'slack') : undefined }} /> {/if} - {#if workspaceConnectedToSlack && isSlackHandler(handlerPath)} + {#if workspaceConnectedToSlack && draft.slack.path}
{#await import('$lib/components/SchemaForm.svelte')} @@ -515,18 +379,18 @@ schemaFieldTooltip={{ channel: 'Slack channel name without the "#" - example: "windmill-alerts"' }} - bind:args={handlerExtraArgs} + bind:args={draft.slack.args} shouldHideNoInputs className="text-xs" /> {/await} - {#if $enterpriseLicense && isSlackHandler(handlerPath)} + {#if $enterpriseLicense} {#if connectionTestJob !== undefined} @@ -559,7 +423,7 @@ {:else if workspaceConnectedToSlack == undefined} {/if} - {:else if handlerSelected === 'teams'} + {:else if draft.selected === 'teams'} { - if (e.detail && errorOrRecovery === 'error') { - handlerPath = teamsHandlerScriptPath - } else if (e.detail && errorOrRecovery === 'recovery') { - handlerPath = teamsRecoveryHandler - } else if (e.detail && errorOrRecovery === 'success') { - handlerPath = teamsSuccessHandler - } else { - handlerPath = undefined - } + on:change={(e) => { + draft.teams.path = e.detail ? builtinHubPath(errorOrRecovery, 'teams') : undefined }} /> {/if} @@ -604,26 +460,26 @@ placeholder="Search Teams channels" workspace={effectiveWorkspace} teamId={teams_team_guid} - selectedChannel={handlerExtraArgs['channel'] + selectedChannel={draft.teams.args['channel'] ? { - channel_id: handlerExtraArgs['channel'], - channel_name: handlerExtraArgs['channel_name'] + channel_id: draft.teams.args['channel'], + channel_name: draft.teams.args['channel_name'] } : undefined} onSelectedChannelChange={(channel) => { - handlerExtraArgs['channel'] = channel?.channel_id - handlerExtraArgs['channel_name'] = channel?.channel_name + draft.teams.args['channel'] = channel?.channel_id + draft.teams.args['channel_name'] = channel?.channel_name }} onError={(e) => sendUserToast('Failed to load channels: ' + e.message, true)} />
- {#if $enterpriseLicense && isTeamsHandler(handlerPath) && workspaceConnectedToTeams} + {#if $enterpriseLicense && draft.teams.path && workspaceConnectedToTeams} {#if connectionTestJob !== undefined} @@ -649,7 +505,7 @@ {:else if workspaceConnectedToTeams == undefined} {/if} - {:else if handlerSelected === 'email'} + {:else if draft.selected === 'email'} {#if isCloudHosted()} Email notifications for trigger failures are only available in self-hosted Windmill @@ -662,8 +518,8 @@ handlerExtraArgs[EMAIL_RECIPIENTS_KEY] ?? [], - (recipients) => (handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = recipients) + () => draft.email.args.email_recipients ?? [], + (recipients) => (draft.email.args.email_recipients = recipients) } placeholder="Enter email addresses..." onCreateItem={(email) => { @@ -672,23 +528,24 @@ sendUserToast('Invalid email format', true) return } - const currentArray = handlerExtraArgs[EMAIL_RECIPIENTS_KEY] ?? [] - handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = [...currentArray, email] + draft.email.args.email_recipients = [ + ...(draft.email.args.email_recipients ?? []), + email + ] }} class="w-full" /> - {#if handlerExtraArgs[EMAIL_RECIPIENTS_KEY]?.length > 0} + {#if draft.email.args.email_recipients?.length > 0} - {handlerExtraArgs[EMAIL_RECIPIENTS_KEY]?.length} email{handlerExtraArgs[ - EMAIL_RECIPIENTS_KEY - ]?.length === 1 + {draft.email.args.email_recipients.length} email{draft.email.args.email_recipients + .length === 1 ? '' : 's'} configured {/if}
{/if} - {:else if handlerSelected === 'instance_alerts'} + {:else if draft.selected === 'instance_alerts'}
Failed jobs are reported to the Slack, Teams and email channels configured at the instance diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 0e2348336c..17f984bac9 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -1583,6 +1583,7 @@ // Frozen while the rename popover is open so the trail holds still as the user types. kind: 'flow', path: pathSnapshot ?? $pathStore, + summary: flowStore.val.summary, summaryContent: flowSummary, // Gated like the summary's own editor: a host that allows neither field keeps the // band's segment, which copies the path. diff --git a/frontend/src/lib/components/NavBreadcrumb.svelte b/frontend/src/lib/components/NavBreadcrumb.svelte index 8238aa5db6..6eaae745bb 100644 --- a/frontend/src/lib/components/NavBreadcrumb.svelte +++ b/frontend/src/lib/components/NavBreadcrumb.svelte @@ -160,6 +160,9 @@ not this component) — so the lead can run the whole line. // The summary is the band's, not the breadcrumb's (see PageHeaderBar), so the path reads as the // last segment only for an item that has no summary to follow it. const itemIsLast = $derived(!!item && !item.summaryContent && !item.summary) + /** Only the summary's text names the item: a control offering to add one does not, and the path + * then stays at every stage. */ + const summaryNamesItem = $derived(!!item?.summary?.trim()) // No hover fill: the chevron beside it is what reacts to the pointer. The accent stays on the // fork's own name inside, so the family's name is not painted as if it were the fork. @@ -388,7 +391,7 @@ not this component) — so the lead can run the whole line. - {@const pathShown = f.path || itemIsLast} + {@const pathShown = f.path || !summaryNamesItem} {#if !nameOnly && (item ? pathShown : section || routePage)} {@render slash()} {/if} diff --git a/frontend/src/lib/components/PageHeaderBar.svelte b/frontend/src/lib/components/PageHeaderBar.svelte index 4683cfe76a..1ad2602d8e 100644 --- a/frontend/src/lib/components/PageHeaderBar.svelte +++ b/frontend/src/lib/components/PageHeaderBar.svelte @@ -37,6 +37,8 @@ The row's height matches the sidebar's own header row, so the two read as one ba const item = $derived(content?.item) const section = $derived(content?.section) const hasSummary = $derived(!!item && !!(item.summaryContent || item.summary)) + /** A summary control can be a placeholder offering to add one; the text is what names the item. */ + const summaryNamesItem = $derived(!!item?.summary?.trim()) const pickerAfterSummary = $derived(hasSummary && !navHidden && !!item?.path) /** A phone's bar: a tighter gutter around the same row. */ @@ -58,8 +60,10 @@ The row's height matches the sidebar's own header row, so the two read as one ba let summaryContentWidth = $state(0) let handleWidth = $state(0) /** The actions box at each fold level it has been seen at, for this page. */ - let actionWidths = $state<(number | undefined)[]>([]) + let actionWidthsByVersion = $state>({}) let actionsEpoch = $state(0) + const actionsVersion = $derived(content?.actionsVersion ?? '') + const actionWidths = $derived(actionWidthsByVersion[actionsVersion] ?? []) const handleShown = $derived((navDetached.val || panelled) && !navHidden) // The bar's own padding, in px, as the classes on the row below set it. @@ -81,6 +85,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba actions.length === 0 ? [0] : actionsRigid ? actionWidths : [GAP + FLEXIBLE_ACTIONS_RESERVE], folds: actions.length > 0 && actionsRigid ? (content?.actionFolds ?? 0) : 0, earlyFolds: content?.earlyActionFolds, + summaryNamesItem, natural: { family: trailMeasures.family, fork: trailMeasures.fork, @@ -90,13 +95,34 @@ The row's height matches the sidebar's own header row, so the two read as one ba }) }) const stage = $derived(BAR_STAGES[fit.stage]) - onDestroy(pageHeader.provideActionFold(() => fit.fold)) + /** The level alone: `fit` is a new object on every recompute, and the attachment re-measuring + * on each one would feed itself. */ + const fold = $derived(fit.fold) + onDestroy(pageHeader.provideActionFold(() => fold)) - // Re-attached whenever the fold level changes, so the first reading at a level is taken even - // when the row comes out exactly as wide as it was at the last one. - function measureActions(fold: number, _epoch: number): Attachment { + // Re-attached whenever the fold level, the page or the actions' version changes, so the first + // reading there is taken even when the row comes out exactly as wide as it was before. + // The page's actions can change on screen — a run's Cancel goes once the job completes — and + // the other levels most likely changed with them, so a change here drops their widths: kept, a + // stale wider one would hold the row folded for good. The bar measures them again by trying. + function recordActions(fold: number, width: number) { + const version = actionsVersion + const widths = actionWidthsByVersion[version] ?? [] + const seen = widths[fold] + // Unchanged, nothing is written: a write recomputes `fit`, and a reading that always wrote + // kept the bar measuring every frame. + if (seen === width) return + const next = seen !== undefined ? [] : [...widths] + next[fold] = width + actionWidthsByVersion[version] = next + } + function measureActions( + fold: number, + _epoch: number, + _version: string + ): Attachment { return (node) => { - const ro = new ResizeObserver(() => (actionWidths[fold] = GAP + node.offsetWidth)) + const ro = new ResizeObserver(() => recordActions(fold, GAP + node.offsetWidth)) ro.observe(node) return () => ro.disconnect() } @@ -136,7 +162,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba afterNavigate(({ from, to }) => { scrolledUnder = false if (from?.url.pathname !== to?.url.pathname) { - actionWidths = [] + actionWidthsByVersion = {} actionsEpoch++ } }) @@ -250,7 +276,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba another level of it. With the path dropped it follows the workspace, and the slash is the trail's own separator. Fixed width, so either fits the width measured below. --> {stage.path || !summaryNamesItem ? '·' : '/'} {#if item.summaryContent}
!b.placement)) @@ -303,6 +304,9 @@ item={{ kind: errorHandlerKind, path, + // Read-only, the summary control falls back to the path when there is no summary, so the + // path is what names the item there and the trail's own copy can go. + summary: onSaved ? summary : summary || path, summaryContent, // Only where this page can save one: without `onSaved` the editor behind it has nothing // to write, and the band keeps its own segment, which copies the path. diff --git a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte index 0280455e77..30aa98b65e 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte @@ -318,6 +318,7 @@ } +export type HandlerDraft = { + selected: ErrorHandler + slack: HandlerSlot + teams: HandlerSlot + email: HandlerSlot + custom: HandlerSlot & { kind: 'script' | 'flow' } +} +type StoredHandler = { path?: string; kind: 'script' | 'flow'; args: Record } + +const SLACK_BOT_ARG = '$res:' + WORKSPACE_SLACK_BOT_TOKEN_PATH + +// Suffixes rather than full paths, so a handler stored under an older hub id is still recognised. +// Email uses one script for every mode. +const EMAIL_SUFFIX = '/workspace-or-error-handler-email' +const BUILTIN_SUFFIXES: Record> = { + error: { + slack: '/workspace-or-schedule-error-handler-slack', + teams: '/workspace-or-schedule-error-handler-teams' + }, + recovery: { + slack: '/schedule-recovery-handler-slack', + teams: '/schedule-recovery-handler-teams' + }, + success: { + slack: '/schedule-success-handler-slack', + teams: '/schedule-success-handler-teams' + } +} + +// The only settings each built-in hub script takes besides the ones the backend injects. +const BUILTIN_KEYS: Record = { + slack: ['channel'], + teams: ['channel', 'channel_name'], + email: ['email_recipients'] +} + +export function builtinHubPath(mode: HandlerMode, destination: 'slack' | 'teams'): string { + const key = + `${destination}${mode[0].toUpperCase()}${mode.slice(1)}Handler` as keyof typeof hubPaths + return hubPaths[key] +} + +export function handlerTypeForPath(mode: HandlerMode, path: string | undefined): ErrorHandler { + if (path?.startsWith('hub/')) { + if (path.endsWith(EMAIL_SUFFIX)) return 'email' + for (const [destination, suffix] of Object.entries(BUILTIN_SUFFIXES[mode])) { + if (path.endsWith(suffix)) return destination as BuiltinDestination + } + } + return 'custom' +} + +function emptyDraft(selected: ErrorHandler): HandlerDraft { + return { + selected, + slack: { args: {} }, + teams: { args: {} }, + email: { path: hubPaths.emailErrorHandler, args: {} }, + custom: { kind: 'script', args: {} } + } +} + +function pick(args: Record, keys: string[]): Record { + return Object.fromEntries(keys.filter((k) => args[k] !== undefined).map((k) => [k, args[k]])) +} + +/** Puts a stored handler into the slot of its destination; every other slot starts empty. */ +export function handlerDraftFromStored( + mode: HandlerMode, + path: string | undefined, + kind: 'script' | 'flow' = 'script', + args: Record | null | undefined = {}, + opts: { asCustom?: boolean; emptySelected?: ErrorHandler } = {} +): HandlerDraft { + if (!path) return emptyDraft(opts.emptySelected ?? 'custom') + const destination = opts.asCustom ? 'custom' : handlerTypeForPath(mode, path) + const draft = emptyDraft(destination) + if (destination === 'custom') { + draft.custom = { path, kind, args: { ...(args ?? {}) } } + } else if (destination !== 'instance_alerts') { + draft[destination] = { path, args: pick(args ?? {}, BUILTIN_KEYS[destination]) } + } + return draft +} + +/** What a save stores: the selected destination only. */ +export function handlerDraftToStored(draft: HandlerDraft): StoredHandler { + const none: StoredHandler = { kind: 'script', args: {} } + switch (draft.selected) { + case 'custom': { + const { path, kind, args } = draft.custom + return path ? { path, kind, args: $state.snapshot(args) } : none + } + case 'slack': + case 'teams': + case 'email': { + const { path, args } = draft[draft.selected] + if (!path) return none + const stored = { path, kind: 'script' as const, args: $state.snapshot(args) } + if (draft.selected === 'slack') stored.args.slack = SLACK_BOT_ARG + return stored + } + default: + return none + } +} + +export function cloneHandlerDraft(draft: HandlerDraft): HandlerDraft { + return $state.snapshot(draft) as HandlerDraft +} diff --git a/frontend/src/lib/components/handlerDraft.test.ts b/frontend/src/lib/components/handlerDraft.test.ts new file mode 100644 index 0000000000..ee6ec4a040 --- /dev/null +++ b/frontend/src/lib/components/handlerDraft.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { + handlerDraftFromStored, + handlerDraftToStored, + handlerTypeForPath +} from './handlerDraft.svelte' + +const SLACK_BOT = '$res:f/slack_bot/bot_token' + +describe('handler draft', () => { + it('round-trips every destination unchanged', () => { + const stored = [ + { + path: 'hub/28794/workspace-or-schedule-error-handler-slack', + kind: 'script' as const, + args: { channel: 'alerts', slack: SLACK_BOT } + }, + { + path: 'hub/19742/workspace-or-schedule-error-handler-teams', + kind: 'script' as const, + args: { channel: 'c1', channel_name: 'General' } + }, + { + path: 'hub/19795/workspace-or-error-handler-email', + kind: 'script' as const, + args: { email_recipients: ['ops@example.com'] } + }, + { + path: 'f/handlers/on_error', + kind: 'flow' as const, + args: { slack: '$res:u/me/my_slack', channel: 'own' } + } + ] + for (const s of stored) { + const draft = handlerDraftFromStored('error', s.path, s.kind, s.args) + expect(handlerDraftToStored(draft)).toEqual(s) + } + }) + + it("saves only the selected destination's settings", () => { + const draft = handlerDraftFromStored('error', 'u/me/handler', 'script', { + webhook_url: 'https://hooks.example/custom' + }) + draft.selected = 'email' + draft.email.args.email_recipients = ['ops@example.com'] + expect(handlerDraftToStored(draft)).toEqual({ + path: 'hub/19795/workspace-or-error-handler-email', + kind: 'script', + args: { email_recipients: ['ops@example.com'] } + }) + }) + + it('recognises built-in handlers per mode', () => { + const recoverySlack = 'hub/28791/slack/schedule-recovery-handler-slack' + expect(handlerTypeForPath('recovery', recoverySlack)).toBe('slack') + expect(handlerTypeForPath('error', recoverySlack)).toBe('custom') + }) + + it('stores no handler when the destination has none', () => { + const draft = handlerDraftFromStored('error', undefined) + expect(handlerDraftToStored(draft)).toEqual({ kind: 'script', args: {} }) + draft.selected = 'slack' + expect(handlerDraftToStored(draft)).toEqual({ kind: 'script', args: {} }) + draft.selected = 'instance_alerts' + expect(handlerDraftToStored(draft)).toEqual({ kind: 'script', args: {} }) + }) +}) diff --git a/frontend/src/lib/components/handlerSlackArg.test.ts b/frontend/src/lib/components/handlerSlackArg.test.ts deleted file mode 100644 index b087a55f26..0000000000 --- a/frontend/src/lib/components/handlerSlackArg.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { syncSlackBotArg } from './handlerSlackArg' - -const AUTO = '$res:f/slack_bot/bot_token' - -describe('syncSlackBotArg', () => { - it("leaves a custom handler's slack value alone", () => { - const args = { slack: '$res:u/me/my_slack' } - syncSlackBotArg(args, false, false) - syncSlackBotArg(args, false, true) - expect(args).toEqual({ slack: '$res:u/me/my_slack' }) - }) - - it('sets the bot token for the built-in and drops it when leaving', () => { - const args: Record = {} - syncSlackBotArg(args, true, false) - expect(args.slack).toBe(AUTO) - syncSlackBotArg(args, false, true) - expect(args).toStrictEqual({}) - }) -}) diff --git a/frontend/src/lib/components/handlerSlackArg.ts b/frontend/src/lib/components/handlerSlackArg.ts deleted file mode 100644 index 6b8e4d6f5c..0000000000 --- a/frontend/src/lib/components/handlerSlackArg.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { WORKSPACE_SLACK_BOT_TOKEN_PATH } from '$lib/consts' - -const AUTO_SLACK_ARG = '$res:' + WORKSPACE_SLACK_BOT_TOKEN_PATH - -// `slack` belongs to the Slack built-in only while it holds the value the picker set; any other -// value is a custom handler's own argument and must survive handler switches and reloads. -export function syncSlackBotArg( - args: Record, - isSlackBuiltin: boolean, - wasSlackBuiltin: boolean -) { - if (isSlackBuiltin) { - args.slack = AUTO_SLACK_ARG - } else if (wasSlackBuiltin && args.slack === AUTO_SLACK_ARG) { - delete args.slack - } -} diff --git a/frontend/src/lib/components/icons/index.ts b/frontend/src/lib/components/icons/index.ts index 2b63491c6c..70d2450f29 100644 --- a/frontend/src/lib/components/icons/index.ts +++ b/frontend/src/lib/components/icons/index.ts @@ -446,7 +446,7 @@ export const APP_TO_ICON_COMPONENT = { quickbooks: QuickbooksIcon, ms_teams_webhook: MsTeamsIcon, teams: MsTeamsIcon, - outlook: OutlookIcon, + ms_outlook: OutlookIcon, mailgun: MailgunIcon, ipinfo: IpinfoIcon, gworkspace: GoogleIcon, diff --git a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts index 3116a90e66..6098095a30 100644 --- a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts +++ b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts @@ -60,6 +60,10 @@ export type PageHeaderContent = { /** How many of the `actionFolds` levels come before the breadcrumb drops anything: buttons * that are there only while the bar has room for everything. */ earlyActionFolds?: number + /** Changes whenever the actions may have changed width at a level that is not on screen — a + * run's Cancel, folded into a menu, going once the job completes. The bar keeps what it + * measured per version, so a new one is measured afresh rather than judged by stale widths. */ + actionsVersion?: string /** A second row of the band, under the breadcrumb and the actions: where a control too wide * for one row goes at a fold that has no room for it beside them, rather than being squeezed * until it is useless. The band is taller while it is there. */ @@ -171,6 +175,7 @@ export const pageHeader = { if (c.actionsFill !== undefined) merged.actionsFill = c.actionsFill if (c.actionFolds !== undefined) merged.actionFolds = c.actionFolds if (c.earlyActionFolds !== undefined) merged.earlyActionFolds = c.earlyActionFolds + if (c.actionsVersion !== undefined) merged.actionsVersion = c.actionsVersion if (c.belowBar !== undefined) merged.belowBar = c.belowBar if (c.belowBarFold !== undefined) merged.belowBarFold = c.belowBarFold if (c.separator !== undefined) merged.separator = c.separator diff --git a/frontend/src/lib/components/pageHeaderStages.test.ts b/frontend/src/lib/components/pageHeaderStages.test.ts index cb28141fef..7366fdd244 100644 --- a/frontend/src/lib/components/pageHeaderStages.test.ts +++ b/frontend/src/lib/components/pageHeaderStages.test.ts @@ -63,5 +63,15 @@ describe('fitBar', () => { const fit = fitBar({ ...base, barWidth: 520, natural, actions: [0], folds: 0 }) expect(fit.stage).toBe(2) expect(fit.caps.name).toBeUndefined() + // A placeholder offering to add a summary names nothing: the path stays a part the bar sizes. + const placeholder = fitBar({ + ...base, + barWidth: 520, + natural, + actions: [0], + folds: 0, + summaryNamesItem: false + }) + expect(placeholder.caps.name).toBeDefined() }) }) diff --git a/frontend/src/lib/components/pageHeaderStages.ts b/frontend/src/lib/components/pageHeaderStages.ts index 4cc66363b5..ca776c9199 100644 --- a/frontend/src/lib/components/pageHeaderStages.ts +++ b/frontend/src/lib/components/pageHeaderStages.ts @@ -95,7 +95,12 @@ export function shownPathLevels(levels: string[], stage: TrailStage): string[] { } /** Whether a part is on screen at a stage. A natural width of 0 means the page has no such part. */ -export function partVisible(part: Part, stage: BarStage, natural: Record): boolean { +export function partVisible( + part: Part, + stage: BarStage, + natural: Record, + summaryNamesItem = true +): boolean { if (natural[part] <= 0) return false switch (part) { case 'summary': @@ -106,7 +111,7 @@ export function partVisible(part: Part, stage: BarStage, natural: Record + /** The summary is the item's own text, not a placeholder offering to add one: only then does it + * name the item well enough for the path to go. */ + summaryNamesItem?: boolean } export type BarFit = { @@ -185,7 +193,7 @@ export function fitBar(m: BarMeasure): BarFit { const at = BAR_STAGES[stage] // The summary gives last: it is what a person calls the item, so the path's name goes first. const parts = (['family', 'fork', 'name', 'summary'] as Part[]).filter((p) => - partVisible(p, at, m.natural) + partVisible(p, at, m.natural, m.summaryNamesItem ?? true) ) const slack = (p: Part) => Math.max(0, m.natural[p] - Math.min(m.natural[p], FLOOR[p])) // The stage was measured with its parts at their floors; this is its width with them whole. diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index 71bb4fcef2..b7ce8d7684 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -951,6 +951,7 @@ // rename popover is open so the trail holds still as the user types. kind: 'app', path: pathSnapshot ?? (newEditedPath || appPath || newPath || undefined), + summary, summaryContent: appSummary, pathTrigger: rawAppPathTrigger }} diff --git a/frontend/src/lib/components/runs/RunDetail.svelte b/frontend/src/lib/components/runs/RunDetail.svelte index 80355ed3cf..d372d5d540 100644 --- a/frontend/src/lib/components/runs/RunDetail.svelte +++ b/frontend/src/lib/components/runs/RunDetail.svelte @@ -765,6 +765,25 @@ !isCloudHosted() && editInForkAllowed(workspaceId, $userWorkspaces) ) + /** Which actions the row has: they come and go as the job runs and completes, often while the + * row is folded into the menu, where the bar cannot see them change (`actionsVersion`). */ + const actionsVersion = $derived( + [ + canDelete, + canCancel, + forceCancel, + canDebug, + canRunNow, + canEdit, + canEditInFork, + dbtResumable, + !!job?.schedule_path, + persistentScriptDefinition !== undefined, + restartStep !== undefined && canRestart(restart) + ] + .map(Number) + .join('') + ) async function runAgain() { // The form this lands on rebuilds the whole project. When resuming THIS run is the cheaper @@ -1059,7 +1078,12 @@ {@render runActions()}
{:else} - + {/if} {#snippet runActions()} diff --git a/frontend/src/lib/components/triggers/TriggerRetriesAndErrorHandler.svelte b/frontend/src/lib/components/triggers/TriggerRetriesAndErrorHandler.svelte index 86dbf31e46..f71205bc13 100644 --- a/frontend/src/lib/components/triggers/TriggerRetriesAndErrorHandler.svelte +++ b/frontend/src/lib/components/triggers/TriggerRetriesAndErrorHandler.svelte @@ -1,9 +1,10 @@