diff --git a/.github/workflows/backend-test-windows.yml b/.github/workflows/backend-test-windows.yml index c43e4ece79..e2e7318ab6 100644 --- a/.github/workflows/backend-test-windows.yml +++ b/.github/workflows/backend-test-windows.yml @@ -179,15 +179,14 @@ jobs: # binary link spikes several hundred MB of transient I/O. Capping at # 8 trades ~25% wall time for headroom on the ~75GB runner disk. CARGO_BUILD_JOBS: 8 - # backend/Cargo.toml leaves profile.dev at the default debug = 2 for - # the (large) windmill workspace crates; that debuginfo is emitted - # into every object file and embedded in each test binary, and on - # windows-msvc also spawns the mspdbsrv.exe PDB type server. Across a - # full --all --features build it is the dominant consumer of the - # ~63GB free on the runner disk (LNK1180 / disk-full during linking). - # CI needs no debug info, so drop it entirely for the dev/test - # profiles here. debug = 0 supersedes the previous split-debuginfo=off - # knob (no debuginfo => no .pdb and no LNK1318 type-server limit). + # backend/Cargo.toml keeps line tables on profile.dev for the (large) + # windmill workspace crates; that debuginfo is emitted into every + # object file and embedded in each test binary, and on windows-msvc + # also spawns the mspdbsrv.exe PDB type server. Across the worker + # crates' test build it drives the peak on the ~63GB free of the + # runner disk (LNK1180 / disk-full during linking). CI reads no + # backtraces, so drop it entirely for the dev/test profiles here: + # debug = 0 means no .pdb and no LNK1318 type-server limit. CARGO_PROFILE_DEV_DEBUG: "0" CARGO_PROFILE_TEST_DEBUG: "0" # Tests' poll-time stack frames (deep nested async fn chains in diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index 5b6a461668..cdd0e2a212 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -268,13 +268,13 @@ jobs: # overhead and extra disk. Off here (kept on for local dev via # .cargo/config.toml). Matches backend-test-windows.yml. CARGO_INCREMENTAL: "0" - # backend/Cargo.toml leaves profile.dev at the default debug = 2 for - # the (large) windmill workspace crates; that debug info is emitted - # into every object file and embedded in each test binary. Across the - # full --all --features build it is the dominant memory/disk consumer - # when mold links the windmill-api-integration-tests binary, tipping - # the runner over (lost runner reported as a canceled step). CI needs - # no debug info, so drop it entirely for the dev/test profiles here. + # backend/Cargo.toml keeps line tables on profile.dev for the (large) + # windmill workspace crates; that debug info is emitted into every + # object file and embedded in each test binary. Across the full + # --all --features build it drives the memory/disk peak when mold + # links the windmill-api-integration-tests binary, tipping the runner + # over (lost runner reported as a canceled step). CI reads no + # backtraces, so drop it entirely for the dev/test profiles here. # (test profile inherits dev, but the workspace crates link in as # dev-profile deps, so both must be set.) CI-only; local dev builds # are unaffected. diff --git a/.release-please-manifest.json b/.release-please-manifest.json index ae93300697..c46e4845e3 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.796.0" + ".": "1.799.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 947e63f5c6..aa9bd16eac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,66 @@ # Changelog +## [1.799.0](https://github.com/windmill-labs/windmill/compare/v1.798.1...v1.799.0) (2026-08-28) + + +### Features + +* enable Anthropic prompt caching on Vertex AI agent steps ([#10876](https://github.com/windmill-labs/windmill/issues/10876)) ([320f400](https://github.com/windmill-labs/windmill/commit/320f4005124202852e6e9c70b394e7f87231d278)) +* instrument AI fill/fix, evals, agents and the debugger ([#10853](https://github.com/windmill-labs/windmill/issues/10853)) ([0bbd559](https://github.com/windmill-labs/windmill/commit/0bbd559ac8a35dba04ba5e8d6f2fd8d1d1124891)) + + +### Bug Fixes + +* **datatables:** stop a fork's pg_dump restore from failing silently ([#10830](https://github.com/windmill-labs/windmill/issues/10830)) ([3ce9bbc](https://github.com/windmill-labs/windmill/commit/3ce9bbc7168b837cb2111aabd533bb67803502b8)) +* key build artifact caches on a runnable's inline modules ([#10819](https://github.com/windmill-labs/windmill/issues/10819)) ([b72ccc3](https://github.com/windmill-labs/windmill/commit/b72ccc35934165b4bad112b947ca5af064aab26f)) +* nested template literals in step inputs, and unresolvable $args tags ([#10856](https://github.com/windmill-labs/windmill/issues/10856)) ([8f349c0](https://github.com/windmill-labs/windmill/commit/8f349c032a0d75fc3350292075e5050a030f6166)) +* pre-fill the test panel JSON args editor and align its placeholder ([#10871](https://github.com/windmill-labs/windmill/issues/10871)) ([fb82f36](https://github.com/windmill-labs/windmill/commit/fb82f36e6d6492dd0740984d8d78ea4eaa30361e)) +* reject a prefixed error_handler_path on triggers ([#10847](https://github.com/windmill-labs/windmill/issues/10847)) ([d334831](https://github.com/windmill-labs/windmill/commit/d33483173526a3b352d2829ac8a2e1e229cc1127)) +* unify billable seat counting and prevent fork subscriptions ([#10818](https://github.com/windmill-labs/windmill/issues/10818)) ([7dd88c4](https://github.com/windmill-labs/windmill/commit/7dd88c470caee5f095dc240667aa7550c55696bc)) + +## [1.798.1](https://github.com/windmill-labs/windmill/compare/v1.798.0...v1.798.1) (2026-08-27) + + +### Bug Fixes + +* allow job tokens to read the automate_username_creation setting ([#10869](https://github.com/windmill-labs/windmill/issues/10869)) ([c2279db](https://github.com/windmill-labs/windmill/commit/c2279db8a96ac76382eafe254627dafd24d173fd)) + +## [1.798.0](https://github.com/windmill-labs/windmill/compare/v1.797.0...v1.798.0) (2026-08-27) + + +### Features + +* a wizard for importing a hub project, and finishing what the import cannot ([#10729](https://github.com/windmill-labs/windmill/issues/10729)) ([2913339](https://github.com/windmill-labs/windmill/commit/29133398f99cd2dd5b33057ee9df4492d82e067a)) + +## [1.797.0](https://github.com/windmill-labs/windmill/compare/v1.796.0...v1.797.0) (2026-08-26) + + +### Features + +* configurable expiry for presigned s3 public url signatures ([#10835](https://github.com/windmill-labs/windmill/issues/10835)) ([8a6dc27](https://github.com/windmill-labs/windmill/commit/8a6dc27236aca67f0efe941d9606b787c2305ea8)) +* **frontend:** flag the fork-compare datatable schema diff as legacy ([#10829](https://github.com/windmill-labs/windmill/issues/10829)) ([07c77ea](https://github.com/windmill-labs/windmill/commit/07c77ead7425f1877372d358d867445a4c525c96)) +* keep a Hub project live while an update is under review ([#10814](https://github.com/windmill-labs/windmill/issues/10814)) ([c04b570](https://github.com/windmill-labs/windmill/commit/c04b5705745c36ecbb3a551ac59459218d2e3807)) + + +### Bug Fixes + +* **cli:** keep svelte component styles in the raw-app bundle ([#10838](https://github.com/windmill-labs/windmill/issues/10838)) ([b8bf539](https://github.com/windmill-labs/windmill/commit/b8bf539c3fe2b4db9c74dd73f04b3029287acdc6)) +* **debugger:** parse bun 1.4's UUID inspector token ([#10828](https://github.com/windmill-labs/windmill/issues/10828)) ([4658224](https://github.com/windmill-labs/windmill/commit/46582245926a7f8ea961bcd125a58fbfba3530cf)) +* force HTTP router rebuild on trigger-change notification ([#10849](https://github.com/windmill-labs/windmill/issues/10849)) ([ffdf17e](https://github.com/windmill-labs/windmill/commit/ffdf17ef8dc5575dd92d62d0d0ba887c1e378576)) +* **frontend:** follow the operating workspace in step input forms ([#10834](https://github.com/windmill-labs/windmill/issues/10834)) ([6b73145](https://github.com/windmill-labs/windmill/commit/6b73145e7220232601538b801ebc9dc73fe79bbb)) +* **frontend:** key the GitHub App installation selector on installation_id ([#10831](https://github.com/windmill-labs/windmill/issues/10831)) ([78331fd](https://github.com/windmill-labs/windmill/commit/78331fda8b290a2d9a5dd92b8362ff32c8b39432)) +* **frontend:** operator menu opens on hover, pins on click ([#10824](https://github.com/windmill-labs/windmill/issues/10824)) ([665f83e](https://github.com/windmill-labs/windmill/commit/665f83e1f438e34d006429889d51a5fb6a6b6176)) +* keep connection string query parameters under token auth ([#10859](https://github.com/windmill-labs/windmill/issues/10859)) ([f131c39](https://github.com/windmill-labs/windmill/commit/f131c3920f50f9fa18cd637eac39609495999aef)) +* migrate slack resource-connect oauth to v2 ([#10836](https://github.com/windmill-labs/windmill/issues/10836)) ([9fa8159](https://github.com/windmill-labs/windmill/commit/9fa8159ad16204cab52fd18a34a48ebf13f800f6)) +* recover from unresolvable AI session links instead of a dead end ([#10854](https://github.com/windmill-labs/windmill/issues/10854)) ([e38c449](https://github.com/windmill-labs/windmill/commit/e38c449007f27b952808cba5aa812441f2ce5946)) +* require admin on workspace tarball settings export ([#10817](https://github.com/windmill-labs/windmill/issues/10817)) ([46c363f](https://github.com/windmill-labs/windmill/commit/46c363ffa4bc72bef6b367ece4bdbeef5e0eadc9)) +* restrict filesystem workspace storage to debug builds ([#10864](https://github.com/windmill-labs/windmill/issues/10864)) ([8b80b09](https://github.com/windmill-labs/windmill/commit/8b80b09f33d311f0881678577ca6004c12d97c22)) + + +### Performance Improvements + +* index the suspended-job resume test instead of filtering it ([#10863](https://github.com/windmill-labs/windmill/issues/10863)) ([69320b2](https://github.com/windmill-labs/windmill/commit/69320b28f615b897a92f580bd5961c41e5c29951)) + ## [1.796.0](https://github.com/windmill-labs/windmill/compare/v1.795.0...v1.796.0) (2026-08-24) diff --git a/backend/.sqlx/query-f47cafb6e9a6ec58ccafb06cf5e806e3fe749119214863b9111b58fff0bb9583.json b/backend/.sqlx/query-f47cafb6e9a6ec58ccafb06cf5e806e3fe749119214863b9111b58fff0bb9583.json new file mode 100644 index 0000000000..cce644b300 --- /dev/null +++ b/backend/.sqlx/query-f47cafb6e9a6ec58ccafb06cf5e806e3fe749119214863b9111b58fff0bb9583.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT plan FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "plan", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "f47cafb6e9a6ec58ccafb06cf5e806e3fe749119214863b9111b58fff0bb9583" +} diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 0d9639c6c0..7bd84dfca3 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -131,9 +131,11 @@ minimal explicit set for dev. ## Workspace object storage in dev — use the local filesystem For a dev workspace you don't need MinIO/S3: use the built-in **`FilesystemStorage`** large-file -storage (a root path on local disk). It is intentionally hidden from the settings-UI storage -dropdown (dev-only), so set it via the API. Requires the backend built with `parquet` (+ `private` -for the real S3 helpers, + `enterprise` if you want advanced permission rules enforced): +storage (a root path on local disk). It is a **debug-build affordance only** — every site that +builds a filesystem object store calls `ensure_filesystem_storage_allowed`, so release builds +refuse it, and the settings UI never offers it — so set it via the API on a `cargo run`/`cargo +test` binary. Requires the backend built with `parquet` (+ `private` for the real S3 helpers, ++ `enterprise` if you want advanced permission rules enforced): ```bash curl -X POST "$BASE/api/w//workspaces/edit_large_file_storage_config" \ diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 2721fc7283..b7f278d437 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -23,7 +23,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -261,13 +261,13 @@ dependencies = [ [[package]] name = "argon2" -version = "0.5.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ "base64ct", - "blake2", - "cpufeatures 0.2.17", + "blake2 0.11.0", + "cpufeatures 0.3.1", "password-hash", ] @@ -1338,7 +1338,7 @@ dependencies = [ "http 1.5.0", "http-body 0.4.6", "hyper 0.14.32", - "hyper 1.11.0", + "hyper 1.11.1", "hyper-rustls 0.24.2", "hyper-rustls 0.27.9", "hyper-util", @@ -1532,7 +1532,7 @@ dependencies = [ "http 1.5.0", "http-body 1.1.0", "http-body-util", - "hyper 1.11.0", + "hyper 1.11.1", "hyper-util", "itoa", "matchit 0.8.4", @@ -1629,7 +1629,7 @@ dependencies = [ "addr2line", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.8.9", "object 0.37.3", "rustc-demangle", "windows-link 0.2.1", @@ -1729,7 +1729,7 @@ dependencies = [ "clang-sys", "itertools 0.13.0", "log", - "prettyplease", + "prettyplease 0.2.37", "proc-macro2", "quote", "regex", @@ -1749,7 +1749,7 @@ dependencies = [ "clang-sys", "itertools 0.13.0", "log", - "prettyplease", + "prettyplease 0.2.37", "proc-macro2", "quote", "regex", @@ -1824,6 +1824,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "blake3" version = "1.8.7" @@ -1834,7 +1843,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq 0.4.2", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] @@ -1856,6 +1865,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-modes" version = "0.8.1" @@ -1908,7 +1926,7 @@ dependencies = [ "hex", "http 1.5.0", "http-body-util", - "hyper 1.11.0", + "hyper 1.11.1", "hyper-named-pipe", "hyper-util", "hyperlocal", @@ -1940,34 +1958,32 @@ dependencies = [ [[package]] name = "bon" -version = "3.9.3" +version = "3.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +checksum = "9e3fac94a66da67200398458a25412bcc3f9b6443b5119a6cad9cf3ccfcd8cc6" dependencies = [ "bon-macros", - "rustversion", ] [[package]] name = "bon-macros" -version = "3.9.3" +version = "3.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +checksum = "d4654961ad0494e4774c5c60b4cb4cd0ae9b9d92d039d901638b1dba97ebebf5" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "ident_case", - "prettyplease", + "prettyplease 0.3.0", "proc-macro2", "quote", - "rustversion", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] name = "borsh" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" dependencies = [ "borsh-derive", "bytes", @@ -1976,15 +1992,15 @@ dependencies = [ [[package]] name = "borsh-derive" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" dependencies = [ "once_cell", "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -2334,12 +2350,12 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -2392,7 +2408,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -2456,6 +2472,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "cms" version = "0.2.3" @@ -2476,9 +2498,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -2678,9 +2700,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -2820,6 +2842,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "csv" version = "1.4.0" @@ -2850,6 +2881,15 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curl-sys" version = "0.4.90+curl-8.21.0" @@ -3415,7 +3455,7 @@ dependencies = [ "arrow", "arrow-buffer", "base64 0.22.1", - "blake2", + "blake2 0.10.6", "blake3", "chrono", "datafusion-common", @@ -3955,7 +3995,7 @@ dependencies = [ "hickory-resolver", "http 1.5.0", "http-body-util", - "hyper 1.11.0", + "hyper 1.11.1", "hyper-rustls 0.27.9", "hyper-util", "ipnet", @@ -4158,7 +4198,7 @@ dependencies = [ "deno_error 0.6.1", "deno_tls", "http-body-util", - "hyper 1.11.0", + "hyper 1.11.1", "hyper-rustls 0.27.9", "hyper-util", "log", @@ -4457,10 +4497,21 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "const-oid", - "crypto-common", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "dirs" version = "4.0.0" @@ -5032,13 +5083,13 @@ dependencies = [ [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", "libz-sys", - "miniz_oxide", + "miniz_oxide 0.9.1", "zlib-rs", ] @@ -6111,7 +6162,7 @@ dependencies = [ "futures", "http 1.5.0", "http-body-util", - "hyper 1.11.0", + "hyper 1.11.1", "hyper-rustls 0.26.0", "hyper-tls", "hyper-tungstenite", @@ -6135,6 +6186,15 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "0.14.32" @@ -6161,9 +6221,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -6191,7 +6251,7 @@ dependencies = [ "futures-util", "headers", "http 1.5.0", - "hyper 1.11.0", + "hyper 1.11.1", "hyper-rustls 0.27.9", "hyper-util", "pin-project-lite", @@ -6207,7 +6267,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" dependencies = [ "hex", - "hyper 1.11.0", + "hyper 1.11.1", "hyper-util", "pin-project-lite", "tokio", @@ -6238,7 +6298,7 @@ checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c" dependencies = [ "futures-util", "http 1.5.0", - "hyper 1.11.0", + "hyper 1.11.1", "hyper-util", "log", "rustls 0.22.4", @@ -6256,7 +6316,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http 1.5.0", - "hyper 1.11.0", + "hyper 1.11.1", "hyper-util", "log", "rustls 0.23.35", @@ -6273,7 +6333,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper 1.11.0", + "hyper 1.11.1", "hyper-util", "pin-project-lite", "tokio", @@ -6288,7 +6348,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.11.0", + "hyper 1.11.1", "hyper-util", "native-tls", "tokio", @@ -6303,7 +6363,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a343d17fe7885302ed7252767dc7bb83609a874b6ff581142241ec4b73957ad" dependencies = [ "http-body-util", - "hyper 1.11.0", + "hyper 1.11.1", "hyper-util", "pin-project-lite", "tokio", @@ -6323,7 +6383,7 @@ dependencies = [ "futures-util", "http 1.5.0", "http-body 1.1.0", - "hyper 1.11.0", + "hyper 1.11.1", "ipnet", "libc", "percent-encoding", @@ -6344,7 +6404,7 @@ checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" dependencies = [ "hex", "http-body-util", - "hyper 1.11.0", + "hyper 1.11.1", "hyper-util", "pin-project-lite", "tokio", @@ -6948,7 +7008,7 @@ dependencies = [ "http 1.5.0", "http-body 1.1.0", "http-body-util", - "hyper 1.11.0", + "hyper 1.11.1", "hyper-http-proxy", "hyper-rustls 0.27.9", "hyper-timeout", @@ -7196,9 +7256,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" dependencies = [ "bitflags 2.13.1", "libc", @@ -7330,9 +7390,9 @@ dependencies = [ [[package]] name = "lru" -version = "0.18.2" +version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c" dependencies = [ "hashbrown 0.17.1", ] @@ -7703,6 +7763,15 @@ name = "miniz_oxide" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ "adler2", "simd-adler32", @@ -7816,7 +7885,7 @@ dependencies = [ "futures-sink", "futures-util", "keyed_priority_queue", - "lru 0.18.2", + "lru 0.18.3", "mysql_common", "native-tls", "pem 3.0.6", @@ -8321,7 +8390,7 @@ dependencies = [ "http-body-util", "httparse", "humantime", - "hyper 1.11.0", + "hyper 1.11.1", "itertools 0.14.0", "md-5 0.10.6", "parking_lot", @@ -8780,9 +8849,9 @@ dependencies = [ [[package]] name = "owo-colors" -version = "4.3.0" +version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" [[package]] name = "p12-keystore" @@ -8920,13 +8989,12 @@ dependencies = [ [[package]] name = "password-hash" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", + "getrandom 0.4.3", + "phc", ] [[package]] @@ -9070,6 +9138,17 @@ dependencies = [ "phf 0.11.3", ] +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", +] + [[package]] name = "phf" version = "0.11.3" @@ -9442,6 +9521,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn 3.0.4", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -10204,7 +10293,7 @@ dependencies = [ "http 1.5.0", "http-body 1.1.0", "http-body-util", - "hyper 1.11.0", + "hyper 1.11.1", "hyper-rustls 0.27.9", "hyper-tls", "hyper-util", @@ -10252,7 +10341,7 @@ dependencies = [ "http 1.5.0", "http-body 1.1.0", "http-body-util", - "hyper 1.11.0", + "hyper 1.11.1", "hyper-rustls 0.27.9", "hyper-util", "js-sys", @@ -10308,7 +10397,7 @@ dependencies = [ "futures", "getrandom 0.2.17", "http 1.5.0", - "hyper 1.11.0", + "hyper 1.11.1", "reqwest 0.13.4", "reqwest-middleware", "retry-policies", @@ -13516,7 +13605,7 @@ dependencies = [ "http 1.5.0", "http-body 1.1.0", "http-body-util", - "hyper 1.11.0", + "hyper 1.11.1", "hyper-timeout", "hyper-util", "percent-encoding", @@ -13548,7 +13637,7 @@ dependencies = [ "http 1.5.0", "http-body 1.1.0", "http-body-util", - "hyper 1.11.0", + "hyper 1.11.1", "hyper-timeout", "hyper-util", "percent-encoding", @@ -13901,9 +13990,9 @@ dependencies = [ [[package]] name = "twox-hash" -version = "2.1.3" +version = "2.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" [[package]] name = "typed-path" @@ -14140,7 +14229,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -14251,9 +14340,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.25.0" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -14272,7 +14361,7 @@ dependencies = [ "fslock", "gzip-header", "home", - "miniz_oxide", + "miniz_oxide 0.8.9", "paste", "which 6.0.3", ] @@ -14663,7 +14752,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-nats", @@ -14748,7 +14837,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.796.0" +version = "1.799.0" dependencies = [ "async-stream", "async-trait", @@ -14781,7 +14870,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.796.0" +version = "1.799.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14794,7 +14883,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "argon2", @@ -14824,7 +14913,7 @@ dependencies = [ "hex", "hmac", "http 1.5.0", - "hyper 1.11.0", + "hyper 1.11.1", "indexmap 2.14.0", "itertools 0.14.0", "jsonwebtoken 8.3.0", @@ -14934,12 +15023,12 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.796.0" +version = "1.799.0" dependencies = [ "axum 0.8.9", "chrono", "http 1.5.0", - "hyper 1.11.0", + "hyper 1.11.1", "lazy_static", "quick_cache", "serde", @@ -14957,7 +15046,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.796.0" +version = "1.799.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14974,7 +15063,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15000,7 +15089,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.796.0" +version = "1.799.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15010,7 +15099,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.796.0" +version = "1.799.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15027,7 +15116,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.796.0" +version = "1.799.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15049,7 +15138,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15072,7 +15161,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.796.0" +version = "1.799.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15088,11 +15177,11 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.796.0" +version = "1.799.0" dependencies = [ "axum 0.8.9", "chrono", - "hyper 1.11.0", + "hyper 1.11.1", "serde", "serde_json", "sql-builder", @@ -15110,7 +15199,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.796.0" +version = "1.799.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15131,7 +15220,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.796.0" +version = "1.799.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15145,7 +15234,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-nats", @@ -15180,14 +15269,14 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "axum 0.8.9", "base64 0.22.1", "chrono", "http 1.5.0", - "hyper 1.11.0", + "hyper 1.11.1", "lazy_static", "serde", "serde_json", @@ -15205,7 +15294,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.796.0" +version = "1.799.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15233,7 +15322,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15255,7 +15344,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.796.0" +version = "1.799.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15275,13 +15364,13 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.796.0" +version = "1.799.0" dependencies = [ "axum 0.8.9", "chrono", "futures", "http 1.5.0", - "hyper 1.11.0", + "hyper 1.11.1", "itertools 0.14.0", "lazy_static", "prometheus", @@ -15313,7 +15402,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15341,7 +15430,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.796.0" +version = "1.799.0" dependencies = [ "lazy_static", "serde", @@ -15353,13 +15442,13 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.796.0" +version = "1.799.0" dependencies = [ "argon2", "axum 0.8.9", "chrono", "http 1.5.0", - "hyper 1.11.0", + "hyper 1.11.1", "lazy_static", "serde", "serde_json", @@ -15377,7 +15466,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.796.0" +version = "1.799.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15391,14 +15480,14 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.796.0" +version = "1.799.0" dependencies = [ "axum 0.8.9", "chrono", "futures", "hex", "http 1.5.0", - "hyper 1.11.0", + "hyper 1.11.1", "lazy_static", "magic-crypt", "regex", @@ -15426,7 +15515,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.796.0" +version = "1.799.0" dependencies = [ "chrono", "lazy_static", @@ -15440,7 +15529,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15459,7 +15548,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.796.0" +version = "1.799.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15496,7 +15585,7 @@ dependencies = [ "globset", "hex", "hmac", - "hyper 1.11.0", + "hyper 1.11.1", "indexmap 2.14.0", "itertools 0.14.0", "jsonwebtoken 8.3.0", @@ -15563,7 +15652,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.796.0" +version = "1.799.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -15582,7 +15671,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.796.0" +version = "1.799.0" dependencies = [ "regex", "serde", @@ -15597,7 +15686,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15621,7 +15710,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "futures", @@ -15638,7 +15727,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.796.0" +version = "1.799.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15654,7 +15743,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-trait", @@ -15675,7 +15764,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-trait", @@ -15706,7 +15795,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "arc-swap", @@ -15731,7 +15820,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-stream", @@ -15765,7 +15854,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "futures", @@ -15783,7 +15872,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.796.0" +version = "1.799.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15792,7 +15881,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "lazy_static", @@ -15804,7 +15893,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "serde_json", @@ -15816,7 +15905,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "gosyn", @@ -15828,7 +15917,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "lazy_static", @@ -15840,7 +15929,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "serde_json", @@ -15852,7 +15941,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "nu-parser", @@ -15863,7 +15952,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15874,7 +15963,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15886,7 +15975,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15897,7 +15986,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-recursion", @@ -15919,7 +16008,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "serde_json", @@ -15931,7 +16020,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "lazy_static", @@ -15945,7 +16034,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15962,7 +16051,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "lazy_static", @@ -15975,7 +16064,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "serde", @@ -15987,7 +16076,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "lazy_static", @@ -16005,7 +16094,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16021,7 +16110,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16037,7 +16126,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "lazy_static", @@ -16051,7 +16140,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-recursion", @@ -16090,7 +16179,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "const_format", @@ -16130,7 +16219,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.796.0" +version = "1.799.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16141,7 +16230,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-recursion", @@ -16152,7 +16241,7 @@ dependencies = [ "futures", "hex", "http 1.5.0", - "hyper 1.11.0", + "hyper 1.11.1", "lazy_static", "magic-crypt", "quick_cache", @@ -16176,7 +16265,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-trait", @@ -16200,14 +16289,14 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-trait", "axum 0.8.9", "chrono", "http 1.5.0", - "hyper 1.11.0", + "hyper 1.11.1", "itertools 0.14.0", "lazy_static", "rand 0.9.0", @@ -16233,7 +16322,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-trait", @@ -16260,7 +16349,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-trait", @@ -16293,7 +16382,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-trait", @@ -16313,7 +16402,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-trait", @@ -16347,7 +16436,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-trait", @@ -16359,7 +16448,7 @@ dependencies = [ "hex", "hmac", "http 1.5.0", - "hyper 1.11.0", + "hyper 1.11.1", "itertools 0.14.0", "lazy_static", "matchit 0.7.3", @@ -16383,7 +16472,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-trait", @@ -16406,7 +16495,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-trait", @@ -16430,7 +16519,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-nats", @@ -16454,7 +16543,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-trait", @@ -16489,7 +16578,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-trait", @@ -16517,7 +16606,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-trait", @@ -16542,7 +16631,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "bitflags 2.13.1", @@ -16561,7 +16650,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-once-cell", @@ -16678,7 +16767,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.796.0" +version = "1.799.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 50a5c34b34..a05bd109ae 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.796.0" +version = "1.799.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.796.0" +version = "1.799.0" authors = ["Ruben Fiszel "] edition = "2021" @@ -100,6 +100,10 @@ path = "./src/main.rs" opt-level = 0 incremental = true split-debuginfo = "unpacked" +# Type and variable DWARF is the single largest thing in target/ and nothing in the dev loop +# reads it; backtraces only need the line tables, which this keeps. Raise to `true` when you +# actually need to inspect variables in gdb/lldb. +debug = "line-tables-only" [profile.dev.package."*"] debug = false diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 8f96f52924..a1d9c874fc 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -d6aef91c0f7ba556befbf4addeb7674d4a9dd819 \ No newline at end of file +9ff97cd818e85940fec282c92161e98c1b8583e2 diff --git a/backend/migrations/20260826202939_queue_suspended_resume_at_index.down.sql b/backend/migrations/20260826202939_queue_suspended_resume_at_index.down.sql new file mode 100644 index 0000000000..fe727f6761 --- /dev/null +++ b/backend/migrations/20260826202939_queue_suspended_resume_at_index.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS queue_suspended_v2; diff --git a/backend/migrations/20260826202939_queue_suspended_resume_at_index.up.sql b/backend/migrations/20260826202939_queue_suspended_resume_at_index.up.sql new file mode 100644 index 0000000000..6d91a64d5f --- /dev/null +++ b/backend/migrations/20260826202939_queue_suspended_resume_at_index.up.sql @@ -0,0 +1,20 @@ +-- Serves the suspended-job pull in windmill-common/src/worker.rs, whose resume test is the +-- indexed CASE expression. Two things about the shape are load-bearing: +-- * (priority DESC NULLS LAST, created_at) leads, so the scan yields that query's ORDER BY +-- and stops at the first match rather than sorting. +-- * the index is dropped before it is built rather than relying on IF NOT EXISTS. The +-- OVERRIDDEN_MIGRATIONS rewrite in windmill-api/src/db.rs runs these CONCURRENTLY, and an +-- interrupted concurrent build leaves the index present but invalid, which IF NOT EXISTS +-- would then skip rebuilding. Retiring the index this replaces is left to the migration +-- that follows, so this one can only ever be replayed while that index is still there to +-- cover the rebuild. +DROP INDEX IF EXISTS queue_suspended_v2; + +CREATE INDEX IF NOT EXISTS queue_suspended_v2 + ON v2_job_queue ( + priority DESC NULLS LAST, + created_at, + (CASE WHEN suspend <= 0 THEN '-infinity'::timestamptz ELSE suspend_until END), + tag + ) + WHERE suspend_until IS NOT NULL; diff --git a/backend/migrations/20260826214706_queue_suspended_drop_legacy_index.down.sql b/backend/migrations/20260826214706_queue_suspended_drop_legacy_index.down.sql new file mode 100644 index 0000000000..011105927a --- /dev/null +++ b/backend/migrations/20260826214706_queue_suspended_drop_legacy_index.down.sql @@ -0,0 +1,3 @@ +CREATE INDEX IF NOT EXISTS queue_suspended + ON v2_job_queue (priority DESC NULLS LAST, created_at, suspend_until, suspend, tag) + WHERE suspend_until IS NOT NULL; diff --git a/backend/migrations/20260826214706_queue_suspended_drop_legacy_index.up.sql b/backend/migrations/20260826214706_queue_suspended_drop_legacy_index.up.sql new file mode 100644 index 0000000000..b8851b99e0 --- /dev/null +++ b/backend/migrations/20260826214706_queue_suspended_drop_legacy_index.up.sql @@ -0,0 +1,6 @@ +-- Retires the index queue_suspended_v2 replaces. Separate from the migration that builds it +-- so that one is only ever replayed while this index still exists: sqlx records a migration +-- only after all its statements run, so a process that dies before the record is written +-- replays the build, and its leading DROP would otherwise be destroying the sole usable +-- index rather than an interrupted build. +DROP INDEX IF EXISTS queue_suspended; diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index cec49861cd..cb874eb2c7 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -16,9 +16,9 @@ "scopes": ["repository"] }, "slack": { - "auth_url": "https://slack.com/oauth/authorize", - "token_url": "https://slack.com/api/oauth.access", - "scopes": ["chat:write:user", "users:read", "users:read.email"] + "auth_url": "https://slack.com/oauth/v2/authorize", + "token_url": "https://slack.com/api/oauth.v2.access", + "scopes": ["chat:write", "chat:write.public", "channels:join", "files:write"] }, "supabase_wizard": { "auth_url": "https://api.supabase.com/v1/oauth/authorize", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 2f2fafedf4..47d807dd0d 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.796.0" +version = "1.799.0" dependencies = [ "aho-corasick", "anyhow", @@ -6274,7 +6274,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.796.0" +version = "1.799.0" dependencies = [ "proc-macro2", "quote", @@ -6286,7 +6286,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.796.0" +version = "1.799.0" dependencies = [ "convert_case", "serde", @@ -6295,7 +6295,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "lazy_static", @@ -6307,7 +6307,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "serde_json", @@ -6319,7 +6319,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "gosyn", @@ -6331,7 +6331,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "lazy_static", @@ -6343,7 +6343,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "serde_json", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "nu-parser", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6377,7 +6377,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6400,7 +6400,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "async-recursion", @@ -6422,7 +6422,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "serde_json", @@ -6434,7 +6434,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "lazy_static", @@ -6448,7 +6448,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "convert_case", @@ -6465,7 +6465,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "lazy_static", @@ -6478,7 +6478,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "serde", @@ -6490,7 +6490,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "lazy_static", @@ -6508,7 +6508,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6524,7 +6524,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6540,7 +6540,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "lazy_static", @@ -6586,7 +6586,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.796.0" +version = "1.799.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 826e279c39..c4a544e545 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.796.0" +version = "1.799.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/src/main.rs b/backend/src/main.rs index 10d1434f98..69f28e11ee 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -380,6 +380,7 @@ async fn cache_hub_scripts(file_path: Option) -> anyhow::Result<()> { "", &mut None, &None, + None, ) .await { @@ -1434,21 +1435,30 @@ Windmill Community Edition {GIT_VERSION} // Poll for new events from notify_event table match windmill_common::notify_events::poll_notify_events(&db, last_event_id).await { Ok(events) => { + let mut http_trigger_change_handled = false; for event in events { if !*windmill_common::QUIET_LOGS { tracing::info!("Processing notify event: channel={}, payload={}", event.channel, event.payload); } - process_notify_event( - &event.channel, - &event.payload, - &db, - &conn, - &tx, - server_mode, - worker_mode, - #[cfg(feature = "parquet")] - disable_s3_store, - ).await; + let is_http_trigger_change = event.channel == "notify_http_trigger_change"; + // Every changed http_trigger row emits its own event and each one forces + // a full router rebuild, but the batch's first successful rebuild already + // read every row the batch committed. A failed rebuild leaves the flag + // clear so the next event in the batch retries it. + if !(is_http_trigger_change && http_trigger_change_handled) { + let handled = process_notify_event( + &event.channel, + &event.payload, + &db, + &conn, + &tx, + server_mode, + worker_mode, + #[cfg(feature = "parquet")] + disable_s3_store, + ).await; + http_trigger_change_handled |= is_http_trigger_change && handled; + } last_event_id = last_event_id.max(event.id); } } @@ -1670,6 +1680,9 @@ Windmill Community Edition {GIT_VERSION} /// Process a single notify event from the polling-based event system. /// This replaces the old PgListener notification handling. +/// +/// Returns `false` when the event still needs handling. Only the HTTP router rebuild reports +/// that, because the poll loop coalesces those events and must not swallow the retry. #[allow(unused_variables)] async fn process_notify_event( channel: &str, @@ -1680,7 +1693,7 @@ async fn process_notify_event( server_mode: bool, worker_mode: bool, #[cfg(feature = "parquet")] disable_s3_store: bool, -) { +) -> bool { match channel { "notify_config_change" => { if payload == "server" && server_mode { @@ -1825,17 +1838,14 @@ async fn process_notify_event( #[cfg(feature = "http_trigger")] "notify_http_trigger_change" => { tracing::info!("HTTP trigger change detected: {}", payload); - match windmill_api::triggers::http::refresh_routers(db).await { - Ok((true, _)) => { + match windmill_api::triggers::http::refresh_routers(db, true).await { + Ok(_) => { tracing::info!("Refreshed HTTP routers (trigger change)"); } - Ok((false, _)) => { - tracing::warn!( - "Should have refreshed HTTP routers (trigger change) but did not" - ); - } Err(err) => { tracing::error!("Error refreshing HTTP routers (trigger change): {err:#}"); + windmill_api::triggers::http::invalidate_routers(); + return false; } }; } @@ -2059,7 +2069,7 @@ async fn process_notify_event( 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 { + match windmill_api::triggers::http::refresh_routers(db, false).await { Ok((true, _)) => { tracing::info!( "Refreshed HTTP routers (http workspaced route setting change)" @@ -2179,6 +2189,7 @@ async fn process_notify_event( tracing::warn!("Unknown notification channel: {}", channel); } } + true } fn display_config(envs: &[&str]) { diff --git a/backend/tests/sign_s3_objects_authz.rs b/backend/tests/sign_s3_objects_authz.rs index a2baf9d449..511ef39ab5 100644 --- a/backend/tests/sign_s3_objects_authz.rs +++ b/backend/tests/sign_s3_objects_authz.rs @@ -17,7 +17,11 @@ //! so two test functions sharing the one fixture workspace serve each other's stale — by then //! deleted — filesystem root. //! -//! Advanced S3 permissions are an enterprise feature, so this test requires the +//! A second test pins the `expiry_secs` bounds: the signature's `exp` follows the caller's +//! request, defaults to 12h, and is clamped to [60s, 7d]. It only mints signatures and never +//! fetches through the proxy, so it never populates or reads that cache. +//! +//! Advanced S3 permissions are an enterprise feature, so these tests require the //! `enterprise` + `private` + `parquet` features. #![cfg(all(feature = "enterprise", feature = "private", feature = "parquet"))] @@ -175,3 +179,88 @@ async fn test_sign_s3_objects_enforces_read_authz(db: Pool) -> anyhow: Ok(()) } + +/// `exp` is signed into the HMAC message, so the only way a caller can influence +/// it is through `expiry_secs` — pin the default and both clamp bounds. +#[sqlx::test(fixtures("base"))] +async fn test_sign_s3_objects_expiry_secs(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace"); + + let storage_dir = tempfile::tempdir()?; + configure_lfs(&db, &storage_dir.path().to_string_lossy()).await?; + + async fn signed_exp(base: &str, body: serde_json::Value) -> anyhow::Result { + let resp = authed( + client().post(format!("{base}/apps/sign_s3_objects")), + "SECRET_TOKEN", + ) + .json(&body) + .send() + .await?; + let status = resp.status(); + let signed: serde_json::Value = resp.json().await?; + assert!(status.is_success(), "sign must succeed: {status} {signed}"); + let presigned = signed[0]["presigned"] + .as_str() + .expect("sign must return a presigned string"); + let exp = presigned + .split('&') + .find_map(|kv| kv.strip_prefix("exp=")) + .expect("presigned string must carry exp"); + Ok(exp.parse::()?) + } + + let key = json!([{ "s3": "allowed/file.txt" }]); + // The handler stamps `now` itself, so assert on a window rather than an exact value. + // Keep the window well under the 60s lower bound, or an unclamped 1s would pass. + let ttl_around = |exp: i64| exp - chrono::Utc::now().timestamp(); + let tolerance = 30; + + let default_ttl = ttl_around(signed_exp(&base, json!({ "s3_objects": key.clone() })).await?); + assert!( + (43200 - tolerance..=43200).contains(&default_ttl), + "omitting expiry_secs must keep the 12h default, got {default_ttl}s" + ); + + let honored = ttl_around( + signed_exp( + &base, + json!({ "s3_objects": key.clone(), "expiry_secs": 300 }), + ) + .await?, + ); + assert!( + (300 - tolerance..=300).contains(&honored), + "expiry_secs must be honored verbatim inside the bounds, got {honored}s" + ); + + let clamped_low = ttl_around( + signed_exp( + &base, + json!({ "s3_objects": key.clone(), "expiry_secs": 1 }), + ) + .await?, + ); + assert!( + (60 - tolerance..=60).contains(&clamped_low), + "expiry_secs below 60s must clamp up to 60s, got {clamped_low}s" + ); + + let clamped_high = ttl_around( + signed_exp( + &base, + json!({ "s3_objects": key.clone(), "expiry_secs": 99_999_999 }), + ) + .await?, + ); + assert!( + (604800 - tolerance..=604800).contains(&clamped_high), + "expiry_secs above 7d must clamp down to 7d, got {clamped_high}s" + ); + + Ok(()) +} diff --git a/backend/tests/suspended_pull_index.rs b/backend/tests/suspended_pull_index.rs new file mode 100644 index 0000000000..4b31f4d45e --- /dev/null +++ b/backend/tests/suspended_pull_index.rs @@ -0,0 +1,77 @@ +//! Pins the plan of the suspended-job pull. Its resume test degrades silently: once the +//! query expression and `queue_suspended_v2` stop matching, Postgres still returns the right +//! job, just by falling back to a heap filter and fetching one tuple per suspended row on +//! every worker poll. No functional test can see that, so assert on the plan instead. + +use serde_json::Value; +use sqlx::{Pool, Postgres}; +use windmill_common::worker::make_suspended_pull_query; + +/// Depth-first walk of an `EXPLAIN (FORMAT JSON)` plan tree. +fn nodes(plan: &Value, out: &mut Vec) { + out.push(plan.clone()); + for child in plan["Plans"].as_array().unwrap_or(&vec![]) { + nodes(child, out); + } +} + +#[sqlx::test(fixtures("base"))] +async fn suspended_pull_tests_resume_time_inside_the_index( + db: Pool, +) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, created_at, scheduled_for, running, suspend, suspend_until, tag) + SELECT gen_random_uuid(), 'test-workspace', now() - make_interval(secs => i), + now(), true, 1 + (i % 3), now() + interval '7 day', 'flow' + FROM generate_series(1, 2000) i", + ) + .execute(&db) + .await?; + sqlx::query("ANALYZE v2_job_queue").execute(&db).await?; + + // Both plans are cheap on a 2000-row table, and which one wins there says nothing + // about a queue with a large suspended backlog. Force the index path, which is the + // one production takes, and assert on how it evaluates the resume test. + let mut conn = db.acquire().await?; + sqlx::query("SET enable_seqscan = off") + .execute(&mut *conn) + .await?; + let version: String = sqlx::query_scalar("SELECT version()") + .fetch_one(&mut *conn) + .await?; + // FORMAT JSON rather than the default: `Index Cond` and `Filter` are separate keys on the + // node, so this does not ride on EXPLAIN's line layout staying put across a major bump. + let explained: Value = sqlx::query_scalar(&format!( + "EXPLAIN (FORMAT JSON) {}", + make_suspended_pull_query(&["flow".to_string()]) + )) + .bind("test-worker") + .fetch_one(&mut *conn) + .await?; + + let mut all = vec![]; + nodes(&explained[0]["Plan"], &mut all); + let pretty = serde_json::to_string_pretty(&explained)?; + let scan = all + .iter() + .find(|n| n["Index Name"] == "queue_suspended_v2") + .unwrap_or_else(|| { + panic!("suspended pull did not scan queue_suspended_v2 on {version}:\n{pretty}") + }); + // Only `Index Cond` is checked against the index tuple, so that is where the resume test + // has to land — as a `Filter` it would cost a heap fetch per suspended row. The residual + // `suspend_until IS NOT NULL` filter is not that: it is always true for rows the partial + // index holds, and only ever runs on the row LIMIT 1 already fetched. + let cond = scan["Index Cond"].as_str().unwrap_or_else(|| { + panic!("no Index Cond on the suspended pull scan on {version}:\n{pretty}") + }); + assert!( + cond.contains("CASE WHEN"), + "resume test is not an index condition on {version}:\n{pretty}" + ); + assert!( + !scan["Filter"].as_str().unwrap_or("").contains("CASE WHEN"), + "resume test fell back to a heap filter on {version}:\n{pretty}" + ); + Ok(()) +} diff --git a/backend/tests/wm_token_confinement.rs b/backend/tests/wm_token_confinement.rs index 5eb885fba9..2d3f1373fe 100644 --- a/backend/tests/wm_token_confinement.rs +++ b/backend/tests/wm_token_confinement.rs @@ -318,6 +318,33 @@ async fn test_wm_token_is_confined_to_its_workspace(db: Pool) -> anyho resp.text().await? ); } + // ...and the one `settings/global` key on the allowlist, which the CLI reads before + // creating a user on a git-sync push. `ws_base_url` is the control: the handler leaves + // it as ungated as `automate_username_creation`, so only the allowlist stops it. + let resp = authed( + client().get(format!("{api}/settings/global/automate_username_creation")), + &user_wm, + ) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "WM_TOKEN must still read automate_username_creation: {}", + resp.text().await? + ); + let resp = authed( + client().get(format!("{api}/settings/global/ws_base_url")), + &user_wm, + ) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "WM_TOKEN must not read any other global setting: {}", + resp.text().await? + ); let resp = authed(client().post(format!("{api}/schedules/preview")), &user_wm) .json(&json!({ "schedule": "0 0 12 * * *", "timezone": "UTC" })) .send() diff --git a/backend/tests/workspace_export.rs b/backend/tests/workspace_export.rs index 8568b8ff24..5894ca8394 100644 --- a/backend/tests/workspace_export.rs +++ b/backend/tests/workspace_export.rs @@ -1,6 +1,6 @@ use sqlx::postgres::Postgres; use sqlx::Pool; -use windmill_test_utils::{initialize_tracing, ApiServer}; +use windmill_test_utils::{initialize_tracing, set_jwt_secret, ApiServer}; /// Integration test: exercises every explicit-column query in `tarball_workspace`. /// @@ -287,3 +287,77 @@ async fn test_tarball_export_gates_values_on_item_scopes(db: Pool) -> Ok(()) } + +/// `settings.json` carries the admin-managed integration config that `get_settings` +/// is admin-only for (the webhook URL, ai_config, git_sync, handler extra_args), so +/// `include_settings` takes the same admin check as `get_settings` rather than +/// riding on the route's `workspaces:read`. Git sync exports settings through the +/// same route, so the gate must still admit its system identity. +#[sqlx::test(fixtures("base"))] +async fn test_tarball_export_settings_are_admin_only(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + let server = ApiServer::start(db.clone()).await?; + let base_url = format!("http://localhost:{}", server.addr.port()); + + sqlx::query( + r#"UPDATE workspace_settings + SET webhook = 'https://hook.example/?token=WEBHOOK_SECRET', + ai_config = '{"providers":{"openai":{"api_key":"AI_CONFIG_SECRET"}}}'::jsonb + WHERE workspace_id = 'test-workspace'"#, + ) + .execute(&db) + .await?; + + let export = async |token: &str| -> anyhow::Result<(u16, String)> { + let resp = reqwest::Client::new() + .get(format!( + "{base_url}/api/w/test-workspace/workspaces/tarball?include_settings=true&settings_version=v2" + )) + .bearer_auth(token) + .send() + .await?; + let status = resp.status().as_u16(); + // Lossy: a successful export is a tar, not UTF-8. Only the values matter here. + Ok(( + status, + String::from_utf8_lossy(&resp.bytes().await?).into_owned(), + )) + }; + + // SECRET_TOKEN_2 belongs to test-user-2, a non-admin member of test-workspace. + let (status, body) = export("SECRET_TOKEN_2").await?; + assert_eq!(status, 403, "non-admin exported settings: {body}"); + + let (status, body) = export("SECRET_TOKEN").await?; + assert_eq!(status, 200, "admin denied settings: {body}"); + assert!( + body.contains("WEBHOOK_SECRET") && body.contains("AI_CONFIG_SECRET"), + "admin got no settings" + ); + + // Git sync pushes the workspace to the repo by exporting it under + // `superadmin_sync@windmill.dev`, which belongs to no workspace: the job token + // it runs with is the export's only admin claim. + let sync_email = windmill_common::users::SUPERADMIN_SYNC_EMAIL; + let sync_token = windmill_common::auth::create_token_for_owner( + &db, + "test-workspace", + sync_email, + "git-sync", + 300, + sync_email, + &uuid::Uuid::new_v4(), + None, + None, + ) + .await?; + let (status, body) = export(&sync_token).await?; + assert_eq!(status, 200, "git-sync identity denied settings: {body}"); + assert!( + body.contains("WEBHOOK_SECRET"), + "git-sync identity got no settings" + ); + + Ok(()) +} diff --git a/backend/windmill-ai/src/ai_providers.rs b/backend/windmill-ai/src/ai_providers.rs index bf16877f7b..a4dcbc4f35 100644 --- a/backend/windmill-ai/src/ai_providers.rs +++ b/backend/windmill-ai/src/ai_providers.rs @@ -24,6 +24,16 @@ lazy_static::lazy_static! { .ok() .map(|v| v == "true" || v == "1") .unwrap_or(false); + /// Drops the cache breakpoints from agent-step requests on every Anthropic platform, + /// not just the one that motivates it: a Google Cloud project can have explicit prompt + /// caching turned off (by request to Cloud support), and Vertex then rejects any request + /// carrying breakpoints. An instance that sets this to unblock such a project also gives + /// up caching on its direct-Anthropic and Foundry resources. + pub static ref DISABLE_ANTHROPIC_PROMPT_CACHING: bool = + std::env::var("DISABLE_ANTHROPIC_PROMPT_CACHING") + .ok() + .map(|v| v == "true" || v == "1") + .unwrap_or(false); } pub const OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; diff --git a/backend/windmill-ai/src/providers/anthropic.rs b/backend/windmill-ai/src/providers/anthropic.rs index f22f28de14..486b08b739 100644 --- a/backend/windmill-ai/src/providers/anthropic.rs +++ b/backend/windmill-ai/src/providers/anthropic.rs @@ -1,7 +1,7 @@ use super::{anthropic_model_rejects_sampling_params, REASONING_OFF_SENTINEL}; use crate::{ ai_google::parse_data_url, - ai_providers::{AIPlatform, AIProvider}, + ai_providers::{AIPlatform, AIProvider, DISABLE_ANTHROPIC_PROMPT_CACHING}, image_handler::prepare_messages_for_api, proxy::{ add_user_to_body, common_outbound_headers, credential_header, ProxyBuildArgs, ProxyRequest, @@ -632,15 +632,13 @@ impl AnthropicQueryBuilder { } } + let caching = !*DISABLE_ANTHROPIC_PROMPT_CACHING; + let system = collect_system_prompt(&prepared_messages, args.system_prompt).map(|text| { vec![AnthropicSystemContent { r#type: "text".to_string(), text, - cache_control: if self.is_vertex() { - None - } else { - Some(CacheControl::ephemeral()) - }, + cache_control: caching.then(CacheControl::ephemeral), }] }); @@ -665,7 +663,7 @@ impl AnthropicQueryBuilder { let max_tokens = Some(args.max_tokens.unwrap_or(64000)); // Apply cache_control on the last custom tool - if !self.is_vertex() { + if caching { if let Some(ref mut tools_vec) = tools_option { if let Some(AnthropicTool::Custom(ref mut custom)) = tools_vec.last_mut() { custom.cache_control = Some(CacheControl::ephemeral()); @@ -674,7 +672,7 @@ impl AnthropicQueryBuilder { } // Apply cache_control on the last content block of the last message - if !self.is_vertex() { + if caching { if let Some(last_msg) = anthropic_messages.last_mut() { if let Some(last_block) = last_msg.content.last_mut() { match last_block { @@ -882,10 +880,15 @@ mod tests { } } - async fn build_text_body(messages: &[OpenAIMessage], system_prompt: Option<&str>) -> String { + async fn build_text_body_on( + platform: AIPlatform, + messages: &[OpenAIMessage], + system_prompt: Option<&str>, + tools: Option<&[ToolDef]>, + ) -> String { let args = BuildRequestArgs { messages, - tools: None, + tools, model: "claude-sonnet-4", temperature: None, reasoning_effort: None, @@ -899,12 +902,16 @@ mod tests { prompt_cache_key: None, }; - AnthropicQueryBuilder::new(AIProvider::Anthropic, AIPlatform::Standard) + AnthropicQueryBuilder::new(AIProvider::Anthropic, platform) .build_request(&args, &authed_client(), "test-workspace") .await .unwrap() } + async fn build_text_body(messages: &[OpenAIMessage], system_prompt: Option<&str>) -> String { + build_text_body_on(AIPlatform::Standard, messages, system_prompt, None).await + } + /// The worker prepends the system prompt as a system message *and* passes it as /// `system_prompt`; the request must still carry it exactly once. #[tokio::test] @@ -946,6 +953,35 @@ mod tests { assert!(request.get("system").is_none()); } + /// Vertex serves the same Messages API and honours `cache_control` breakpoints, so + /// its requests must carry the same three the standard platform gets. + #[tokio::test] + async fn sets_cache_breakpoints_on_every_platform() { + let messages = vec![message("system", SYSTEM_PROMPT), message("user", "hi")]; + let tools = vec![ToolDef { + r#type: "function".to_string(), + function: ToolDefFunction { + name: "get_weather".to_string(), + description: None, + parameters: RawValue::from_string("{}".to_string()).unwrap(), + }, + }]; + let ephemeral = serde_json::json!({ "type": "ephemeral" }); + + for platform in [AIPlatform::Standard, AIPlatform::GoogleVertexAi] { + let body = + build_text_body_on(platform, &messages, Some(SYSTEM_PROMPT), Some(&tools)).await; + let request: serde_json::Value = serde_json::from_str(&body).unwrap(); + + assert_eq!(request["system"][0]["cache_control"], ephemeral); + let sent_tools = request["tools"].as_array().unwrap(); + assert_eq!(sent_tools.last().unwrap()["cache_control"], ephemeral); + let sent = request["messages"].as_array().unwrap(); + let content = sent.last().unwrap()["content"].as_array().unwrap(); + assert_eq!(content.last().unwrap()["cache_control"], ephemeral); + } + } + fn has_header(headers: &[(String, String)], name: &str, value: &str) -> bool { headers .iter() diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index 7109a04425..d99d955de9 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -996,6 +996,13 @@ fn scope_grants_access( /// the caller's own row; `email` and `allowed_domain_auto_invite` are derived from the /// token itself and touch no table. /// +/// `settings/global/automate_username_creation` is the one instance setting on the list. +/// `get_global_setting` exempts a handful of keys from its own super-admin gate, that one +/// among them, so the boolean is already readable by every authenticated user; it is here +/// because the CLI reads it before creating a user during a git-sync push, which runs as a +/// job. The other ungated keys have no such caller, so they stay confined — being ungated +/// earns a key nothing on its own. +/// /// Deliberately absent, as each crosses that line: `users/list_invites` (returns the /// workspace ids the identity was invited to), `users/tokens/list` (credential metadata /// of the borrowed identity), `users/exists/{email}` (an oracle over arbitrary @@ -1011,6 +1018,7 @@ fn is_global_read_open_to_job_token(route_path: &str) -> bool { | "/api/users/usage" | "/api/users/tutorial_progress" | "/api/workspaces/allowed_domain_auto_invite" + | "/api/settings/global/automate_username_creation" | "/api/docs/search" | "/api/docs/page" | "/api/integrations/hub/list" diff --git a/backend/windmill-api-debug/src/lib.rs b/backend/windmill-api-debug/src/lib.rs index 7b2f569821..ab89df988d 100644 --- a/backend/windmill-api-debug/src/lib.rs +++ b/backend/windmill-api-debug/src/lib.rs @@ -468,6 +468,9 @@ async fn sign_debug_request( // Parse the language let script_lang: ScriptLang = request.language.parse().unwrap_or(ScriptLang::Bun); + // Taken from the parsed language, not the request's string: the telemetry key vocabulary has + // to stay the closed set of languages rather than whatever a caller sent. + let lang_key = script_lang.as_str(); // Hash the code (we don't include full code in JWT to keep it small) let mut hasher = Sha256::new(); @@ -578,6 +581,8 @@ async fn sign_debug_request( tx.commit().await?; + windmill_common::feature_usage::log_feature_usage("debugger", "session", lang_key); + Ok(Json(SignedDebugPayload { token, code: request.code, diff --git a/backend/windmill-api-integration-tests/tests/jobs_authed.rs b/backend/windmill-api-integration-tests/tests/jobs_authed.rs index cd60275afc..c3da2f3fb9 100644 --- a/backend/windmill-api-integration-tests/tests/jobs_authed.rs +++ b/backend/windmill-api-integration-tests/tests/jobs_authed.rs @@ -286,13 +286,24 @@ async fn test_jobs_authed_reachability(db: Pool) -> anyhow::Result<()> "GET /jobs/result_by_id", ); + // Sent the way the generated client sends it. A handler whose `Path` tuple has drifted from + // the route is rejected by axum before it runs, which surfaces as a routing error rather + // than the handler's own answer, so reaching the handler is what this pins. let resp = authed(client().post(format!("{base}/restart/f/{fake}"))) + .json(&json!({ "step_id": "a" })) .send() .await?; - assert_route_reachable( - resp.status().as_u16(), - &resp.text().await?, - "POST /jobs/restart/f", + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_route_reachable(status, &body, "POST /jobs/restart/f"); + assert!( + !body.contains("path arguments"), + "POST /jobs/restart/f never reached its handler: {status} {body}", + ); + #[cfg(not(feature = "enterprise"))] + assert!( + body.contains("only available in enterprise version"), + "POST /jobs/restart/f must report the enterprise gate outside EE: {status} {body}", ); let resp = authed(client().post(format!("{base}/run/workflow_as_code/{fake}/main"))) diff --git a/backend/windmill-api-workspaces/src/datatable_migrations.rs b/backend/windmill-api-workspaces/src/datatable_migrations.rs index bf0b7dd213..bb4310d1bc 100644 --- a/backend/windmill-api-workspaces/src/datatable_migrations.rs +++ b/backend/windmill-api-workspaces/src/datatable_migrations.rs @@ -10,7 +10,10 @@ //! management, and the workspace-merge diff helper. Split out of `workspaces.rs` //! to keep that file focused on core workspace configuration. -use crate::workspaces::{pg_dump_database, ItemComparison}; +use crate::workspaces::{ + is_instance_datatable, pg_dump_database, strip_unreplayable_dump_lines, ItemComparison, + PgDumpOptions, +}; use axum::{ extract::{Extension, Path, Query}, @@ -1408,18 +1411,25 @@ async fn generate_initial_datatable_migration( let pg_db: PgDatabase = serde_json::from_value(db_resource) .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?; - // Snapshot the schema, excluding Windmill's own migration bookkeeping table. - let dump_file = pg_dump_database(&pg_db, true, &["_wm_migrations"]).await?; + // Snapshot the schema without `_wm_migrations`, Windmill's own bookkeeping table, and + // without what a replay elsewhere cannot run: the replaying user owns none of this + // database's objects, and the grants Windmill plants in an instance database (`ALTER + // DEFAULT PRIVILEGES FOR ROLE ...`) fail even replaying onto the same server. + let no_acl = is_instance_datatable(&db, &w_id, &datatable_name).await?; + let dump_file = pg_dump_database( + &pg_db, + PgDumpOptions { + schema_only: true, + exclude_tables: &["_wm_migrations"], + no_owner: true, + no_acl, + }, + ) + .await?; let raw_dump = tokio::fs::read_to_string(&dump_file.path) .await .map_err(|e| Error::internal_err(format!("Failed to read schema dump: {}", e)))?; - // pg_dump emits psql meta-commands (\restrict / \unrestrict) that aren't - // valid SQL; drop them so the migration body can run via a plain query. - let code_up: String = raw_dump - .lines() - .filter(|line| !line.trim_start().starts_with('\\')) - .collect::>() - .join("\n"); + let code_up = strip_unreplayable_dump_lines(&raw_dump); // Record the definition first, then mark it installed. If marking fails we // delete the definition, so a failure leaves no phantom "initial" (rather diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index e04e1c1ab9..c8c49416d8 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -117,6 +117,7 @@ pub fn workspaced_service() -> Router { get(get_secondary_storage_names), ) .route("/is_premium", get(is_premium)) + .route("/billable_seats", get(get_billable_seats)) .route("/edit_error_handler", post(edit_error_handler)) .route("/edit_success_handler", post(edit_success_handler)) .route( @@ -686,6 +687,48 @@ async fn is_premium( Ok(Json(premium)) } +#[derive(Serialize)] +struct BillableSeatsResponse { + /// Both omitted when the seats counted are another workspace's: a fork member need not be a + /// member of the billing root, so the root's headcount is not theirs to read. The total is, + /// since it is the divisor of the quota their own executions draw on. + #[serde(skip_serializing_if = "Option::is_none")] + developers: Option, + #[serde(skip_serializing_if = "Option::is_none")] + operators: Option, + seats: i64, +} + +async fn get_billable_seats( + _authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> JsonResult { + // Readable by any workspace member, like `is_premium`: this is what the sidebar usage meter + // divides by, and that meter is shown to non-admin developers too. + // + // On cloud a fork draws its plan, quota and bill from the root, so the seats its usage is + // measured against are the root's. Resolved here rather than by the caller: a fork member need + // not be a member of that root, and so cannot count its seats from the member list. Off cloud + // a fork is not billed through a root at all, so the workspace answers for itself. + #[cfg(feature = "cloud")] + let billing_w_id = if *CLOUD_HOSTED { + windmill_common::workspaces::get_billing_workspace_id(&db, &w_id).await? + } else { + w_id.clone() + }; + #[cfg(not(feature = "cloud"))] + let billing_w_id = w_id.clone(); + + let counted = windmill_common::workspaces::billable_seats(&db, &billing_w_id).await?; + let own = billing_w_id == w_id; + Ok(Json(BillableSeatsResponse { + developers: own.then_some(counted.developers), + operators: own.then_some(counted.operators), + seats: counted.seats, + })) +} + async fn exists_workspace( authed: ApiAuthed, Extension(user_db): Extension, @@ -1970,6 +2013,23 @@ async fn edit_large_file_storage_config( ))); } + if !windmill_common::workspaces::filesystem_storage_allowed() { + let named = std::iter::once(("primary storage", &lfs_config.large_file_storage)).chain( + lfs_config + .secondary_storage + .iter() + .map(|(name, storage)| (name.as_str(), storage)), + ); + for (name, storage) in named { + if matches!(storage, LargeFileStorage::FilesystemStorage(_)) { + return Err(Error::BadRequest(format!( + "{name}: {}", + windmill_common::workspaces::FILESYSTEM_STORAGE_DEV_ONLY_MSG + ))); + } + } + } + let serialized_lfs_config = serde_json::to_value::(lfs_config) .map_err(|err| Error::internal_err(err.to_string()))?; @@ -2594,6 +2654,66 @@ fn truncate_column_default(default: String) -> String { mod tests { use super::*; + /// The header of a pg_dump, followed by an object whose body also holds a `SET`. + const DUMP: &str = "--\n\ + -- PostgreSQL database dump\n\ + --\n\ + \n\ + \\restrict aBcD\n\ + \n\ + SET statement_timeout = 0;\n\ + SET transaction_timeout = 0;\n\ + SET client_encoding = 'UTF8';\n\ + SELECT pg_catalog.set_config('search_path', '', false);\n\ + \n\ + SET default_table_access_method = heap;\n\ + \n\ + CREATE FUNCTION public.f() RETURNS void LANGUAGE plpgsql AS $$\n\ + BEGIN\n\ + SET transaction_timeout = 0;\n\ + END;\n\ + $$;\n"; + + #[test] + fn replayable_dump_keeps_everything_but_meta_commands_and_session_timeouts() { + let replayable = strip_unreplayable_dump_lines(DUMP); + + assert!(!replayable.contains("\\restrict")); + assert!(!replayable.contains("SET statement_timeout")); + assert!(!replayable.contains("SET transaction_timeout = 0;\nSET client_encoding")); + assert!(replayable.contains("SET client_encoding = 'UTF8';")); + assert!(replayable.contains("SET default_table_access_method = heap;")); + // Past the preamble the dump is an object's own text: left exactly as it is. + assert!(replayable.contains("BEGIN\nSET transaction_timeout = 0;\nEND;")); + } + + #[tokio::test] + async fn dump_preamble_only_drops_settings_the_server_lacks() { + let dump_file = DumpFile::new().unwrap(); + tokio::fs::write(&dump_file.path, DUMP).await.unwrap(); + let supported = [ + "statement_timeout", + "client_encoding", + "default_table_access_method", + ] + .map(String::from) + .into_iter() + .collect(); + + comment_out_unsupported_settings(&dump_file, &supported) + .await + .unwrap(); + + let patched = tokio::fs::read_to_string(&dump_file.path).await.unwrap(); + // Rewriting the header must not shift the rest of the dump. + assert_eq!(patched.len(), DUMP.len()); + assert!(patched.contains("-- transaction_timeout = 0;")); + assert!(patched.contains("SET statement_timeout = 0;")); + assert!(patched.contains("SET default_table_access_method = heap;")); + // The `SET` inside the function body is past the preamble: never touched. + assert!(patched.contains("BEGIN\nSET transaction_timeout = 0;\nEND;")); + } + #[test] fn compact_column_type_truncates_multibyte_defaults_safely() { let default = "é".repeat(31); @@ -2696,6 +2816,35 @@ pub(crate) async fn resolve_pg_source_checked( .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e))) } +/// Whether the data table `name` is backed by the Windmill instance's own PostgreSQL +/// rather than a user resource. +pub(crate) async fn is_instance_datatable(db: &DB, w_id: &str, name: &str) -> Result { + let config = sqlx::query_scalar!( + "SELECT datatable->'datatables'->$2 FROM workspace_settings WHERE workspace_id = $1", + w_id, + name + ) + .fetch_optional(db) + .await? + .flatten(); + Ok(config + .and_then(|v| { + v.get("database") + .and_then(|d| d.get("resource_type")) + .and_then(|r| r.as_str()) + .map(|s| s == "instance") + }) + .unwrap_or(false)) +} + +/// Same, for the `datatable://` / `$res:` form the import endpoints take. +async fn is_instance_datatable_source(db: &DB, w_id: &str, source: &str) -> Result { + match source.strip_prefix("datatable://") { + Some(name) => is_instance_datatable(db, w_id, name).await, + None => Ok(false), + } +} + /// A temporary file for pg_dump output that is automatically deleted when dropped. pub(crate) struct DumpFile { pub(crate) path: std::path::PathBuf, @@ -2743,12 +2892,21 @@ impl Drop for DumpFile { } } +#[derive(Default)] +pub(crate) struct PgDumpOptions<'a> { + pub(crate) schema_only: bool, + pub(crate) exclude_tables: &'a [&'a str], + /// Leave out `ALTER ... OWNER TO`. + pub(crate) no_owner: bool, + /// Leave out `GRANT`, `REVOKE` and `ALTER DEFAULT PRIVILEGES`. + pub(crate) no_acl: bool, +} + /// Run pg_dump against a PgDatabase, writing output to a temp file on disk. /// Returns a DumpFile handle; the file is deleted when the handle is dropped. pub(crate) async fn pg_dump_database( pg_db: &PgDatabase, - schema_only: bool, - exclude_tables: &[&str], + opts: PgDumpOptions<'_>, ) -> Result { let dump_file = DumpFile::new()?; @@ -2759,10 +2917,16 @@ pub(crate) async fn pg_dump_database( let mut cmd = tokio::process::Command::new("pg_dump"); cmd.arg("--format=plain").arg("--file").arg(&dump_file.path); - if schema_only { + if opts.schema_only { cmd.arg("--schema-only"); } - for table in exclude_tables { + if opts.no_owner { + cmd.arg("--no-owner"); + } + if opts.no_acl { + cmd.arg("--no-privileges"); + } + for table in opts.exclude_tables { cmd.arg(format!("--exclude-table={table}")); } cmd.arg("--host") @@ -2794,37 +2958,179 @@ pub(crate) async fn pg_dump_database( Ok(dump_file) } -/// Import a pg_dump file into a target database using psql. -async fn pg_import_dump(target_db: &PgDatabase, dump_file: &DumpFile) -> Result<()> { - let host = &target_db.host; - let port = target_db.port.unwrap_or(5432).to_string(); - let user = target_db.login_name(); - let dbname = &target_db.dbname; +/// Whether `line` still belongs to the preamble pg_dump emits before the first +/// dumped object: comments, blank lines, psql meta-commands and the session `SET`s. +fn is_dump_preamble_line(line: &[u8]) -> bool { + let line = line.trim_ascii_start(); + line.is_empty() + || line.starts_with(b"--") + || line.starts_with(b"\\") + || line.starts_with(b"SET ") + || line.starts_with(b"SELECT pg_catalog.set_config(") +} +/// The GUCs pg_dump's preamble sets only to keep the dumping session out of the way. +/// They are also the ones that come and go across versions (`transaction_timeout` is +/// PG 17+), so they are what a dump replayed on an older server trips over first. +const DUMP_SESSION_TIMEOUTS: [&str; 4] = [ + "statement_timeout", + "lock_timeout", + "idle_in_transaction_session_timeout", + "transaction_timeout", +]; + +/// Turn a dump into SQL that can be replayed on another database: drop pg_dump's psql +/// meta-commands (`\restrict` / `\unrestrict`, not valid SQL) and the session timeouts +/// its preamble sets, which the replaying server may not have as GUCs at all. Only the +/// preamble is filtered, so an object's body keeps whatever it holds. +pub(crate) fn strip_unreplayable_dump_lines(dump: &str) -> String { + let mut in_preamble = true; + dump.lines() + .filter(|line| { + in_preamble = in_preamble && is_dump_preamble_line(line.as_bytes()); + if line.trim_start().starts_with('\\') { + return false; + } + !(in_preamble + && preamble_setting_name(line.as_bytes()) + .is_some_and(|name| DUMP_SESSION_TIMEOUTS.contains(&name))) + }) + .collect::>() + .join("\n") +} + +/// The GUC a preamble `SET = ...;` line assigns, if the line is one. +fn preamble_setting_name(line: &[u8]) -> Option<&str> { + let name = line.strip_prefix(b"SET ")?.split(|c| *c == b' ').next()?; + std::str::from_utf8(name).ok() +} + +/// The preamble Windmill's postgres client writes can set GUCs an older server does not +/// have — harmless session tuning, but one failing statement aborts a restore that stops +/// on the first error. Comment those out in place, three bytes each, so the data +/// section's offsets stay put. +async fn comment_out_unsupported_settings( + dump_file: &DumpFile, + supported_settings: &HashSet, +) -> Result<()> { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + + let file = tokio::fs::File::open(&dump_file.path) + .await + .map_err(|e| Error::internal_err(format!("Failed to open dump file: {}", e)))?; + let mut reader = tokio::io::BufReader::new(file); + + let mut preamble: Vec = Vec::new(); + let mut patched = false; + loop { + let start = preamble.len(); + let read = reader + .read_until(b'\n', &mut preamble) + .await + .map_err(|e| Error::internal_err(format!("Failed to read dump file: {}", e)))?; + if read == 0 { + break; + } + let line = &preamble[start..]; + if !is_dump_preamble_line(line) { + preamble.truncate(start); + break; + } + if preamble_setting_name(line).is_some_and(|name| !supported_settings.contains(name)) { + preamble[start..start + 3].copy_from_slice(b"-- "); + patched = true; + } + } + if !patched { + return Ok(()); + } + + let mut file = tokio::fs::OpenOptions::new() + .write(true) + .open(&dump_file.path) + .await + .map_err(|e| Error::internal_err(format!("Failed to open dump file: {}", e)))?; + file.write_all(&preamble) + .await + .map_err(|e| Error::internal_err(format!("Failed to rewrite dump preamble: {}", e)))?; + file.flush() + .await + .map_err(|e| Error::internal_err(format!("Failed to rewrite dump preamble: {}", e)))?; + Ok(()) +} + +/// A psql invocation against `pg_db`, carrying the connection settings the CLI reads +/// from the environment. +fn psql_command(pg_db: &PgDatabase) -> tokio::process::Command { let mut cmd = tokio::process::Command::new("psql"); cmd.arg("--host") - .arg(host) + .arg(&pg_db.host) .arg("--port") - .arg(&port) + .arg(pg_db.port.unwrap_or(5432).to_string()) .arg("--username") - .arg(user) + .arg(pg_db.login_name()) .arg("--dbname") - .arg(dbname) + .arg(&pg_db.dbname) .arg("--no-psqlrc") - .arg("--file") - .arg(&dump_file.path) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); - if let Some(ref password) = target_db.password { + if let Some(ref password) = pg_db.password { cmd.env("PGPASSWORD", password); } - - if let Some(ref sslmode) = target_db.sslmode { + if let Some(ref sslmode) = pg_db.sslmode { cmd.env("PGSSLMODE", sslmode); } + cmd +} - let output = cmd +/// GUC names the server backing `pg_db` knows about. +/// +/// Asked through psql rather than a tokio-postgres connection so the lookup reaches +/// exactly the servers the restore itself can: libpq negotiates TLS for `sslmode=prefer` +/// and an unset mode, where `PgDatabase::connect` would hand a TLS-only server a +/// plaintext socket and fail before the import ever starts. +async fn server_setting_names(pg_db: &PgDatabase) -> Result> { + let output = psql_command(pg_db) + .arg("--tuples-only") + .arg("--no-align") + .arg("--command") + .arg("SELECT name FROM pg_settings") + .output() + .await + .map_err(|e| Error::internal_err(format!("Failed to execute psql: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::internal_err(format!( + "Failed to list the settings of the target server: {}", + stderr + ))); + } + + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()) + .collect()) +} + +/// Import a pg_dump file into a target database using psql. +/// +/// Left to its defaults psql reports a failed statement, carries on and still exits 0, +/// so a dump that breaks partway through imports partially and reads as a success. +/// ON_ERROR_STOP surfaces the failure and --single-transaction makes the restore +/// all-or-nothing, leaving the target as it was and the import retryable. +async fn pg_import_dump(target_db: &PgDatabase, dump_file: &DumpFile) -> Result<()> { + let supported_settings = server_setting_names(target_db).await?; + comment_out_unsupported_settings(dump_file, &supported_settings).await?; + + let output = psql_command(target_db) + .arg("--set") + .arg("ON_ERROR_STOP=1") + .arg("--single-transaction") + .arg("--file") + .arg(&dump_file.path) .output() .await .map_err(|e| Error::internal_err(format!("Failed to execute psql: {}", e)))?; @@ -2869,29 +3175,7 @@ async fn create_pg_database( } } - // Determine if this is an instance or resource-backed datatable - let is_instance_datatable = if let Some(dt_name) = req.source.strip_prefix("datatable://") { - let config = sqlx::query_scalar!( - "SELECT datatable->'datatables'->$2 FROM workspace_settings WHERE workspace_id = $1", - &w_id, - dt_name - ) - .fetch_optional(&db) - .await? - .flatten(); - config - .and_then(|v| { - v.get("database") - .and_then(|d| d.get("resource_type")) - .and_then(|r| r.as_str()) - .map(|s| s == "instance") - }) - .unwrap_or(false) - } else { - false - }; - - if is_instance_datatable { + if is_instance_datatable_source(&db, &w_id, &req.source).await? { windmill_common::create_custom_instance_database(&db, &req.target_dbname, "datatable") .await?; } else { @@ -2989,7 +3273,18 @@ async fn import_pg_database( } windmill_common::validate_dbname(&target_pg.dbname)?; - let dump_file = pg_dump_database(&source_pg, schema_only, &[]).await?; + // Ownership never replays: the restore runs as the target's own connection user, and + // what it creates it owns. Grants do, except around an instance data table — Windmill + // plants `custom_instance_user` grants in one, which nothing else can replay. Elsewhere + // the ACLs are user intent (`REVOKE ... FROM PUBLIC`) and dropping them widens access. + let no_acl = is_instance_datatable_source(&db, &w_id, &req.target).await? + || is_instance_datatable_source(&db, &w_id, &req.source).await?; + + let dump_file = pg_dump_database( + &source_pg, + PgDumpOptions { schema_only, no_owner: true, no_acl, ..Default::default() }, + ) + .await?; pg_import_dump(&target_pg, &dump_file).await?; Ok(format!( @@ -3011,7 +3306,11 @@ async fn export_pg_schema( Json(req): Json, ) -> Result { let pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?; - let dump_file = pg_dump_database(&pg, true, &[]).await?; + let dump_file = pg_dump_database( + &pg, + PgDumpOptions { schema_only: true, ..Default::default() }, + ) + .await?; tokio::fs::read_to_string(&dump_file.path) .await .map_err(|e| Error::internal_err(format!("Failed to read dump file: {}", e))) @@ -7118,6 +7417,76 @@ async fn enforce_cloud_fork_cap(db: &DB, parent_workspace_id: &str) -> Result<() enforce_cloud_fork_count(db, &root, 1).await } +/// Cloud: refuse to attach a workspace that already has a paid plan of its own. +/// +/// Once attached it draws the root's plan and meters its usage there, so a subscription of its own +/// bills a second time for one plan. Only an attach can reach this state: a fork is created as a +/// fresh workspace and never had a plan to keep. +/// +/// Asked only of a candidate joining this family, never of one already under the same root: that +/// one is already in the double-billed state, where the settings page surfaces the leftover +/// subscription and the portal that cancels it, and refusing there would block re-designating a +/// renamed dev workspace over a billing problem the attach did not cause. +#[cfg(feature = "cloud")] +async fn reject_attach_of_subscribed_workspace(db: &DB, dev_w_id: &str) -> Result<()> { + let plan = sqlx::query_scalar!( + "SELECT plan FROM workspace_settings WHERE workspace_id = $1", + dev_w_id + ) + .fetch_optional(db) + .await? + .flatten(); + // Any plan, not just `'team'`: the column is written by the subscription webhook, and a plan + // value it does not write yet would otherwise walk straight past this. An enterprise + // arrangement is deliberately not covered — it sets `premium` without a plan and has no + // self-serve portal, so refusing there would be a dead end rather than something to act on. + if plan.is_some() { + return Err(Error::BadRequest(format!( + "Workspace {dev_w_id} is on a paid plan of its own. A dev or fork workspace runs on its parent's plan and is never invoiced separately, so cancel that subscription from its own billing settings before attaching it." + ))); + } + Ok(()) +} + +#[cfg(all(test, feature = "cloud"))] +mod attach_billing_guard_tests { + use super::reject_attach_of_subscribed_workspace; + use sqlx::{Pool, Postgres}; + + async fn workspace_on_plan(db: &Pool, id: &str, plan: Option<&str>) { + sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ($1, $1, 'test-user')") + .bind(id) + .execute(db) + .await + .expect("insert workspace"); + sqlx::query("INSERT INTO workspace_settings (workspace_id, plan) VALUES ($1, $2)") + .bind(id) + .bind(plan) + .execute(db) + .await + .expect("insert workspace_settings"); + } + + #[sqlx::test(migrations = "../migrations")] + async fn refuses_a_candidate_that_still_pays_for_itself(db: Pool) { + workspace_on_plan(&db, "subscribed", Some("team")).await; + workspace_on_plan(&db, "cancelled", None).await; + + let err = reject_attach_of_subscribed_workspace(&db, "subscribed") + .await + .expect_err("a workspace on a paid plan of its own must not be attachable"); + assert!(err.to_string().contains("paid plan of its own"), "{err}"); + + // Cancelling clears `plan` but keeps `customer_id`, so the plan column is what decides. + reject_attach_of_subscribed_workspace(&db, "cancelled") + .await + .expect("a workspace with no plan is attachable"); + reject_attach_of_subscribed_workspace(&db, "no-settings-row") + .await + .expect("a workspace with no settings row is attachable"); + } +} + /// General guardrail (all builds): reject creating a fork/dev under `parent` when it would nest deeper /// than `MAX_FORK_DEPTH`. `added_subtree_height` is the height of the subtree grafted below the new /// node — 0 for a plain fork, or the candidate's own subtree height for an attach. @@ -7660,6 +8029,17 @@ async fn attach_dev_workspace( ))); } + // Deliberately below the admin-of-candidate check, unlike the cap enforcement above: the + // refusal names the candidate's plan, so running it earlier would tell any admin of any + // premium workspace whether an arbitrary workspace id is on a team plan. + #[cfg(feature = "cloud")] + if *CLOUD_HOSTED { + let root = windmill_common::workspaces::get_billing_workspace_id(&db, &prod_w_id).await?; + if windmill_common::workspaces::get_billing_workspace_id(&db, &dev_w_id).await? != root { + reject_attach_of_subscribed_workspace(&db, &dev_w_id).await?; + } + } + let mut tx = db.begin().await?; // Everything above ran outside a transaction, so prod's eligibility and the chain's labels could // have changed under us: re-decide both here, under the pairing lock. diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 38e207e30b..332d970319 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.796.0 + version: 1.799.0 title: Windmill API contact: @@ -3788,6 +3788,38 @@ paths: schema: type: boolean + /w/{workspace}/workspaces/billable_seats: + get: + summary: get the billable seats of the workspace the plan is billed on + operationId: getBillableSeats + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + + responses: + "200": + description: billable seats + content: + application/json: + schema: + type: object + properties: + developers: + type: integer + description: >- + Omitted when the seats counted are another workspace's, as they are for a + fork resolving to its billing root. + operators: + type: integer + description: >- + Omitted when the seats counted are another workspace's, as they are for a + fork resolving to its billing root. + seats: + type: integer + required: + - seats + /w/{workspace}/workspaces/premium_info: get: summary: get premium info @@ -13636,6 +13668,10 @@ paths: type: array items: $ref: "#/components/schemas/S3Object" + expiry_secs: + type: integer + format: int64 + description: how long the signature stays valid, in seconds. Defaults to 43200 (12h) and is clamped server-side to [60, 604800] (1 minute to 7 days). required: - s3_objects responses: @@ -25451,6 +25487,68 @@ paths: schema: type: string + /w/{workspace}/hub/projects/{slug}/withdraw: + post: + summary: take a hub project submission back out of review + description: | + Requires the caller to be a workspace admin. Forwards the request to the + configured Hub scoped to the `{workspace}:{folder}` source and returns + the Hub's status code and raw response body. Everything pushed for the + submission is kept, so it can be fixed and submitted again. + operationId: withdrawHubProject + tags: + - hubPublish + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: slug + in: path + required: true + description: hub project slug (3-50 chars, lowercase alphanumeric and hyphens, no leading/trailing hyphen) + schema: + type: string + minLength: 3 + maxLength: 50 + pattern: "^[a-z0-9][a-z0-9-]*[a-z0-9]$" + - $ref: "#/components/parameters/HubPublishFolder" + responses: + "200": + description: raw Hub response body (status code is passed through from the Hub) + content: + text/plain: + schema: + type: string + + /w/{workspace}/hub/projects/{slug}/discard_update: + post: + summary: discard the pending update to a published hub project + description: | + Requires the caller to be a workspace admin. Forwards the request to the + configured Hub scoped to the `{workspace}:{folder}` source and returns + the Hub's status code and raw response body. The published project is + left untouched. + operationId: discardHubProjectUpdate + tags: + - hubPublish + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: slug + in: path + required: true + description: hub project slug (3-50 chars, lowercase alphanumeric and hyphens, no leading/trailing hyphen) + schema: + type: string + minLength: 3 + maxLength: 50 + pattern: "^[a-z0-9][a-z0-9-]*[a-z0-9]$" + - $ref: "#/components/parameters/HubPublishFolder" + responses: + "200": + description: raw Hub response body (status code is passed through from the Hub) + content: + text/plain: + schema: + type: string + /w/{workspace}/hub/project: get: summary: get the hub project linked to a workspace folder @@ -28092,7 +28190,10 @@ components: type: string args: $ref: "#/components/schemas/ScriptArgs" - result: {} + result: + description: | + For large results, this may be the placeholder string 'WINDMILL_TOO_BIG'. + Use the completed job result endpoint to retrieve the full result. logs: type: string deleted: @@ -30366,7 +30467,9 @@ components: description: If true, passes the request body as a raw string instead of parsing as JSON error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: $ref: "#/components/schemas/ScriptArgs" description: Arguments to pass to the error handler @@ -30455,7 +30558,9 @@ components: description: If true, passes the request body as a raw string instead of parsing as JSON error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: $ref: "#/components/schemas/ScriptArgs" description: Arguments to pass to the error handler @@ -30551,7 +30656,9 @@ components: description: If true, passes the request body as a raw string instead of parsing as JSON error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: $ref: "#/components/schemas/ScriptArgs" description: Arguments to pass to the error handler @@ -30743,7 +30850,9 @@ components: description: Optional periodic heartbeat message configuration error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: description: Arguments to pass to the error handler $ref: "#/components/schemas/ScriptArgs" @@ -30808,7 +30917,9 @@ components: description: Optional periodic heartbeat message configuration error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: description: Arguments to pass to the error handler $ref: "#/components/schemas/ScriptArgs" @@ -30884,7 +30995,9 @@ components: description: Optional periodic heartbeat message configuration error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: description: Arguments to pass to the error handler $ref: "#/components/schemas/ScriptArgs" @@ -31012,7 +31125,9 @@ components: description: Last error message if the trigger failed error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: $ref: "#/components/schemas/ScriptArgs" description: Arguments to pass to the error handler @@ -31063,7 +31178,9 @@ components: $ref: "#/components/schemas/TriggerMode" error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: $ref: "#/components/schemas/ScriptArgs" description: Arguments to pass to the error handler @@ -31127,7 +31244,9 @@ components: $ref: "#/components/schemas/TriggerMode" error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: $ref: "#/components/schemas/ScriptArgs" description: Arguments to pass to the error handler @@ -31210,7 +31329,9 @@ components: description: Last error message if the trigger failed error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: $ref: "#/components/schemas/ScriptArgs" description: Arguments to pass to the error handler @@ -31251,7 +31372,9 @@ components: $ref: "#/components/schemas/TriggerMode" error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: $ref: "#/components/schemas/ScriptArgs" description: Arguments to pass to the error handler @@ -31305,7 +31428,9 @@ components: $ref: "#/components/schemas/TriggerMode" error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: $ref: "#/components/schemas/ScriptArgs" description: Arguments to pass to the error handler @@ -31703,7 +31828,9 @@ components: description: Last error message if the trigger failed error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: $ref: "#/components/schemas/ScriptArgs" description: Arguments to pass to the error handler @@ -31805,7 +31932,9 @@ components: $ref: "#/components/schemas/TriggerMode" error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: $ref: "#/components/schemas/ScriptArgs" description: Arguments to pass to the error handler @@ -31861,7 +31990,9 @@ components: $ref: "#/components/schemas/TriggerMode" error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: $ref: "#/components/schemas/ScriptArgs" description: Arguments to pass to the error handler @@ -31989,7 +32120,9 @@ components: description: Timestamp of last server heartbeat (internal) error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: $ref: "#/components/schemas/ScriptArgs" description: Arguments to pass to the error handler @@ -32029,7 +32162,9 @@ components: description: Configuration for creating/managing the publication (tables, operations) error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: $ref: "#/components/schemas/ScriptArgs" description: Arguments to pass to the error handler @@ -32081,7 +32216,9 @@ components: description: Configuration for creating/managing the publication (tables, operations) error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: $ref: "#/components/schemas/ScriptArgs" description: Arguments to pass to the error handler @@ -32158,7 +32295,9 @@ components: description: Last error message if the trigger failed error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: $ref: "#/components/schemas/ScriptArgs" description: Arguments to pass to the error handler @@ -32222,7 +32361,9 @@ components: $ref: "#/components/schemas/TriggerMode" error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: $ref: "#/components/schemas/ScriptArgs" description: Arguments to pass to the error handler @@ -32297,7 +32438,9 @@ components: description: True if script_path points to a flow, false if it points to a script error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: $ref: "#/components/schemas/ScriptArgs" description: Arguments to pass to the error handler @@ -32360,7 +32503,9 @@ components: description: Last error message if the trigger failed error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: $ref: "#/components/schemas/ScriptArgs" description: Arguments to pass to the error handler @@ -32408,7 +32553,9 @@ components: $ref: "#/components/schemas/TriggerMode" error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: $ref: "#/components/schemas/ScriptArgs" description: Arguments to pass to the error handler @@ -32467,7 +32614,9 @@ components: description: True if script_path points to a flow, false if it points to a script error_handler_path: type: string - description: Path to a script or flow to run when the triggered job fails + description: Path to a script to run when the triggered job fails. A bare + path, without the script/ or flow/ prefix a schedule error handler takes; + it cannot be a flow. error_handler_args: $ref: "#/components/schemas/ScriptArgs" description: Arguments to pass to the error handler diff --git a/backend/windmill-api/src/ai_evals/datasets.rs b/backend/windmill-api/src/ai_evals/datasets.rs index da901276b0..8e1acb3d2a 100644 --- a/backend/windmill-api/src/ai_evals/datasets.rs +++ b/backend/windmill-api/src/ai_evals/datasets.rs @@ -190,6 +190,8 @@ pub async fn create_dataset( } tx.commit().await?; + windmill_common::feature_usage::log_feature_usage("ai_agent_eval", "dataset_created", ""); + Ok(format!("Created eval dataset {}", payload.path)) } diff --git a/backend/windmill-api/src/ai_evals/run.rs b/backend/windmill-api/src/ai_evals/run.rs index e102abaf4c..5d282c7e5b 100644 --- a/backend/windmill-api/src/ai_evals/run.rs +++ b/backend/windmill-api/src/ai_evals/run.rs @@ -707,6 +707,17 @@ pub async fn run_experiment( .await?; return Err(e); } + // Which state of the agent was measured is the whole key vocabulary: it is what separates + // running what is deployed from measuring edits or an older version. + windmill_common::feature_usage::log_feature_usage( + "ai_agent_eval", + "run", + match subject.kind { + EvalSubjectKind::Agent => "agent", + EvalSubjectKind::AgentDraft => "agent_draft", + EvalSubjectKind::AgentVersion => "agent_version", + }, + ); Ok(experiment_id.to_string()) } diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 0e64d1cba2..7bc995844d 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -4112,10 +4112,18 @@ struct S3DeleteTokenClaims { pub exp: usize, } +#[cfg(feature = "parquet")] +const SIGN_S3_DEFAULT_EXPIRY_SECS: i64 = 12 * 60 * 60; +#[cfg(feature = "parquet")] +const SIGN_S3_MIN_EXPIRY_SECS: i64 = 60; +#[cfg(feature = "parquet")] +const SIGN_S3_MAX_EXPIRY_SECS: i64 = 7 * 24 * 60 * 60; + #[cfg(feature = "parquet")] #[derive(Deserialize)] struct S3TokenRequestBody { s3_objects: Vec, + expiry_secs: Option, } #[cfg(feature = "parquet")] async fn sign_s3_objects( @@ -4126,6 +4134,12 @@ async fn sign_s3_objects( ) -> Result>> { let workspace_key = get_workspace_key(&w_id, &db).await?; + let expiry_secs = body + .expiry_secs + .unwrap_or(SIGN_S3_DEFAULT_EXPIRY_SECS) + .clamp(SIGN_S3_MIN_EXPIRY_SECS, SIGN_S3_MAX_EXPIRY_SECS); + let exp = (chrono::Utc::now() + chrono::Duration::seconds(expiry_secs)).timestamp(); + let futures = body.s3_objects.into_iter().map(|s3_object| async { // The signature this mints is a transferable bearer capability: `validate_s3_signature` // only checks the HMAC and expiry, so anyone who obtains the string can read this key. @@ -4156,7 +4170,6 @@ async fn sign_s3_objects( ) .await?; - let exp = (chrono::Utc::now() + chrono::Duration::hours(12)).timestamp(); let message = format!( "file_key={}&exp={}{}", s3_object.s3.clone(), diff --git a/backend/windmill-api/src/db.rs b/backend/windmill-api/src/db.rs index 04d21c7b53..d781e229d2 100644 --- a/backend/windmill-api/src/db.rs +++ b/backend/windmill-api/src/db.rs @@ -102,6 +102,12 @@ lazy_static::lazy_static! { (20260727151319, include_str!( "../../migrations/20260727151319_draft_only_listing_indexes.up.sql" ).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY")), + (20260826202939, include_str!( + "../../migrations/20260826202939_queue_suspended_resume_at_index.up.sql" + ).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY").replace("DROP INDEX", "DROP INDEX CONCURRENTLY")), + (20260826214706, include_str!( + "../../migrations/20260826214706_queue_suspended_drop_legacy_index.up.sql" + ).replace("DROP INDEX", "DROP INDEX CONCURRENTLY")), ].into_iter().collect(); } @@ -228,6 +234,8 @@ impl Migrate for CustomMigrator { // CONCURRENTLY operations cannot run inside a transaction block // or a multi-statement query (PostgreSQL requires top-level execution). // Split into individual statements and execute each separately. + // The split is naive, so a `;` anywhere in an overridden migration — + // inside a comment or a string literal included — splits mid-statement. for stmt in migration_sql.split(';') { let stmt = stmt.trim(); if !stmt.is_empty() diff --git a/backend/windmill-api/src/hub_publish.rs b/backend/windmill-api/src/hub_publish.rs index 033c6ebce5..ccb530ce20 100644 --- a/backend/windmill-api/src/hub_publish.rs +++ b/backend/windmill-api/src/hub_publish.rs @@ -42,6 +42,11 @@ pub fn workspaced_service() -> Router { .route("/migrations", post(publish_migrations)) .route("/projects/{slug}/export", get(get_project_export)) .route("/projects/{slug}/submit", post(submit_project)) + .route("/projects/{slug}/withdraw", post(withdraw_project)) + .route( + "/projects/{slug}/discard_update", + post(discard_project_update), + ) .route("/project", get(get_project_by_source)) } @@ -554,9 +559,35 @@ async fn submit_project( .await } -// The Hub has no auth of its own: it validates bearer tokens by calling this -// instance's /api/users/whoami. Forwarding the caller's own token logs them in -// on the Hub as themselves (account auto-created on first use). +// Take a submission back out of review, keeping what was pushed for it. +async fn withdraw_project( + ctx: HubPublishCtx, + Path((_workspace, slug)): Path<(String, ProjectSlug)>, +) -> Result { + ctx.post( + &format!("/projects/{}/withdraw", slug), + &serde_json::json!({}), + ) + .await +} + +// Throw away the pending update to an already-published project. The published +// version is untouched — it never saw the update. +async fn discard_project_update( + ctx: HubPublishCtx, + Path((_workspace, slug)): Path<(String, ProjectSlug)>, +) -> Result { + ctx.post( + &format!("/projects/{}/discard_update", slug), + &serde_json::json!({}), + ) + .await +} + +// The Hub has no auth of its own: it validates bearer tokens by calling +// /api/users/whoami — on app.windmill.dev for the public Hub, on the paired +// instance for a private one. Forwarding the caller's own token logs them in on +// the Hub as themselves (account auto-created on first use). async fn get_from_hub( path: &str, source_id: &str, diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 513341b6c1..a30cffbfbf 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -6559,17 +6559,18 @@ pub async fn run_flow_by_version_inner( Ok((uuid, early_return, has_failure_module)) } +/// Path parameters of `POST /w/{workspace}/jobs/restart/f/{job_id}`, shared by the CE and EE +/// handlers. Axum only checks the tuple against the route at request time and rejects a +/// mismatch with an opaque 500 before the handler runs, so both must be declared from here: +/// an arity that drifts from the route hides the handler behind what reads as a broken route. +type RestartFlowPath = Path<(String, Uuid)>; + #[cfg(not(feature = "enterprise"))] pub async fn restart_flow( _authed: ApiAuthed, Extension(_db): Extension, Extension(_user_db): Extension, - Path((_w_id, _job_id, _step_id, _branch_or_iteration_n)): Path<( - String, - Uuid, - String, - Option, - )>, + Path((_w_id, _job_id)): RestartFlowPath, Query(_run_query): Query, ) -> error::Result<(StatusCode, String)> { return Err(Error::BadRequest( @@ -6798,7 +6799,7 @@ pub async fn restart_flow( authed: ApiAuthed, Extension(db): Extension, Extension(user_db): Extension, - Path((w_id, job_id)): Path<(String, Uuid)>, + Path((w_id, job_id)): RestartFlowPath, Query(run_query): Query, Json(RestartFlowRequestBody { step_id, diff --git a/backend/windmill-api/src/triggers/http/handler.rs b/backend/windmill-api/src/triggers/http/handler.rs index 37442c8111..e58aab44a2 100644 --- a/backend/windmill-api/src/triggers/http/handler.rs +++ b/backend/windmill-api/src/triggers/http/handler.rs @@ -123,7 +123,9 @@ async fn get_http_route_trigger( let routers_cache = if routers_cache.routers.is_empty() { tracing::warn!("HTTP routers are not loaded, loading from db"); - let (_, routers_cache) = refresh_routers(db).await?; + // refresh_routers takes the write lock, so holding this read guard across it deadlocks. + drop(routers_cache); + let (_, routers_cache) = refresh_routers(db, false).await?; routers_cache } else { routers_cache diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index 9116021626..05cd354cd4 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -643,6 +643,16 @@ pub(crate) async fn tarball_workspace( windmill_api_auth::forbid_scoped_token_workspace_key(&authed)?; } + // settings.json carries the admin-managed integration config that `get_settings` + // is admin-only for (ai_config, the webhook URL, git_sync, handler extra_args), + // so it takes the same check. Not a per-field redaction: fields silently dropped + // from settings.json come back as null on the next `wmill sync push`. + if include_settings.unwrap_or(false) && !authed.is_admin { + return Err(Error::PermissionDenied( + "include_settings requires workspace admin".to_string(), + )); + } + // The route is gated by workspaces:read, but the tarball also carries the item // values that the per-item routes gate on their own domain (get_resource_value, // get_variable). A whole-workspace export cannot be confined to a path, so it @@ -1626,13 +1636,7 @@ pub(crate) async fn tarball_workspace( slack_name: row.slack_name.clone(), slack_command_script: row.slack_command_script.clone(), slack_oauth_client_id: row.slack_oauth_client_id.clone(), - // Mirror the non-admin redaction in `get_settings`: the OAuth - // client secret is admin-only and must not leak via tarball. - slack_oauth_client_secret: if authed.is_admin { - row.slack_oauth_client_secret.clone() - } else { - None - }, + slack_oauth_client_secret: row.slack_oauth_client_secret.clone(), }; serde_json::to_value(settings) .map(|v| serde_json::to_string_pretty(&v).ok()) diff --git a/backend/windmill-common/src/db_params.rs b/backend/windmill-common/src/db_params.rs deleted file mode 100644 index d52700376d..0000000000 --- a/backend/windmill-common/src/db_params.rs +++ /dev/null @@ -1,45 +0,0 @@ -use anyhow::Result; - -/// Parsed database connection parameters, shared across DB auth providers (IAM RDS, Entra ID, etc.) -#[derive(Debug, Clone)] -pub struct DatabaseParams { - pub hostname: String, - pub port: u64, - pub username: String, - pub database: String, -} - -/// Extract database connection parameters from a PostgreSQL URL -pub fn extract_database_params(database_url: &str) -> Result { - let url = url::Url::parse(database_url) - .map_err(|e| anyhow::anyhow!("Failed to parse database URL: {}", e))?; - - let hostname = url - .host_str() - .ok_or_else(|| anyhow::anyhow!("Database URL missing hostname"))? - .to_string(); - - let port = url.port().unwrap_or(5432) as u64; - - let username = if url.username().is_empty() { - return Err(anyhow::anyhow!("Database URL missing username")); - } else { - urlencoding::decode(url.username())?.to_string() - }; - - let database = url - .path() - .trim_start_matches('/') - .split('/') - .next() - .filter(|s| !s.is_empty()) - .ok_or_else(|| anyhow::anyhow!("Database URL missing database name"))? - .to_string(); - - Ok(DatabaseParams { - hostname, - port, - username, - database: urlencoding::decode(&database)?.to_string(), - }) -} diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 89db0ee769..cf0be2bbd8 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -42,7 +42,6 @@ pub mod db; mod db_entra_ee; #[cfg(all(feature = "enterprise", feature = "private"))] mod db_iam_ee; -pub mod db_params; pub mod dbt_manifest; pub mod deploy_origin; #[cfg(feature = "private")] @@ -1479,6 +1478,17 @@ pub async fn create_custom_instance_database( Ok(()) } +/// Connection options parsed from a database URL. +/// +/// The only place a database URL becomes `PgConnectOptions`. Providers that mint the password +/// themselves override it on these and keep the rest: options assembled field by field instead +/// would drop every query parameter, `sslmode` and `sslrootcert` above all, leaving the +/// connection on sqlx's default TLS policy rather than the operator's. +pub fn base_connect_options(database_url: &str) -> Result { + sqlx::postgres::PgConnectOptions::from_str(database_url) + .map_err(|e| Error::InternalErr(format!("Failed to parse database URL: {}", e))) +} + #[derive(Clone)] pub enum DatabaseUrl { #[cfg(all(feature = "enterprise", feature = "private"))] @@ -1509,8 +1519,8 @@ impl DatabaseUrl { } /// Get PgConnectOptions for this database URL. - /// For token-based auth (IAM RDS, Entra ID), this returns options built directly from the - /// token to avoid double-encoding issues with temporary credentials. + /// For token-based auth (IAM RDS, Entra ID), this returns options carrying the current + /// token, set on the builder to avoid double-encoding temporary credentials. /// For static URLs, this parses the URL string. pub async fn connect_options(&self) -> Result { match self { @@ -1524,8 +1534,7 @@ impl DatabaseUrl { let guard = entra_url.read().await; Ok(guard.connect_options()) } - DatabaseUrl::Static(url) => sqlx::postgres::PgConnectOptions::from_str(url) - .map_err(|e| Error::InternalErr(format!("Failed to parse database URL: {}", e))), + DatabaseUrl::Static(url) => base_connect_options(url), } } diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index 7945e73b45..84284759c6 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -734,11 +734,17 @@ fn format_pull_query(peek: String) -> String { r } +// The `CASE` is `suspend <= 0 OR suspend_until <= now()` written as one indexable +// expression, equivalent only under the `suspend_until IS NOT NULL` guard. It must stay in +// sync with `queue_suspended_v2` (migration 20260826202939): if it no longer matches, the +// test silently reverts to a heap filter over every suspended row on every worker poll. pub fn make_suspended_pull_query(tags: &[String]) -> String { format_pull_query(format!( "SELECT id FROM v2_job_queue - WHERE suspend_until IS NOT NULL AND (suspend <= 0 OR suspend_until <= now()) AND tag IN ({}) + WHERE suspend_until IS NOT NULL + AND (CASE WHEN suspend <= 0 THEN '-infinity'::timestamptz ELSE suspend_until END) <= now() + AND tag IN ({}) ORDER BY priority DESC NULLS LAST, created_at FOR UPDATE SKIP LOCKED LIMIT 1", diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 5f558d325a..c1d8fc00bc 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -759,15 +759,24 @@ pub async fn count_workspace_forks(db: &crate::DB, root: &str) -> Result { Ok(count) } -/// Approximate paid seats of a workspace as `ceil(developers + operators/2)`, excluding disabled and -/// service-account members. Reuses billing's author/operator weighting, but counts provisioned -/// members rather than the active-user population billing meters, so it only ever loosens the fork -/// cap (never blocks a paid seat) — good enough for a soft guardrail. +/// The billable members of a workspace and the seats they add up to. +#[derive(Clone, Debug, Serialize)] +pub struct BillableSeats { + pub developers: i64, + pub operators: i64, + pub seats: i64, +} + +/// Billable members of `w_id` and the seats they cost, as `ceil(developers + operators/2)`. Service +/// accounts cannot log in and do not take a seat; a disabled member is not billed either. +/// +/// The workspace is invoiced by a job outside this codebase that counts the same rows with its own +/// SQL. The two must be changed together: this rule disagreeing with that one is what bills a +/// workspace for seats the product never credits it for. /// /// Unauthenticated metering helper: reads member counts for any `w_id`, so callers must already be /// authorized for that workspace (or run in trusted server-side code). -#[cfg(feature = "cloud")] -pub async fn count_paid_seats(db: &crate::DB, w_id: &str) -> Result { +pub async fn billable_seats(db: &crate::DB, w_id: &str) -> Result { let row = sqlx::query!( r#"SELECT COUNT(*) FILTER (WHERE NOT operator AND NOT disabled AND NOT is_service_account) AS "developers!", @@ -777,8 +786,18 @@ pub async fn count_paid_seats(db: &crate::DB, w_id: &str) -> Result { ) .fetch_one(db) .await - .map_err(|e| Error::internal_err(format!("counting paid seats of {w_id}: {e:#}")))?; - Ok(((row.developers as f64) + 0.5 * (row.operators as f64)).ceil() as i64) + .map_err(|e| Error::internal_err(format!("counting billable seats of {w_id}: {e:#}")))?; + Ok(BillableSeats { + developers: row.developers, + operators: row.operators, + seats: ((row.developers as f64) + 0.5 * (row.operators as f64)).ceil() as i64, + }) +} + +/// Seats only, for the fork cap. See [`billable_seats`]. +#[cfg(feature = "cloud")] +pub async fn count_paid_seats(db: &crate::DB, w_id: &str) -> Result { + Ok(billable_seats(db, w_id).await?.seats) } #[cfg(feature = "cloud")] @@ -2193,6 +2212,32 @@ pub fn lfs_entry_storage_ref(entry: &serde_json::Value) -> Option { Some(format!("{typ}:{path}")) } +pub const FILESYSTEM_STORAGE_DEV_ONLY_MSG: &str = + "Filesystem storage is only available in development builds of Windmill: it points the \ + workspace at a directory on the server's own disk rather than at a resource. Use an S3, \ + Azure Blob or Google Cloud Storage backend instead."; + +/// A filesystem workspace storage names a directory on the server's own disk, so it hands whoever +/// configures it — a workspace admin, or any member who can write a `filesystem` resource — +/// whatever the server process can reach, and it only resolves when server and workers share that +/// disk. It is there so local development can skip MinIO, hence debug builds only. Instance object +/// storage on local disk is a separate, superadmin-only setting and stays allowed everywhere. +pub fn filesystem_storage_allowed() -> bool { + cfg!(debug_assertions) +} + +/// Guards every site that builds an `ObjectStoreResource::Filesystem`, so nothing downstream can +/// reach a local-disk store: a stored config outlives the build that accepted it, and the resource +/// route never passes through the workspace-storage settings at all. +pub fn ensure_filesystem_storage_allowed() -> Result<()> { + if !filesystem_storage_allowed() { + return Err(Error::BadRequest( + FILESYSTEM_STORAGE_DEV_ONLY_MSG.to_string(), + )); + } + Ok(()) +} + /// Resolve a `$res:`/`$var:` reference tree to its concrete value (recursively, secrets /// decrypted). No permission checks — trusted server-side callers only; never echo the result /// to a user. diff --git a/backend/windmill-common/tests/billing_workspace.rs b/backend/windmill-common/tests/billing_workspace.rs index 1b0186a5cf..3025061249 100644 --- a/backend/windmill-common/tests/billing_workspace.rs +++ b/backend/windmill-common/tests/billing_workspace.rs @@ -3,7 +3,7 @@ use sqlx::{Pool, Postgres}; use windmill_common::workspaces::{ - count_paid_seats, count_workspace_forks, fork_chain_depth, fork_subtree_height, + billable_seats, count_paid_seats, count_workspace_forks, fork_chain_depth, fork_subtree_height, get_billing_workspace_id, invalidate_billing_workspace_cache, list_fork_descendants, }; @@ -106,11 +106,17 @@ async fn paid_seats_and_fork_count(db: Pool) { insert_member(&db, "seat-root", "dev2@w.dev", false, false, false).await; insert_member(&db, "seat-root", "op1@w.dev", true, false, false).await; insert_member(&db, "seat-root", "op2@w.dev", true, false, false).await; - // These must NOT count towards seats. + // These must NOT count towards seats. The service account is a non-operator, so counting it + // would inflate the developer tally the invoice line is written from, not the operator one. insert_member(&db, "seat-root", "disabled@w.dev", false, true, false).await; insert_member(&db, "seat-root", "svc@w.dev", false, false, true).await; assert_eq!(count_paid_seats(&db, "seat-root").await.unwrap(), 3); + let breakdown = billable_seats(&db, "seat-root").await.unwrap(); + assert_eq!( + (breakdown.developers, breakdown.operators, breakdown.seats), + (2, 2, 3) + ); insert_ws(&db, "seat-fork1", Some("seat-root"), false).await; insert_ws(&db, "seat-fork2", Some("seat-root"), false).await; diff --git a/backend/windmill-object-store/src/lib.rs b/backend/windmill-object-store/src/lib.rs index 0f919f1048..cc588d485f 100644 --- a/backend/windmill-object-store/src/lib.rs +++ b/backend/windmill-object-store/src/lib.rs @@ -1171,6 +1171,7 @@ pub fn lfs_to_object_store_resource( Ok(ObjectStoreResource::Gcs(gcs_resource)) } LargeFileStorage::FilesystemStorage(fs) => { + windmill_common::workspaces::ensure_filesystem_storage_allowed()?; Ok(ObjectStoreResource::Filesystem(FilesystemSettings { root_path: fs.root_path.clone(), })) diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index a1bd8034f0..c7c119a5a7 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -4671,6 +4671,12 @@ pub async fn check_debouncing_within_limits( } } +/// Whether the tag's queue name is computed from the job's arguments, so that a caller holding +/// arguments it could not build knows the tag cannot be built either. +pub fn tag_reads_args(tag: &str) -> bool { + RE_ARG_TAG.is_match(tag) +} + pub fn interpolate_args(x: String, args: &PushArgs, workspace_id: &str) -> String { // Save this value to avoid parsing twice let workspaced = x.as_str().replace("$workspace", workspace_id).to_string(); diff --git a/backend/windmill-trigger-http/src/handler.rs b/backend/windmill-trigger-http/src/handler.rs index 14e1b9a4d2..68986bb6f9 100644 --- a/backend/windmill-trigger-http/src/handler.rs +++ b/backend/windmill-trigger-http/src/handler.rs @@ -268,6 +268,15 @@ pub async fn create_many_http_triggers( format!("http_triggers:write:{}", &new_http_trigger.base.path) })?; + // This route inserts directly, bypassing the shared create handler. + // `error_wrapper` would turn the rejection into a 500. + new_http_trigger.error_handling.validate().map_err(|err| { + Error::BadRequest(format!( + "Error occurred for HTTP route at route path: {}, error: {}", + new_http_trigger.config.route_path, err + )) + })?; + handler .validate_new(&db, &w_id, &new_http_trigger.config) .await @@ -570,8 +579,8 @@ impl TriggerCrud for HttpTrigger { route_path, &route_path_key, Some(effective_workspaced), - trigger.config.wrap_body, - trigger.config.raw_string, + trigger.config.wrap_body.unwrap_or(false), + trigger.config.raw_string.unwrap_or(false), trigger.config.authentication_resource_path, trigger.base.script_path, trigger.base.path, @@ -626,8 +635,8 @@ impl TriggerCrud for HttpTrigger { workspace_id = $20 AND path = $21 "#, - trigger.config.wrap_body, - trigger.config.raw_string, + trigger.config.wrap_body.unwrap_or(false), + trigger.config.raw_string.unwrap_or(false), trigger.config.authentication_resource_path, trigger.base.script_path, trigger.base.path, diff --git a/backend/windmill-trigger-http/src/lib.rs b/backend/windmill-trigger-http/src/lib.rs index f89247dd16..b78276c28d 100644 --- a/backend/windmill-trigger-http/src/lib.rs +++ b/backend/windmill-trigger-http/src/lib.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use quick_cache::sync::Cache; use serde::{Deserialize, Serialize}; @@ -27,9 +28,12 @@ lazy_static::lazy_static! { pub static ref HTTP_ROUTERS_CACHE: RwLock = RwLock::new(RoutersCache { routers: HashMap::new(), version: 0, + invalidations: 0, }); } +static HTTP_ROUTERS_INVALIDATIONS: AtomicU64 = AtomicU64::new(0); + #[derive(Debug, Deserialize, Clone)] pub struct TriggerRoute { pub path: String, @@ -56,6 +60,10 @@ pub struct TriggerRoute { pub struct RoutersCache { pub routers: HashMap>, pub version: i64, + /// `HTTP_ROUTERS_INVALIDATIONS` as of the moment these rows were read. A rebuild that + /// started before an invalidation publishes a count behind the current one, which is what + /// stops it from passing its own stale rows off as covering that invalidation. + invalidations: u64, } #[derive(Serialize, Deserialize, sqlx::Type, Debug, Clone, Copy, Hash, Eq, PartialEq)] @@ -223,12 +231,24 @@ pub fn validate_authentication_method( } } -pub async fn refresh_routers(db: &DB) -> Result<(bool, RwLockReadGuard<'_, RoutersCache>)> { +/// `force` rebuilds unconditionally. `nextval` on `http_trigger_version_seq` runs inside the +/// writing transaction and sequences are non-transactional, so another session can cache the +/// bumped version against still-uncommitted rows, after which every version-gated refresh is a +/// no-op. Force when reacting to a bump that could have been observed before its own rows were. +pub async fn refresh_routers( + db: &DB, + force: bool, +) -> Result<(bool, RwLockReadGuard<'_, RoutersCache>)> { + let invalidations = HTTP_ROUTERS_INVALIDATIONS.load(Ordering::Relaxed); let version = sqlx::query_scalar!("SELECT last_value FROM http_trigger_version_seq",) .fetch_one(db) .await?; let routers_cache = HTTP_ROUTERS_CACHE.read().await; - if routers_cache.version == 0 || version > routers_cache.version { + if force + || routers_cache.version == 0 + || version > routers_cache.version + || invalidations != routers_cache.invalidations + { drop(routers_cache); let mut routers = HashMap::new(); @@ -274,7 +294,8 @@ 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.load(std::sync::atomic::Ordering::Relaxed); + let http_route_workspaced = + HTTP_ROUTE_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed); for trigger in triggers { let full_path = @@ -306,7 +327,7 @@ pub async fn refresh_routers(db: &DB) -> Result<(bool, RwLockReadGuard<'_, Route } let mut routers_cache = HTTP_ROUTERS_CACHE.write().await; - *routers_cache = RoutersCache { routers, version }; + *routers_cache = RoutersCache { routers, version, invalidations }; Ok((true, routers_cache.downgrade())) } else { @@ -315,11 +336,19 @@ pub async fn refresh_routers(db: &DB) -> Result<(bool, RwLockReadGuard<'_, Route } } +/// Record that the cache no longer covers everything committed, so the next refresh rebuilds +/// whatever the version says. The routes already loaded keep being served in the meantime. Use +/// after a forced refresh fails: its change is inside the cached version, so nothing else would +/// retry it. +pub fn invalidate_routers() { + HTTP_ROUTERS_INVALIDATIONS.fetch_add(1, Ordering::Relaxed); +} + pub async fn refresh_routers_loop( db: &DB, mut killpill_rx: tokio::sync::broadcast::Receiver<()>, ) -> () { - match refresh_routers(db).await { + match refresh_routers(db, false).await { Ok(_) => { tracing::info!("Loaded HTTP routers"); } @@ -335,7 +364,7 @@ pub async fn refresh_routers_loop( break; } _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => { - match refresh_routers(&db).await { + match refresh_routers(&db, false).await { Ok((true, _)) => { tracing::info!("Refreshed HTTP routers"); } diff --git a/backend/windmill-trigger-http/tests/refresh_routers.rs b/backend/windmill-trigger-http/tests/refresh_routers.rs new file mode 100644 index 0000000000..f47da2ee0e --- /dev/null +++ b/backend/windmill-trigger-http/tests/refresh_routers.rs @@ -0,0 +1,62 @@ +use sqlx::{Pool, Postgres}; +use windmill_trigger_http::{invalidate_routers, refresh_routers, HttpMethod, RoutersCache}; + +async fn insert_trigger(db: &Pool, path: &str, route_path: &str) { + sqlx::query( + "INSERT INTO http_trigger ( + path, route_path, route_path_key, script_path, is_flow, workspace_id, edited_by, + permissioned_as, http_method, authentication_method, request_type, is_static_website, + workspaced_route, wrap_body, raw_string, mode + ) VALUES ($1, $2, $2, 'f/test/handler', false, 'test-workspace', 'test-user', + 'u/test-user', 'get', 'none', 'async', false, false, false, false, 'enabled')", + ) + .bind(path) + .bind(route_path) + .execute(db) + .await + .expect("insert http_trigger"); +} + +fn routes(cache: &RoutersCache, path: &str) -> bool { + cache.routers[&HttpMethod::Get].at(path).is_ok() +} + +// A trigger row can commit without advancing http_trigger_version_seq past what the cache +// already holds, because `nextval` runs ahead of the commit it belongs to. The version gate +// cannot see such a row; only forcing, or an invalidation, recovers the route. +#[sqlx::test(migrations = "../migrations")] +async fn rebuilds_a_change_the_cached_version_does_not_cover(db: Pool) { + insert_trigger(&db, "f/test/first", "first").await; + let (rebuilt, cache) = refresh_routers(&db, false).await.unwrap(); + assert!(rebuilt); + assert!(routes(&cache, "/first")); + drop(cache); + + insert_trigger(&db, "f/test/second", "second").await; + + let (rebuilt, cache) = refresh_routers(&db, false).await.unwrap(); + assert!(!rebuilt, "an unchanged version must not rebuild"); + assert!(!routes(&cache, "/second")); + drop(cache); + + let (rebuilt, cache) = refresh_routers(&db, true).await.unwrap(); + assert!(rebuilt, "force must rebuild whatever the version says"); + assert!(routes(&cache, "/second")); + drop(cache); + + // A forced refresh that failed leaves its change inside the cached version, so the periodic + // version-gated refresh has to rebuild on the invalidation alone. + insert_trigger(&db, "f/test/third", "third").await; + invalidate_routers(); + + let (rebuilt, cache) = refresh_routers(&db, false).await.unwrap(); + assert!( + rebuilt, + "an invalidation must rebuild through the version gate" + ); + assert!(routes(&cache, "/third")); + drop(cache); + + let (rebuilt, _) = refresh_routers(&db, false).await.unwrap(); + assert!(!rebuilt, "a served invalidation must not rebuild forever"); +} diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 28898489f4..8ef7c1ee39 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -554,6 +554,8 @@ async fn create_trigger( ))); } + new_trigger.error_handling.validate()?; + handler .validate_new(&db, &workspace_id, &new_trigger.config) .await?; @@ -815,6 +817,8 @@ async fn update_trigger( ) })?; + edit_trigger.error_handling.validate()?; + handler .validate_edit(&db, &workspace_id, &edit_trigger.config, path) .await?; diff --git a/backend/windmill-trigger/src/types.rs b/backend/windmill-trigger/src/types.rs index dcf9a136be..2c1b854396 100644 --- a/backend/windmill-trigger/src/types.rs +++ b/backend/windmill-trigger/src/types.rs @@ -84,6 +84,31 @@ pub struct TriggerErrorHandling { pub retry: Option>, } +impl TriggerErrorHandling { + /// Schedule and workspace error handlers encode script-vs-flow as a + /// `script/`/`flow/` prefix; a trigger's handler is always a script, so a + /// prefixed path here would be looked up verbatim as a script name and fail + /// only once the trigger errors, which is when the handler is needed. + pub fn validate(&self) -> windmill_common::error::Result<()> { + let Some(path) = self.error_handler_path.as_deref() else { + return Ok(()); + }; + if let Some(bare) = path.strip_prefix("script/") { + return Err(windmill_common::error::Error::BadRequest(format!( + "error_handler_path is a plain script path, not the prefixed form a schedule \ + error handler takes: got '{path}', use '{bare}'" + ))); + } + if path.starts_with("flow/") { + return Err(windmill_common::error::Error::BadRequest(format!( + "error_handler_path must be a script: a trigger error handler cannot be a flow \ + (got '{path}')" + ))); + } + Ok(()) + } +} + #[derive(Serialize, Deserialize, Clone)] pub struct Trigger where @@ -253,6 +278,43 @@ mod tests { use super::*; use serde_json::json; + // --- TriggerErrorHandling::validate --- + + fn error_handling(path: Option<&str>) -> TriggerErrorHandling { + TriggerErrorHandling { + error_handler_path: path.map(str::to_string), + error_handler_args: None, + retry: None, + } + } + + #[test] + fn test_error_handler_path_accepts_bare_and_hub_paths() { + for path in [None, Some("f/team/handler"), Some("u/admin/handler")] { + assert!(error_handling(path).validate().is_ok(), "{path:?}"); + } + assert!(error_handling(Some("hub/13953/windmill/handler")) + .validate() + .is_ok()); + } + + #[test] + fn test_error_handler_path_rejects_prefixed_paths() { + // The rejection names the bare path to use, so the caller can fix it + // without knowing which of the two conventions a trigger follows. + let err = error_handling(Some("script/f/team/handler")) + .validate() + .unwrap_err() + .to_string(); + assert!(err.contains("f/team/handler"), "{err}"); + + let err = error_handling(Some("flow/f/team/handler")) + .validate() + .unwrap_err() + .to_string(); + assert!(err.contains("cannot be a flow"), "{err}"); + } + // --- TriggerMode serde --- #[test] diff --git a/backend/windmill-types/src/jobs.rs b/backend/windmill-types/src/jobs.rs index db20bc1251..5190efe380 100644 --- a/backend/windmill-types/src/jobs.rs +++ b/backend/windmill-types/src/jobs.rs @@ -632,6 +632,28 @@ pub enum JobPayload { }, } +impl JobPayload { + /// Whether the payload itself declares a dedicated worker, in which case `push` replaces + /// whatever tag it is handed and the caller's tag never reaches the queue. + /// + /// This reads what the payload carries, not what `push` will conclude: a `SingleStepFlow` + /// loads the flag from the script row at push time and reports `false` here. That only + /// matters to a caller reasoning about the tag, and for those the answer is the same either + /// way, since `push` replaces the tag in exactly the case this misses. + pub fn is_dedicated_worker(&self) -> bool { + let dedicated_worker = match self { + JobPayload::ScriptHash { dedicated_worker, .. } + | JobPayload::FlowScript { dedicated_worker, .. } + | JobPayload::Dependencies { dedicated_worker, .. } + | JobPayload::FlowDependencies { dedicated_worker, .. } + | JobPayload::Flow { dedicated_worker, .. } => dedicated_worker, + JobPayload::Code(raw) => &raw.dedicated_worker, + _ => &None, + }; + dedicated_worker.is_some_and(|x| x) + } +} + #[derive(Clone, Serialize, Deserialize, Debug)] pub struct SkipHandler { pub path: String, diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 1fb566e57f..77221edf22 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -1256,6 +1256,7 @@ pub async fn prebundle_bun_script( token: &str, occupancy_metrics: &mut Option<&mut OccupancyMetrics>, temp_script_refs: &Option>, + modules: Option<&HashMap>, ) -> Result<()> { let (local_path, remote_path) = compute_bundle_local_and_remote_path( inner_content, @@ -1264,6 +1265,7 @@ pub async fn prebundle_bun_script( db, w_id, temp_script_refs, + modules, ) .await; if exists_in_cache(&local_path, &remote_path).await { @@ -1442,6 +1444,7 @@ pub async fn compute_bundle_local_and_remote_path( db: Option<&DB>, w_id: &str, temp_script_refs: &Option>, + modules: Option<&HashMap>, ) -> (String, String) { let mut input_src = format!("{inner_content}{lock}",); @@ -1470,7 +1473,10 @@ pub async fn compute_bundle_local_and_remote_path( let ws_suffix = crate::workspace_registry_cache_suffix(w_id).await; input_src.push_str(&ws_suffix); - let hash = windmill_common::utils::calculate_hash(&input_src); + + // The loader resolves relative imports against the module files in the job dir, so + // their content is inlined into the bundle this name covers. + let hash = crate::worker::artifact_cache_name(input_src, modules); let local_path = format!("{}/{hash}", *BUN_BUNDLE_CACHE_DIR); #[cfg(windows)] @@ -1569,6 +1575,7 @@ pub async fn handle_bun_job( Some(db), &job.workspace_id, &temp_script_refs, + modules.as_ref(), ) .await } @@ -2962,9 +2969,8 @@ pub async fn handle_wac_v2_output( version: flow_info.version, labels: flow_info.labels.clone(), }; - let on_behalf_of = flow_info - .on_behalf_of(&job.workspace_id, db) - .await?; + let on_behalf_of = + flow_info.on_behalf_of(&job.workspace_id, db).await?; let step_args: HashMap> = step .args .iter() @@ -4382,4 +4388,49 @@ export function main(x: number) { return x; }"#; assert!(wrapper.contains(r#"line.startsWith("exec_preprocess:")"#)); assert!(wrapper.contains(r#"line.startsWith("exec:")"#)); } + + /// The bundle cache is global and content-keyed, so a key that ignores the inline + /// modules hands one workspace's bundle — attacker helper code and all — to the next + /// job whose main content and lockfile happen to match. + #[tokio::test] + async fn bundle_cache_key_separates_inline_module_content() { + use windmill_common::scripts::ScriptModule; + + async fn key_for(modules: Option<&HashMap>) -> String { + compute_bundle_local_and_remote_path( + "import { h } from './helper.ts';\nexport async function main() { return h(); }", + "{}\n//bun.lock\n", + "u/alice/script", + None, + "w1", + &None, + modules, + ) + .await + .1 + } + fn modules(content: &str) -> HashMap { + HashMap::from([( + "helper.ts".to_string(), + ScriptModule { + content: content.to_string(), + language: ScriptLang::Bun, + lock: None, + }, + )]) + } + + let attacker = key_for(Some(&modules("export const h = () => 'attacker'"))).await; + let victim = key_for(Some(&modules("export const h = () => 'victim'"))).await; + assert_ne!(attacker, victim); + assert_eq!( + attacker, + key_for(Some(&modules("export const h = () => 'attacker'"))).await, + "same modules must still share a cache slot" + ); + + // An absent map and an empty one are the same script, so they share a slot. + assert_eq!(key_for(None).await, key_for(Some(&HashMap::new())).await); + assert_ne!(key_for(None).await, attacker); + } } diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 6ee22fd882..f6391eab07 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -1640,6 +1640,7 @@ pub(crate) async fn get_workspace_s3_resource_path( ) } Some(LargeFileStorage::FilesystemStorage(fs)) => { + windmill_common::workspaces::ensure_filesystem_storage_allowed()?; return Ok(Some( windmill_object_store::ObjectStoreResource::Filesystem( windmill_object_store::FilesystemSettings { root_path: fs.root_path.clone() }, diff --git a/backend/windmill-worker/src/csharp_executor.rs b/backend/windmill-worker/src/csharp_executor.rs index c95107d504..d393171f7f 100644 --- a/backend/windmill-worker/src/csharp_executor.rs +++ b/backend/windmill-worker/src/csharp_executor.rs @@ -13,7 +13,7 @@ use itertools::Itertools; #[cfg(feature = "csharp")] use tokio::{fs::File, io::AsyncReadExt, process::Command}; #[cfg(feature = "csharp")] -use windmill_common::{utils::calculate_hash, worker::write_file}; +use windmill_common::worker::write_file; #[cfg(feature = "csharp")] use crate::global_cache::save_cache; @@ -72,13 +72,21 @@ const CSHARP_OBJECT_STORE_PREFIX: &str = /// Cache key of a C# build. The run path and the deploy-time prebuild must derive it the /// same way or the prebuilt binary is never found and gets rebuilt on first run. #[cfg(feature = "csharp")] -async fn csharp_cache_key(code: &str, requirements_o: Option<&str>, w_id: &str) -> String { - let mut hash = calculate_hash(&format!( +async fn csharp_cache_key( + code: &str, + requirements_o: Option<&str>, + w_id: &str, + modules: Option<&HashMap>, +) -> String { + // The SDK project globs every `.cs` under the job dir, so companion modules are + // compiled into the binary this key names and have to be part of it. + let base = format!( "{}{}{}", code, requirements_o.unwrap_or(""), DOTNET_TARGET_FRAMEWORK.as_str() - )); + ); + let mut hash = crate::worker::artifact_cache_name(base, modules); hash.push_str(&crate::workspace_registry_cache_suffix(w_id).await); hash } @@ -487,10 +495,11 @@ pub async fn prebuild_csharp_binary( worker_name: &str, base_internal_url: &str, occupancy_metrics: &mut OccupancyMetrics, + modules: Option<&HashMap>, ) -> error::Result> { check_executor_binary_exists("dotnet", DOTNET_PATH.as_str(), "C#")?; - let hash = csharp_cache_key(code, Some(lock), &job.workspace_id).await; + let hash = csharp_cache_key(code, Some(lock), &job.workspace_id, modules).await; let remote_path = format!("{CSHARP_OBJECT_STORE_PREFIX}{hash}"); if crate::global_cache::exists_in_object_store(&remote_path).await { return Ok(None); @@ -549,6 +558,7 @@ pub async fn handle_csharp_job( _worker_name: &str, _envs: HashMap, _occupancy_metrics: &mut OccupancyMetrics, + _modules: Option<&HashMap>, ) -> Result, Error> { Err(anyhow!("C# is not available because the feature is not enabled").into()) } @@ -568,6 +578,7 @@ pub async fn handle_csharp_job( worker_name: &str, envs: HashMap, occupancy_metrics: &mut OccupancyMetrics, + modules: Option<&HashMap>, ) -> Result, Error> { check_executor_binary_exists("dotnet", DOTNET_PATH.as_str(), "C#")?; @@ -575,6 +586,7 @@ pub async fn handle_csharp_job( inner_content, requirements_o.map(|x| x.as_str()), &job.workspace_id, + modules, ) .await; let bin_path = format!("{}/{hash}", *CSHARP_CACHE_DIR); diff --git a/backend/windmill-worker/src/go_executor.rs b/backend/windmill-worker/src/go_executor.rs index 1d2009c5c0..9fe6d4a616 100644 --- a/backend/windmill-worker/src/go_executor.rs +++ b/backend/windmill-worker/src/go_executor.rs @@ -230,8 +230,15 @@ fn go_runtime_int32(v: &str) -> Option { /// Cache key of a Go build. The run path and the deploy-time prebuild must derive it the /// same way or the prebuilt binary is never found and gets rebuilt on first run. -fn go_cache_key(code: &str, maybe_lock: &MaybeLock) -> String { - calculate_hash(&format!("{}{:?}v2", code, maybe_lock)) +fn go_cache_key( + code: &str, + maybe_lock: &MaybeLock, + modules: Option<&HashMap>, +) -> String { + // A module whose path starts with `go/` lands inside the module dir this builds, so + // its content ends up in the binary the key names. + let base = format!("{}{:?}v2", code, maybe_lock); + crate::worker::artifact_cache_name(base, modules) } /// Install the deps, generate the entrypoint wrapper, `go build`, and push the binary to @@ -459,9 +466,10 @@ pub async fn prebuild_go_binary( worker_name: &str, base_internal_url: &str, occupancy_metrics: &mut OccupancyMetrics, + modules: Option<&HashMap>, ) -> Result, Error> { let maybe_lock = MaybeLock::Resolved { lock: lock.to_string() }; - let hash = go_cache_key(code, &maybe_lock); + let hash = go_cache_key(code, &maybe_lock, modules); let remote_path = format!("{GO_OBJECT_STORE_PREFIX}{hash}"); if crate::global_cache::exists_in_object_store(&remote_path).await { return Ok(None); @@ -512,6 +520,7 @@ pub async fn handle_go_job( envs: HashMap, occupation_metrics: &mut OccupancyMetrics, maybe_lock: MaybeLock, + modules: Option<&HashMap>, ) -> Result, Error> { //go does not like executing modules at temp root let job_dir = &format!("{job_dir}/go"); @@ -520,7 +529,7 @@ pub async fn handle_go_job( .create(&job_dir) .expect("could not create go job dir"); - let hash = go_cache_key(inner_content, &maybe_lock); + let hash = go_cache_key(inner_content, &maybe_lock, modules); let bin_path = format!("{}/{hash}", *GO_BIN_CACHE_DIR); let remote_path = format!("{GO_OBJECT_STORE_PREFIX}{hash}"); let (cache, cache_logs) = crate::global_cache::load_cache(&bin_path, &remote_path, false).await; diff --git a/backend/windmill-worker/src/java_executor.rs b/backend/windmill-worker/src/java_executor.rs index bb0518f22d..2e47fced18 100644 --- a/backend/windmill-worker/src/java_executor.rs +++ b/backend/windmill-worker/src/java_executor.rs @@ -66,6 +66,7 @@ pub(crate) struct JobHandlerInput<'a> { pub requirements_o: Option<&'a String>, pub shared_mount: &'a str, pub worker_name: &'a str, + pub modules: Option<&'a HashMap>, } pub async fn handle_java_job<'a>(mut args: JobHandlerInput<'a>) -> Result, Error> { @@ -612,25 +613,33 @@ async fn compile<'a>( inner_content, requirements_o, parent_runnable_path, + modules, .. }: &mut JobHandlerInput<'a>, classpath: &'a str, // plugins: Vec<&'a str>, ) -> Result<(), Error> { - fn compute_hash(code: &str, requirements_o: Option<&String>) -> String { - calculate_hash(&format!( + // The cached artifact is the whole `target/` dir, and companion modules are written + // into the job dir before this runs, so their content can land in it. + fn compute_hash( + code: &str, + requirements_o: Option<&String>, + modules: Option<&HashMap>, + ) -> String { + let base = format!( "{}{}", code, requirements_o .as_ref() .map(|x| x.to_string()) .unwrap_or_default() - )) + ); + crate::worker::artifact_cache_name(base, modules) } let reserved_variables = get_reserved_variables(job, &client.token, conn, parent_runnable_path.clone()).await?; let ws_suffix = crate::workspace_registry_cache_suffix(&job.workspace_id).await; - let mut hash = compute_hash(inner_content, *requirements_o); + let mut hash = compute_hash(inner_content, *requirements_o, *modules); hash.push_str(&ws_suffix); let bin_path = format!("{}/{hash}", *JAVA_CACHE_DIR); let remote_path = format!("java_jar/{hash}"); diff --git a/backend/windmill-worker/src/rust_executor.rs b/backend/windmill-worker/src/rust_executor.rs index b9d53a801b..dff7d58c2d 100644 --- a/backend/windmill-worker/src/rust_executor.rs +++ b/backend/windmill-worker/src/rust_executor.rs @@ -14,7 +14,6 @@ use tokio::{ }; use windmill_common::{ error::{self, Error}, - utils::calculate_hash, worker::{write_file, Connection}, }; use windmill_queue::MiniPulledJob; @@ -602,8 +601,13 @@ pub async fn build_rust_crate( /// Cache key of a Rust build. The run path and the deploy-time prebuild must derive it /// the same way or the prebuilt binary is never found and gets rebuilt on first run. -async fn rust_cache_key(code: &str, requirements_o: Option<&String>, w_id: &str) -> String { - let mut hash = compute_rust_hash(code, requirements_o); +async fn rust_cache_key( + code: &str, + requirements_o: Option<&String>, + w_id: &str, + modules: Option<&HashMap>, +) -> String { + let mut hash = compute_rust_hash(code, requirements_o, modules); hash.push_str(&crate::workspace_registry_cache_suffix(w_id).await); hash } @@ -621,11 +625,12 @@ pub async fn prebuild_rust_binary( worker_name: &str, base_internal_url: &str, occupancy_metrics: &mut OccupancyMetrics, + modules: Option<&HashMap>, ) -> error::Result> { ensure_rust_runtime_dirs(); check_executor_binary_exists("cargo", CARGO_PATH.as_str(), "rust")?; - let hash = rust_cache_key(code, Some(&lock.to_string()), &job.workspace_id).await; + let hash = rust_cache_key(code, Some(&lock.to_string()), &job.workspace_id, modules).await; let remote_path = format!("{RUST_OBJECT_STORE_PREFIX}{hash}"); if crate::global_cache::exists_in_object_store(&remote_path).await { return Ok(None); @@ -652,15 +657,22 @@ pub async fn prebuild_rust_binary( Ok(Some(logs)) } -pub fn compute_rust_hash(code: &str, requirements_o: Option<&String>) -> String { - calculate_hash(&format!( +pub fn compute_rust_hash( + code: &str, + requirements_o: Option<&String>, + // Companion modules are written into the crate dir and compiled into the binary this + // key names, so leaving them out shares one script's binary with another. + modules: Option<&HashMap>, +) -> String { + let base = format!( "{}{}", code, requirements_o .as_ref() .map(|x| x.to_string()) .unwrap_or_default() - )) + ); + crate::worker::artifact_cache_name(base, modules) } #[tracing::instrument(level = "trace", skip_all)] @@ -679,11 +691,12 @@ pub async fn handle_rust_job( worker_name: &str, envs: HashMap, occupancy_metrics: &mut OccupancyMetrics, + modules: Option<&HashMap>, ) -> Result, Error> { ensure_rust_runtime_dirs(); check_executor_binary_exists("cargo", CARGO_PATH.as_str(), "rust")?; - let hash = rust_cache_key(inner_content, requirements_o, &job.workspace_id).await; + let hash = rust_cache_key(inner_content, requirements_o, &job.workspace_id, modules).await; let bin_path = format!("{}/{hash}", *RUST_CACHE_DIR); let remote_path = format!("{RUST_OBJECT_STORE_PREFIX}{hash}"); diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 350f0c5a61..7e620439b0 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -950,6 +950,51 @@ pub async fn workspace_registry_cache_suffix(w_id: &str) -> String { } } +/// The name a build artifact is cached under, derived from `base` — the runnable's own +/// cache-key input — and its inline modules. +/// +/// `write_module_files` puts module content in the job dir where the build inlines it into +/// the artifact, so a name without it serves one runnable's modules to another whose main +/// content and lockfile match — across workspaces, the cache being global. +/// +/// Only the path and content may name the artifact, because they are all the build reads. +/// `ScriptModule::lock` especially must stay out: deploy regenerates it *after* the parent +/// has prebuilt, so naming it would strand every prebuilt artifact. +pub(crate) fn artifact_cache_name( + base: String, + modules: Option<&std::collections::HashMap>, +) -> String { + let Some(modules) = modules.filter(|m| !m.is_empty()) else { + // Byte-identical to the name a module-free runnable had before modules entered this, + // so its cached artifacts stay reachable. A pre-fix multi-file runnable also stored + // here, so one of those stays reachable too — accepted over invalidating every cache, + // and once this ships nothing can be stored here with module content in it again. + return windmill_common::utils::calculate_hash(&base); + }; + let mut entries: Vec<(&String, &ScriptModule)> = modules.iter().collect(); + entries.sort_by(|a, b| a.0.cmp(b.0)); + // `base` ends in caller-supplied bytes (a preview brings its own lockfile), so it is + // sealed to a fixed width before the module block is appended — raw, a crafted lockfile + // could spell out another runnable's block and reach its slot. + let mut keyed = format!( + "{}:modules:{}", + windmill_common::utils::calculate_hash(&base), + entries.len() + ); + for (path, module) in entries { + // Both length-prefixed, else `{"a": "bc"}` and `{"ab": "c"}` encode alike. + keyed.push_str(&format!( + ":{}:{path}:{}:{}", + path.len(), + module.content.len(), + module.content, + )); + } + // Its own namespace: `calculate_hash` emits hex, so however a module-free runnable + // crafts its content and lockfile it can never land on a module-bearing name. + format!("mod-{}", windmill_common::utils::calculate_hash(&keyed)) +} + pub fn is_sandboxing_enabled() -> bool { if !*DISABLE_NSJAIL { return true; @@ -3532,7 +3577,13 @@ pub async fn run_worker( job.kind, JobKind::Script | JobKind::Preview | JobKind::FlowScript ) { - if !dedicated_workers.is_empty() { + // A job carrying a pre-run error never runs its code: it only has to be + // pulled so `handle_queued_job` can fail it. Both hand-off paths below + // dispatch by path and return before that check, so a job sent down them + // would run with whatever arguments survived the failure. + let fails_before_running = job.pre_run_error.is_some(); + + if !dedicated_workers.is_empty() && !fails_before_running { let dedicated_worker_tx = job.runnable_path.as_ref().and_then(|path| { // For flow steps inside branches/loops, runnable_path includes // nesting segments (e.g. f/flow/branchone-0/a) but the dedicated @@ -3577,7 +3628,7 @@ pub async fn run_worker( NextJob::Http(_) => None, }; - if let Some(flow_runners) = flow_runners { + if let Some(flow_runners) = flow_runners.filter(|_| !fails_before_running) { let key_o = job.flow_step_id.as_ref().map(|x| x.to_string()); if let Some(key) = key_o { if let Some(flow_runner_tx) = flow_runners.runners.get(&key) { @@ -5453,7 +5504,9 @@ async fn handle_code_execution_job( None => job, }; - // For preview jobs, extract modules from args._MODULES if not already set + // Any job kind, not just previews: whatever is here is what gets written to the job dir + // and built in, so the agent-worker server precomputing a cache name has to resolve + // modules the same way (`windmill-api-agent-workers`, `get_code_and_lock`). let modules = modules_from_data.clone().or_else(|| { job.args.as_ref().and_then(|args| { args.get("_MODULES").and_then(|raw| { @@ -5646,11 +5699,106 @@ mod write_module_files_tests { use super::*; use std::collections::HashMap; use windmill_common::scripts::ScriptLang; + use windmill_common::utils::calculate_hash; fn module(content: &str) -> ScriptModule { ScriptModule { content: content.to_string(), language: ScriptLang::Python3, lock: None } } + /// Every language's artifact cache name funnels module content through this, so an + /// ambiguous encoding puts two different runnables back on one name. + #[test] + fn artifact_name_cannot_be_re_cut_into_another_module_map() { + fn name(entries: &[(&str, &str)]) -> String { + let map: HashMap = entries + .iter() + .map(|(p, c)| (p.to_string(), module(c))) + .collect(); + artifact_cache_name("base".to_string(), Some(&map)) + } + + // Naive `path + content` concatenation renders both of these as "abc". + assert_ne!(name(&[("a", "bc")]), name(&[("ab", "c")])); + // Splitting one module into two must not read back as the joined one. + assert_ne!(name(&[("a", "b"), ("c", "d")]), name(&[("ac", "bd")])); + // Iteration order of the map must not move the name. + assert_eq!( + name(&[("a", "1"), ("b", "2")]), + name(&[("b", "2"), ("a", "1")]) + ); + } + + /// A module-free runnable must keep the exact name it had before modules entered the + /// derivation, or upgrading strands every artifact already in the cache. + #[test] + fn artifact_name_is_unchanged_without_modules() { + assert_eq!( + artifact_cache_name("code+lock".to_string(), None), + calculate_hash("code+lock") + ); + assert_eq!( + artifact_cache_name("code+lock".to_string(), Some(&HashMap::new())), + calculate_hash("code+lock") + ); + } + + /// A preview brings its own source and lockfile, so a module-free runnable picks its + /// whole `base`. Module-bearing names live in their own namespace precisely so that no + /// crafted `base` can be made to land on one. + #[test] + fn a_module_free_runnable_cannot_forge_a_module_bearing_name() { + let modules = HashMap::from([("h.ts".to_string(), module("evil"))]); + let victim = artifact_cache_name("code+lock".to_string(), Some(&modules)); + + // `calculate_hash` emits hex, so the namespace is unreachable however `base` is + // chosen — including by feeding it the victim's own name. + assert!(victim.starts_with("mod-")); + assert_ne!(artifact_cache_name(victim.clone(), None), victim); + assert!(!artifact_cache_name("anything".to_string(), None).starts_with("mod-")); + } + + /// The `mod-` namespace separates module-free from module-bearing, and nothing separates + /// two module-bearing runnables — only the seal does. Unsealed, `base` is variable-width, + /// so the split between it and the module block is ambiguous and a preview (which brings + /// its own source *and* lockfile) can absorb part of another runnable's block. + #[test] + fn a_module_bearing_runnable_cannot_absorb_another_ones_block() { + let victim = artifact_cache_name( + "V".to_string(), + Some(&HashMap::from([( + "h.ts".to_string(), + module(":modules:1:1:a:1:b"), + )])), + ); + // Byte-identical to the victim's without the seal: the forger's `base` spells out the + // victim's leading block, leaving its own single module to supply the tail. + let forged = artifact_cache_name( + "V:modules:1:4:h.ts:18:".to_string(), + Some(&HashMap::from([("a".to_string(), module("b"))])), + ); + + assert_ne!(victim, forged); + } + + /// Deploy fills a module's lock in after the parent has prebuilt, so a name that moved + /// with it would leave every prebuilt artifact unreachable by the runs it was built for. + #[test] + fn artifact_name_ignores_the_lock_deploy_fills_in_later() { + let prebuild = artifact_cache_name( + "base".to_string(), + Some(&HashMap::from([("h.ts".to_string(), module("x"))])), + ); + + let mut locked = module("x"); + locked.lock = Some("{}\n//bun.lock\n".to_string()); + let after_deploy = artifact_cache_name( + "base".to_string(), + Some(&HashMap::from([("h.ts".to_string(), locked)])), + ); + + assert_eq!(prebuild, after_deploy); + } + #[test] fn contained_relative_path_rejects_traversal_and_absolute() { assert!(is_contained_relative_path("u/admin/pkg")); @@ -6380,6 +6528,7 @@ mount {{ envs, occupancy_metrics, maybe_lock, + modules.as_ref(), )) .await } @@ -6509,6 +6658,7 @@ mount {{ worker_name, envs, occupancy_metrics, + modules.as_ref(), )) .await } @@ -6567,6 +6717,7 @@ mount {{ worker_name, envs, occupancy_metrics, + modules.as_ref(), )) .await } @@ -6631,6 +6782,7 @@ mount {{ worker_name, envs, occupancy_metrics, + modules: modules.as_ref(), })) .await } diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index bd39afa8c7..34f46307cf 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -70,9 +70,9 @@ use windmill_queue::schedule::get_schedule_opt; use windmill_queue::{ add_completed_job, add_completed_job_error, append_logs, get_mini_pulled_job, insert_concurrency_key_capped, interpolate_args, - report_error_to_workspace_handler_or_critical_side_channel, try_schedule_next_job, CanceledBy, - FlowRunners, MiniCompletedJob, MiniPulledJob, PushArgs, PushIsolationLevel, SameWorkerPayload, - WrappedError, + report_error_to_workspace_handler_or_critical_side_channel, tag_reads_args, + try_schedule_next_job, CanceledBy, FlowRunners, MiniCompletedJob, MiniPulledJob, PushArgs, + PushIsolationLevel, SameWorkerPayload, WrappedError, }; use windmill_audit::audit_oss::audit_log; @@ -4403,6 +4403,23 @@ async fn push_next_flow_job( payload_tag.tag.as_deref(), ); + // `push_args` is empty once the input transforms failed, so a tag reading `$args[...]` + // interpolates to a queue nobody serves and the step sits there instead of reporting + // the error. Send it to the flow's tag, which a worker is provably serving right now. + // + // A step handed over by id, or one whose tag `push` replaces, never reaches a worker + // through its tag, so rewriting theirs would be noise. + let step_is_pulled_by_tag = !continue_on_same_worker + && !continue_with_runners + && !payload_tag.payload.is_dedicated_worker(); + let reroute_to_flow_tag = + err.is_some() && step_is_pulled_by_tag && tag.as_deref().is_some_and(tag_reads_args); + let tag = if reroute_to_flow_tag { + Some(flow_job.tag.clone()) + } else { + tag + }; + let (email, permissioned_as) = if let Some(on_behalf_of) = payload_tag.on_behalf_of.as_ref() { (&on_behalf_of.email, on_behalf_of.permissioned_as.clone()) @@ -4421,8 +4438,7 @@ async fn push_next_flow_job( .as_deref() .filter(|t| !t.is_empty() && *t != flow_job.tag.as_str()) { - let is_super_admin = - windmill_common::auth::is_super_admin_email(db, email).await?; + let is_super_admin = windmill_common::auth::is_super_admin_email(db, email).await?; check_tag_available_for_workspace_internal( db, &flow_job.workspace_id, @@ -6155,9 +6171,7 @@ pub async fn script_to_payload( .await? .prefetch_cached(&db) .await?; - let on_behalf_of = script_info - .on_behalf_of(&flow_job.workspace_id, db) - .await?; + let on_behalf_of = script_info.on_behalf_of(&flow_job.workspace_id, db).await?; let ScriptHashInfo { tag, cache_ttl, diff --git a/backend/windmill-worker/src/worker_lockfiles.rs b/backend/windmill-worker/src/worker_lockfiles.rs index 5cfb9f08ce..57b859075a 100644 --- a/backend/windmill-worker/src/worker_lockfiles.rs +++ b/backend/windmill-worker/src/worker_lockfiles.rs @@ -199,6 +199,7 @@ async fn handle_build_binary_job( worker_name, base_internal_url, occupancy_metrics, + script_data.modules.as_ref(), ) .await? } @@ -214,6 +215,7 @@ async fn handle_build_binary_job( worker_name, base_internal_url, occupancy_metrics, + script_data.modules.as_ref(), ) .await? } @@ -235,6 +237,7 @@ async fn handle_build_binary_job( worker_name, base_internal_url, occupancy_metrics, + script_data.modules.as_ref(), ) .await? } @@ -3155,7 +3158,11 @@ async fn capture_dependency_job( ) .await? { - if !wd_exist { + // Nothing here writes the module files, so a bundle built now resolves a + // multi-file script's relative imports remotely rather than from its + // modules; caching that under a key naming them would serve the wrong + // code to every run. Leave it to the first run instead. + if !wd_exist && modules.map_or(true, |m| m.is_empty()) { crate::bun_executor::prebundle_bun_script( job_raw_code, &lock, @@ -3169,6 +3176,7 @@ async fn capture_dependency_job( &token, &mut Some(occupancy_metrics), temp_script_refs, + modules, ) .await?; } diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 55734e52e8..b4f7ce465c 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.796.0"; +export const VERSION = "v1.799.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/commands/app/bundle.ts b/cli/src/commands/app/bundle.ts index 0ebbdaa076..286e878cfb 100644 --- a/cli/src/commands/app/bundle.ts +++ b/cli/src/commands/app/bundle.ts @@ -191,7 +191,14 @@ function createSveltePlugin(appDir: string): any { // Convert Svelte syntax to JavaScript try { - const { js, warnings } = svelte.compile(source, { filename }); + // The raw-app editor's in-browser bundler compiles with + // `css: "injected"`, so this must too, or the same app renders + // styled there and unstyled once the CLI builds it: Svelte's default + // ("external") hands the +`, + "styles_entry.ts": `import Styled from './Styled.svelte'; +export default Styled; +`, + }); + + const js = await bundle("styles_entry.ts"); + + const scopeClass = js.match(/

{ this.inspectorWs = new WebSocket(wsUrl) + // A close before the script is running is a failed connection, not a finished script, + // and the two are reported to the client in opposite ways. The socket opening is not + // the line: the setup commands below run over an open socket and none of them reject + // when it drops (sendInspectorCommand only has its own timer), so a drop mid-setup + // would otherwise be indistinguishable from a clean exit. + let opened = false + let executionStarted = false + let handshakeError: string | null = null + const timeout = setTimeout(() => { reject(new Error('Inspector connection timeout')) }, 5000) this.inspectorWs.onopen = async () => { clearTimeout(timeout) + opened = true logger.info('Connected to inspector') try { @@ -2030,6 +2052,11 @@ export class DebugSession { logger.info('Starting script execution with Inspector.initialized...') await this.sendInspectorCommand('Inspector.initialized', {}) + // Only past its reply is a later close a finished script rather than a lost + // connection. The reply precedes any close on this socket, so the continuation + // runs first and a real run is never misread as a failure. + executionStarted = true + resolve() } catch (error) { reject(error) @@ -2042,12 +2069,25 @@ export class DebugSession { this.inspectorWs.onerror = (error) => { logger.error('Inspector WebSocket error:', error) + if (!opened) { + handshakeError = (error as ErrorEvent)?.message || String(error) + } } this.inspectorWs.onclose = () => { logger.info('Inspector WebSocket closed') this.inspectorWs = null + if (!executionStarted) { + clearTimeout(timeout) + reject( + new Error( + `Inspector connection failed: ${handshakeError ?? (opened ? 'closed before setup completed' : 'closed before the handshake completed')}` + ) + ) + return + } + // When inspector closes, the script has ended - send terminated event if (!this.terminatedSent) { this.terminatedSent = true diff --git a/docker-compose.yml b/docker-compose.yml index a801a0ce7c..bd1e060f22 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,15 +8,28 @@ x-logging: &default-logging compress: "true" services: + ## UPGRADING FROM POSTGRES 16: db_data holds a cluster 18 cannot read, so the + ## container exits with an explanatory error rather than coming up blank. Migrating + ## means dumping the WHOLE cluster (pg_dumpall), never just the windmill database: + ## Windmill keeps datatable, DuckLake and wm_fork_* databases beside it and grants + ## its RLS policies to cluster-level roles, and a single-database dump loses both + ## silently. Full procedure, and why 16 is still a valid choice until Nov 2028: + ## https://www.windmill.dev/docs/advanced/self_host#upgrade-postgresql-to-18 db: deploy: # To use an external database, set replicas to 0 and set DATABASE_URL to the external database url in the .env file replicas: 1 - image: postgres:16 + image: postgres:18 shm_size: 1g restart: unless-stopped volumes: - - db_data:/var/lib/postgresql/data + # From 18 on the official image keeps the cluster in a major-version + # subdirectory (/var/lib/postgresql/18/docker), so the mount has to be the + # parent directory: that is what lets pg_upgrade see an old and a new + # cluster inside a single mount point. Mounting the pre-18 .../data path + # instead makes the image exit rather than start, which is what turns a + # stale 16 cluster into a loud failure instead of an empty instance. + - db_data:/var/lib/postgresql expose: - 5432 environment: diff --git a/docker/test_windmill_extra.ts b/docker/test_windmill_extra.ts index 46ffce54c9..5fd1a74108 100644 --- a/docker/test_windmill_extra.ts +++ b/docker/test_windmill_extra.ts @@ -206,7 +206,11 @@ class DAPTestClient { private events: DAPMessage[] = [] private output: string[] = [] private result: unknown = undefined - private eventHandlers = new Map void)[]>() + private eventWaiters = new Map void)[]>() + // An event that arrives before its waiter is registered is queued rather than dropped: the + // server sends 'initialized' right behind the 'initialize' response, and 'terminated' can + // land before the launch call the test awaits has even returned. + private bufferedEvents = new Map() async connect(endpoint: string): Promise { const url = `ws://${HOST}:${DEBUGGER_PORT}${endpoint}` @@ -273,9 +277,13 @@ class DAPTestClient { this.result = msg.body.result } - const handlers = this.eventHandlers.get(msg.event!) || [] - for (const handler of handlers) { - handler(msg) + const waiters = this.eventWaiters.get(msg.event!) + if (waiters && waiters.length > 0) { + waiters.shift()!(msg) + } else { + const buffered = this.bufferedEvents.get(msg.event!) || [] + buffered.push(msg) + this.bufferedEvents.set(msg.event!, buffered) } } } catch { @@ -313,23 +321,27 @@ class DAPTestClient { } waitForEvent(eventName: string, timeout = 10000): Promise { + const buffered = this.bufferedEvents.get(eventName) + if (buffered && buffered.length > 0) { + return Promise.resolve(buffered.shift()!) + } + return new Promise((resolve, reject) => { + const waiters = this.eventWaiters.get(eventName) || [] + this.eventWaiters.set(eventName, waiters) + const timer = setTimeout(() => { + const idx = waiters.indexOf(handler) + if (idx >= 0) waiters.splice(idx, 1) reject(new Error(`Timeout waiting for event: ${eventName}`)) }, timeout) const handler = (event: DAPMessage) => { clearTimeout(timer) - const handlers = this.eventHandlers.get(eventName) || [] - const idx = handlers.indexOf(handler) - if (idx >= 0) handlers.splice(idx, 1) resolve(event) } - if (!this.eventHandlers.has(eventName)) { - this.eventHandlers.set(eventName, []) - } - this.eventHandlers.get(eventName)!.push(handler) + waiters.push(handler) }) } @@ -379,6 +391,7 @@ class DAPTestClient { this.output = [] this.result = undefined this.events = [] + this.bufferedEvents.clear() } } diff --git a/docs/feature-telemetry.md b/docs/feature-telemetry.md index 57467e25ec..5d7f0b2c29 100644 --- a/docs/feature-telemetry.md +++ b/docs/feature-telemetry.md @@ -4,10 +4,10 @@ anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick" without any identifying data leaving the instance. -It currently carries 21 registered actions across nine features (`ai_session`, `ai_chat`, -`flow_editor`, `flow_run`, `flow_step`, `trigger`, `command_script`, `hub_script`, -`usage_meter`). Nearly all of the product is uninstrumented, so new user-facing work is the -opportunity to change that. +It currently carries 28 registered actions across fourteen features (`ai_session`, `ai_chat`, +`ai_fix`, `ai_agent`, `ai_agent_eval`, `flow_editor`, `flow_run`, `flow_step`, `run_form`, +`debugger`, `trigger`, `command_script`, `hub_script`, `usage_meter`). Nearly all of the +product is uninstrumented, so new user-facing work is the opportunity to change that. ## When to instrument diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6d72964274..e6549e22ef 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.796.0", + "version": "1.799.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.796.0", + "version": "1.799.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -29,6 +29,7 @@ "@windmill-labs/svelte-dnd-action": "^0.9.44", "@xterm/addon-fit": "^0.10.0", "@xyflow/svelte": "^1.0.0", + "acorn": "^8.15.0", "ag-charts-community": "^9.0.1", "ag-charts-enterprise": "^9.0.1", "ag-grid-community": "^31.3.4", @@ -13195,9 +13196,9 @@ } }, "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "optional": true, diff --git a/frontend/package.json b/frontend/package.json index 1d984c8587..2fe0532761 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.796.0", + "version": "1.799.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", @@ -105,6 +105,7 @@ "@windmill-labs/svelte-dnd-action": "^0.9.44", "@xterm/addon-fit": "^0.10.0", "@xyflow/svelte": "^1.0.0", + "acorn": "^8.15.0", "ag-charts-community": "^9.0.1", "ag-charts-enterprise": "^9.0.1", "ag-grid-community": "^31.3.4", diff --git a/frontend/src/lib/components/AppConnectDrawer.svelte b/frontend/src/lib/components/AppConnectDrawer.svelte index 67c19b0fe7..8de484929e 100644 --- a/frontend/src/lib/components/AppConnectDrawer.svelte +++ b/frontend/src/lib/components/AppConnectDrawer.svelte @@ -6,7 +6,7 @@ import DrawerContent from './common/drawer/DrawerContent.svelte' import AppConnectInner from './AppConnectInner.svelte' - import DarkModeObserver from './DarkModeObserver.svelte' + import GoogleSigninButton from './GoogleSigninButton.svelte' import IconedResourceType from './IconedResourceType.svelte' import { addResourceTitle } from './resourceTypeDisplay' @@ -22,6 +22,10 @@ disableChatOffset = false }: Props = $props() + /** Set by `open(rt, fillPath)`, not by the parent: which resource this run fills is a + * property of the click, and a prop would go stale between two different rows. */ + let fillPath: string | undefined = $state(undefined) + let drawer: Drawer | undefined = $state() let resourceType = $state('') let step = $state(1) @@ -32,29 +36,46 @@ let appConnectInner: AppConnectInner | undefined = $state(undefined) let rtToLoad: string | undefined = $state('') - export async function open(rt?: string) { + /** `fill` connects into a resource that already exists, instead of creating one. */ + export async function open(rt?: string, fill?: string) { + fillPath = fill + handedOff = false rtToLoad = rt drawer?.openDrawer?.() } + /** + * Hand off to the inner component exactly once per opening. The reactive statement below + * re-runs both when `rtToLoad` changes and when `appConnectInner` binds — and it binds + * afresh on every opening, since the drawer destroys its content on close. A second + * `open()` runs `next()` a second time, which walks a drawer opened on a resource type + * straight past the Connect button and into `window.open`; a popup opened from a reactive + * effect rather than from the click is blocked, leaving "Finish connection in popup + * window" with no popup behind it. + * + * A flag rather than the last resource type: `open()` with no argument leaves `rtToLoad` + * undefined, which compares equal to the initial state and would skip the hand-off + * entirely — the resources page opens it that way. + */ + let handedOff = false function onRtToLoadChange(rtToLoad: string | undefined) { + if (handedOff) return + handedOff = true appConnectInner?.open(rtToLoad) } const dispatch = createEventDispatcher() - let darkMode: boolean = $state(false) run(() => { appConnectInner && onRtToLoadChange(rtToLoad) }) - - { step = 1 + handedOff = false dispatch('close') }} size="700px" @@ -83,22 +104,26 @@ on:refresh express={expressOAuthSetup} {workspace} + {fillPath} /> {#snippet actions()}
- {#if step > 1} - + + {#if step > 1 && !rtToLoad} + {/if} {#if isGoogleSignin} - + appConnectInner?.next()} /> {:else} -
{:else} -
+ +
{#if step > 2} - + {/if} - + {#if isGoogleSignin} + appConnect?.next()} /> + {:else} + + {/if}
{/if} @@ -64,6 +72,7 @@ bind:resourceType bind:disabled bind:manual + bind:isGoogleSignin on:error on:refresh /> diff --git a/frontend/src/lib/components/CenteredModal.svelte b/frontend/src/lib/components/CenteredModal.svelte index e3f8ea1604..3250b3a16a 100644 --- a/frontend/src/lib/components/CenteredModal.svelte +++ b/frontend/src/lib/components/CenteredModal.svelte @@ -6,6 +6,9 @@ interface Props { subtitle?: string | undefined + /** Rendered under the title, for a subtitle that needs markup (a link, say). + * Sits below `subtitle` when both are given. */ + subtitleSnippet?: import('svelte').Snippet title?: string large?: boolean centerVertically?: boolean @@ -16,6 +19,7 @@ let { subtitle = undefined, + subtitleSnippet = undefined, title = 'Windmill', large = false, centerVertically = true, @@ -60,6 +64,9 @@ {subtitle}

{/if} + {#if subtitleSnippet} +
{@render subtitleSnippet()}
+ {/if}
{#if children} diff --git a/frontend/src/lib/components/DatatableSchemaDiff.svelte b/frontend/src/lib/components/DatatableSchemaDiff.svelte index 579b7a1923..1fb6918580 100644 --- a/frontend/src/lib/components/DatatableSchemaDiff.svelte +++ b/frontend/src/lib/components/DatatableSchemaDiff.svelte @@ -48,6 +48,9 @@ // migrations, its changes flow through the normal item diff instead, so it is // excluded here and the whole section hides once none remain. let applicableCount = $state(0) + let applicableLabel = $derived( + applicableCount === 1 ? 'this data table has' : 'these data tables have' + ) let expandedDatatables: Set = $state(new Set()) // Drawer state @@ -293,6 +296,12 @@ {#if applicableCount > 0}

Datatable schema changes

+ + This section diffs data table schemas directly because {applicableLabel} not opted in to migrations. + With migrations enabled, schema changes are tracked as migrations and deployed like any other item. + Opt in from the parent workspace's data table settings — a fork only inherits the flag when it + is created, so this fork keeps using the legacy diff. + {#if loading}
Loading datatable diffs... diff --git a/frontend/src/lib/components/Dev.svelte b/frontend/src/lib/components/Dev.svelte index 54a68dd5a3..c7e83b8a80 100644 --- a/frontend/src/lib/components/Dev.svelte +++ b/frontend/src/lib/components/Dev.svelte @@ -1,6 +1,7 @@ diff --git a/frontend/src/lib/components/GitHubAppIntegration.svelte b/frontend/src/lib/components/GitHubAppIntegration.svelte index 7aa136b072..11844d7d9c 100644 --- a/frontend/src/lib/components/GitHubAppIntegration.svelte +++ b/frontend/src/lib/components/GitHubAppIntegration.svelte @@ -55,6 +55,19 @@ ) ) + // Org names that appear on more than one installation, so the dropdown can + // tell those entries apart. + let duplicatedAccountIds = $derived( + new Set( + githubState.workspaceGithubInstallations + .filter( + (installation, _, array) => + array.filter((other) => other.account_id === installation.account_id).length > 1 + ) + .map((installation) => installation.account_id) + ) + ) + let showGitHubApp = $derived( resourceType === 'git_repository' && $workspaceStore && @@ -205,29 +218,47 @@

GitHub Account ID

- (githubState.selectedGHAppRepository = undefined)} + > + {#each githubState.workspaceGithubInstallations as installation (`select-${installation.installation_id}-${installation.workspace_id}`)} - {/each}
- {#if githubState.selectedGHAppAccountId} + {#if githubState.selectedGHAppInstallationId !== undefined} {@const selectedInstallation = githubState.workspaceGithubInstallations.find( - (inst) => inst.account_id === githubState.selectedGHAppAccountId + (inst) => inst.installation_id === githubState.selectedGHAppInstallationId )} {#if selectedInstallation}

Repository

- + + {#key selectedInstallation.installation_id} + + {/key}
{/if} {/if} diff --git a/frontend/src/lib/components/GoogleSigninButton.svelte b/frontend/src/lib/components/GoogleSigninButton.svelte new file mode 100644 index 0000000000..d7d2ab102c --- /dev/null +++ b/frontend/src/lib/components/GoogleSigninButton.svelte @@ -0,0 +1,21 @@ + + + + diff --git a/frontend/src/lib/components/ImportProjectCard.svelte b/frontend/src/lib/components/ImportProjectCard.svelte new file mode 100644 index 0000000000..26bb3f7b18 --- /dev/null +++ b/frontend/src/lib/components/ImportProjectCard.svelte @@ -0,0 +1,115 @@ + + + + +
+ +
+
+ +
+ {#if project.logoUrl} + + {:else if icons[0]} + {@const Icon = icons[0]} + + + + {:else} + + {/if} +
+ +
+ + + {project.name} + + +

{project.summary}

+

+ by {project.author} + · {project.slug} +

+ + +
+ +
+
+ + + {#if restIcons.length > 0} +
+ {#each restIcons as Icon, i (i)} + + + + {/each} +
+ {/if} +
+
+ + +
diff --git a/frontend/src/lib/components/ImportProjectStep.svelte b/frontend/src/lib/components/ImportProjectStep.svelte new file mode 100644 index 0000000000..04c169d41c --- /dev/null +++ b/frontend/src/lib/components/ImportProjectStep.svelte @@ -0,0 +1,524 @@ + + +
+ + {#if existingWorkspace} +
+ + + Folder inside {existingWorkspace} + + +

+ Everything the project ships is imported into this folder. +

+ + + {#if folder.trim() && !folderValid} +

Letters, digits, dashes and underscores only.

+ {/if} +
+ {/if} + + +

What this will do

+ + + + + {#if execution?.error} + + {execution.error} + + {/if} + + + + Resources are imported as empty stubs — set their values after import; one whose path is + already in the workspace is left exactly as it is and reported as already there, so a value + you have since filled in is never overwritten. Trigger kinds are + recreated disabled, except GCP and Azure triggers, which manage cloud subscriptions at creation + and must be re-created manually after filling their resource. Kafka, NATS, SQS, GCP and Azure + triggers all require Enterprise. Triggers that reference a resource depend on stubs imported + empty, so fill in the resource value before re-enabling the trigger. + + +
+ + {#if !execution?.done} + + {:else} + + {/if} + +
+ + {#if execution?.createdWorkspace && !execution.done} + + {/if} + + {#if execution?.done} + + {#if execution.error} + + {/if} + + + {:else} + + {/if} +
+
+
+ + + + + + + closeMigrationReview(false)}> + closeMigrationReview(false)}> +
+ + + {reviewList.length === 1 ? 'This data table is' : 'These data tables are'} already set up{existingWorkspace + ? ` in ${existingWorkspace}` + : ''} and may already hold data. These migrations were written to create the project's tables, + so running them here can alter or drop what is in them. Read the SQL before you run it, and skip + anything you are unsure of. + +

+ Review and edit the SQL, then choose which to run. A migration runs against the data table + of the same name in the destination workspace; if that data table has migrations enabled it + is recorded, otherwise it runs once as a preview job. +

+ {#each reviewList as m (m.datatable_name)} +
+
+ {m.datatable_name} + +
+ {#if m.run} + + {/if} +
+ {/each} +
+ {#snippet actions()} + + + {/snippet} +
+
diff --git a/frontend/src/lib/components/ImportSetupRow.svelte b/frontend/src/lib/components/ImportSetupRow.svelte new file mode 100644 index 0000000000..11f95b333c --- /dev/null +++ b/frontend/src/lib/components/ImportSetupRow.svelte @@ -0,0 +1,48 @@ + + + +
  • +
    +
    {@render icon()}
    + +
    + {@render title()} + {@render detail?.()} +
    + +
    + {@render action()} + {#if flash} +
    + +
    + {/if} +
    +
    + {@render extra?.()} +
  • diff --git a/frontend/src/lib/components/ImportSetupStep.svelte b/frontend/src/lib/components/ImportSetupStep.svelte new file mode 100644 index 0000000000..86670b0817 --- /dev/null +++ b/frontend/src/lib/components/ImportSetupStep.svelte @@ -0,0 +1,861 @@ + + +
    +
    +

    Finish setting up

    + +

    + Your project is imported. For its apps and flows to actually run, they need a place to store + data and credentials for the services they use — the import can't supply those for you. +

    +
    + + {#if loading} +
    + Checking what this project needs… +
    + {:else if loadError} + + {loadError}. You can finish and configure them later in Workspace settings → Data tables. + + {:else} + {#if rows.length > 0} + +
    + + Data table{rows.length === 1 ? '' : 's'} to set up ({rows.length}) + +

    + Where apps and flows keep the data they read and write. +

    +
    + {/if} +
      + {#each rows as row (row.name)} + {@const sql = row.migrations + .map((m) => m.sql) + .filter(Boolean) + .join('\n\n')} + {@const hasTable = configuredNames.some((c) => c.name === row.name)} + + {#snippet icon()} + {#if row.status === 'done'} + + {:else if row.status === 'running'} + + {:else if row.status === 'failed'} + + {:else if row.status === 'unknown'} + + {:else} + + {/if} + {/snippet} + {#snippet title()} + {row.name} + {/snippet} + {#snippet detail()} + + {#if row.status === 'done'} + {row.migrations.length} migration{row.migrations.length === 1 ? '' : 's'} run + {:else if row.status === 'running'} + running migrations… + {:else if row.status === 'failed'} + {row.error} + {:else if row.status === 'unknown'} + set up, but its tables could not be read — the database may be unreachable + {:else} + not configured yet + {/if} + + {/snippet} + {#snippet extra()} + + {#if sql && row.status !== 'done'} +
      + + {row.status === 'unknown' + ? 'Show the SQL this project ships' + : 'Show the SQL this will run'} + +
      {sql}
      +
      + {/if} + {/snippet} + {#snippet action()} + + {#if row.status === 'unknown'} + + + {:else if hasTable && row.status !== 'done' && row.status !== 'running'} + + + {:else} + + + {/if} + {/snippet} +
      + {/each} +
    + + {#if blanks.length > 0} +
    + Credentials to fill ({blanks.length}) +
      + {#each blanks as b (b.path)} + {@const blocked = !!b.occupiedBy || !!b.unreadable} + {@const canConnect = !b.done && !blocked && canConnectType(b.resourceType)} + + + {#snippet icon()} + {#if b.done} + + {:else if blocked} + + {:else} + + {/if} + {/snippet} + {#snippet title()} +
      + + {resourceTypeDisplayName(b.resourceType)} + + + {b.path} + +
      + {/snippet} + {#snippet detail()} + {#if b.occupiedBy} + + a {resourceTypeDisplayName(b.occupiedBy)} resource already holds this path — the + project did not get this one + + {:else if b.unreadable} + + could not be read, so whether it needs filling is unknown + + {:else if !b.done && b.missing.length > 0} + + Missing {b.missing.join(', ')} + + {/if} + {/snippet} + {#snippet action()} + + {#if blocked} + + + {b.occupiedBy ? 'Resolve in the workspace' : 'Check the workspace'} + + {:else} + + {/if} + {/snippet} +
      + {/each} +
    +
    + {/if} + + + {#if outstanding === 0} + + Everything this project needs is configured. Finish, and it is ready to run. + + {:else if pendingTables.length > 0} + 0 + ? 'The project will not run without this' + : 'This could not be checked'} + size="xs" + > + {#if missingTables.length > 0} + The tables {missingTables.length === 1 ? 'this data table holds' : 'these data tables hold'} + do not exist, and the project's apps and flows read them. Every one of those fails as soon + as it opens. + {/if} + {#if uncheckedTables.length > 0} + {#if missingTables.length > 0}

    {/if} + {uncheckedTables.length === 1 ? 'One data table is' : 'Some data tables are'} set up, but + {uncheckedTables.length === 1 ? 'its' : 'their'} schema could not be read, so whether the + project's tables are there is unknown. Check again once the database is reachable. + {/if} +
    + {:else} + + The project's apps and flows will fail wherever they read a credential that is still + missing. Everything else it imported works either way, and you can fill these in from the + workspace at any time. + + {/if} + {/if} + +
    + {#if onBack} + + {:else} + + {/if} +
    + + {#if outstanding > 0 && !loading && !loadError} + + {/if} + +
    +
    +
    + +{#if wizardOpen || wizardFor} + runMigrationsFor(retryTarget ?? '')} + existingNames={configuredNames.map((c) => c.name)} + existingDataTables={configuredNames} + onDone={() => void afterWizard()} + {customInstanceDbs} + {confirmationModal} + {defaultInstanceDbName} + /> +{/if} + + + + + + + void refreshBlanks()} + onRestored={() => void refreshBlanks()} +/> + + + void refreshBlanks()} /> diff --git a/frontend/src/lib/components/ImportWizardSteps.svelte b/frontend/src/lib/components/ImportWizardSteps.svelte new file mode 100644 index 0000000000..5712ede180 --- /dev/null +++ b/frontend/src/lib/components/ImportWizardSteps.svelte @@ -0,0 +1,71 @@ + + + + +
    + + onStepClick(e.detail.index)} + /> +
    diff --git a/frontend/src/lib/components/InputTransformForm.svelte b/frontend/src/lib/components/InputTransformForm.svelte index 630d052ce4..d766c06603 100644 --- a/frontend/src/lib/components/InputTransformForm.svelte +++ b/frontend/src/lib/components/InputTransformForm.svelte @@ -30,6 +30,7 @@ import type { InputTransform } from '$lib/gen' import TemplateEditor from './TemplateEditor.svelte' import { setInputCat as computeInputCat, isCodeInjection } from '$lib/utils' + import { escapeTemplateBackticks } from '$lib/utils/templateLiteral' import { FunctionSquare, InfoIcon } from 'lucide-svelte' import { getResourceTypes } from './resourceTypesStore' import type { FlowCopilotContext } from './copilot/flow' @@ -253,7 +254,7 @@ arg.expr = getDefaultExpr( argName, previousModuleId, - `\`${rawValue.toString().replaceAll('`', '\\`')}\`` + `\`${escapeTemplateBackticks(rawValue.toString())}\`` ) arg.type = 'javascript' propertyType = 'static' @@ -687,7 +688,7 @@ argName, previousModuleId, staticTemplate - ? `\`${arg?.value?.toString().replaceAll('`', '\\`') ?? ''}\`` + ? `\`${escapeTemplateBackticks(arg?.value?.toString() ?? '')}\`` : arg.value ? '(' + JSON.stringify(arg?.value, null, 4) + ')' : '' diff --git a/frontend/src/lib/components/InputTransformSchemaForm.svelte b/frontend/src/lib/components/InputTransformSchemaForm.svelte index 034f891116..d0d29b3111 100644 --- a/frontend/src/lib/components/InputTransformSchemaForm.svelte +++ b/frontend/src/lib/components/InputTransformSchemaForm.svelte @@ -1,9 +1,10 @@ + + + +
    + {#each shown as c (c.label)} + + {c.count} + {c.label}{c.count === 1 ? '' : 's'} + + {/each} +
    diff --git a/frontend/src/lib/components/RepositorySelector.svelte b/frontend/src/lib/components/RepositorySelector.svelte index cfeaa3945d..30f8361cbb 100644 --- a/frontend/src/lib/components/RepositorySelector.svelte +++ b/frontend/src/lib/components/RepositorySelector.svelte @@ -11,7 +11,7 @@ interface Props { disabled?: boolean selectedRepository?: string | undefined - accountId: string + installationId: number initialRepositories: Repository[] totalCount: number perPage: number @@ -23,7 +23,7 @@ let { disabled = false, selectedRepository = $bindable(), - accountId, + installationId, initialRepositories, totalCount, perPage, @@ -67,8 +67,8 @@ page: nextPage }) - // Find the matching installation and get its repositories - const installation = installations.find((inst) => inst.account_id === accountId) + // Match on installation_id: several installations can share one account_id + const installation = installations.find((inst) => inst.installation_id === installationId) if (installation?.repositories) { // Append new repos to existing ones @@ -95,8 +95,8 @@ }))} placeholder="Select repository..." clearable - disabled={disabled} - bind:filterText={filterText} + {disabled} + bind:filterText bind:value={selectedRepository} /> {#if hasMoreRepos} diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index eec4c7579b..296ab693a8 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -22,8 +22,16 @@ let { workspace = undefined, disableChatOffset = false, - onRestored = undefined - }: { workspace?: string; disableChatOffset?: boolean; onRestored?: () => void } = $props() + onRestored = undefined, + onSaved = undefined + }: { + workspace?: string + disableChatOffset?: boolean + onRestored?: () => void + /** Fires after Save has written, for a caller showing state derived from the + * resource — `onRestored` only covers restoring an old version. */ + onSaved?: () => void + } = $props() let drawer: Drawer | undefined = $state() let historyDrawer: Drawer | undefined = $state() @@ -149,9 +157,14 @@ variant="accent" unifiedSize="md" startIcon={{ icon: Save }} - on:click={() => { - resourceEditor?.save() + on:click={async () => { + // Closed before the write is awaited, the way it always was: `save()` toasts its + // own failures and never rejects, so waiting would only add visible lag to every + // caller of this drawer. `onSaved` still fires after the write lands. + const saved = resourceEditor?.save() drawer?.closeDrawer() + await saved + onSaved?.() }} disabled={!canSave} > diff --git a/frontend/src/lib/components/RunForm.svelte b/frontend/src/lib/components/RunForm.svelte index 89eaf8f7ab..a371f3eea9 100644 --- a/frontend/src/lib/components/RunForm.svelte +++ b/frontend/src/lib/components/RunForm.svelte @@ -19,6 +19,7 @@ import { page } from '$app/state' import { replaceState } from '$app/navigation' import JsonInputs from '$lib/components/JsonInputs.svelte' + import { argsToJsonPayload } from '$lib/schema' import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte' import InputSelectedBadge from './schema/InputSelectedBadge.svelte' import { untrack } from 'svelte' @@ -53,6 +54,8 @@ args = scriptArgs psCommonParams = commonParams reloadArgs++ + // `reloadArgs` only keys the form; the JSON editor reads its payload once, at mount. + syncJsonEditor() } export async function run(overrideScheduledForStr?: string | undefined | null) { @@ -199,8 +202,10 @@ return result } - export function setCode(code: string) { - jsonEditor?.setCode(code) + /** Rewrite the open JSON editor from the current args. Only for args replaced from outside + * the editor: entering the JSON view already starts from whatever `args` holds. */ + export function syncJsonEditor() { + jsonEditor?.setCode(argsToJsonPayload(runnable?.schema, args)) } $effect(() => { overrideTag @@ -320,6 +325,7 @@ args = enforceDisabledDefaults(e.detail) } }} + initialCode={argsToJsonPayload(runnable.schema, args)} updateOnBlur={false} placeholder={`Write args as JSON.

    Example:

    {
      "foo": "12"
    }`} /> diff --git a/frontend/src/lib/components/SchemaForm.svelte b/frontend/src/lib/components/SchemaForm.svelte index 0bd2f23e61..ddc2f182bc 100644 --- a/frontend/src/lib/components/SchemaForm.svelte +++ b/frontend/src/lib/components/SchemaForm.svelte @@ -15,6 +15,7 @@ import { Plus } from 'lucide-svelte' import ArgInput from './ArgInput.svelte' import { createEventDispatcher, untrack } from 'svelte' + import { watch } from 'runed' import { deepEqual } from 'fast-equals' import { dragHandleZone, @@ -155,6 +156,11 @@ let itemPicker: ItemPicker | undefined = $state(undefined) let variableEditor: VariableEditor | undefined = $state(undefined) + watch( + () => ws, + () => itemPicker?.reloadItems() + ) + let resourceTypes: string[] | undefined = $state(undefined) async function loadResourceTypes() { diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 7bc4774e9f..fdab141d03 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -122,6 +122,7 @@ import { updateDelegateToGitRepoConfig, insertAdditionalInventories } from '$lib/ansibleUtils' import { copilotInfo } from '$lib/aiStore' import JsonInputs from '$lib/components/JsonInputs.svelte' + import { argsToJsonPayload } from '$lib/schema' import Toggle from './Toggle.svelte' import { deepEqual } from 'fast-equals' import { usePreparedAssetSqlQueries } from '$lib/infer.svelte' @@ -310,6 +311,10 @@ let moduleTestState: Record; schema: Schema }> = $state({}) let testPanelArgs: Record = $state({}) let testPanelSchema: Schema = $state(emptySchema()) + // Bumped whenever the args under test are replaced from outside the arg panel. Both arg + // views key off it: without a bump the JSON editor keeps showing, and on the next + // keystroke commits, the payload it was seeded with for the previous args. + let argsRender = $state(0) // 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, @@ -329,7 +334,13 @@ }) function switchToModule(modulePath: string) { - if (activeModuleTab !== null && modules && activeModuleTab !== modulePath) { + // Re-clicking the tab you are already on is a no-op. Re-running the body would reset this + // module's test state whenever its inference is still pending or has failed (the catch + // leaves `moduleTestState` unwritten), losing both the filled-in args and the arg views. + if (activeModuleTab === modulePath) { + return + } + if (activeModuleTab !== null && modules) { // Switching from another module: save its content and test state modules[activeModuleTab] = { ...modules[activeModuleTab], content: editorCode } moduleTestState[activeModuleTab] = { args: testPanelArgs, schema: testPanelSchema } @@ -345,13 +356,20 @@ } else { testPanelArgs = {} testPanelSchema = emptySchema() + // Inference lands after the bump below, so the editor opens on `{}` and the arg + // views follow the schema in once it arrives. Remounting them again on arrival + // instead would discard anything typed while it was in flight. inferModuleSchema() } + argsRender++ } } function switchToMain() { - if (activeModuleTab !== null && modules) { + if (activeModuleTab === null) { + return + } + if (modules) { // Save current module content and test state modules[activeModuleTab] = { ...modules[activeModuleTab], content: editorCode } moduleTestState[activeModuleTab] = { args: testPanelArgs, schema: testPanelSchema } @@ -360,6 +378,7 @@ editorCode = code lastSyncedCode = code editor?.setCode(editorCode) + argsRender++ } // Whether the open file is tested as a runnable of its own. A `__mod` helper @@ -854,6 +873,7 @@ export function setArgs(nargs: Record) { args = nargs + argsRender++ } export async function runTest(opts?: { cascade?: boolean; skipDdlGuard?: boolean }) { @@ -1654,15 +1674,30 @@ $effect(() => { !hasPreprocessor && (selectedTab = 'main') }) + // `main` and `preprocessor` describe the same args under different schemas; every other tab + // (`diagram`) runs against main's schema, so it collapses into `main` here. + let lastSchemaTab = untrack(() => (selectedTab === 'preprocessor' ? 'preprocessor' : 'main')) $effect(() => { // Only depend on selectedTab (preprocessor ↔ main toggle). // Code changes are handled by the editor on:change handler and // explicit inferSchema calls (initContent, onMount), so we read // `code` inside untrack to avoid a redundant double-inference race. - selectedTab && untrack(() => code && inferSchema(code)) + selectedTab && + untrack(() => { + const schemaTab = selectedTab === 'preprocessor' ? 'preprocessor' : 'main' + const switched = schemaTab !== lastSchemaTab + lastSchemaTab = schemaTab + if (!code) return + // Bump on the switch itself, not on the inference it starts: the other tab's schema + // only lands once that resolves, and remounting the arg views then would discard + // anything typed while it was in flight. An untouched editor follows the schema in. + if (switched) { + argsRender++ + } + inferSchema(code) + }) }) - let argsRender = $state(0) export async function updateArgs(newArgs: Record) { if (Object.keys(newArgs).length > 0) { args = { ...newArgs } @@ -2299,19 +2334,24 @@ style="height: {!schemaHeight || schemaHeight < 600 ? 600 : schemaHeight}px" data-schema-picker > - { - if (e.detail) { - if (onModuleArgs) { - testPanelArgs = e.detail - } else { - args = e.detail + {#key argsRender} + { + if (e.detail) { + if (onModuleArgs) { + testPanelArgs = e.detail + } else { + args = e.detail + } } - } - }} - updateOnBlur={false} - placeholder={`Write args as JSON.

    Example:

    {
      "foo": "12"
    }`} - /> + }} + initialCode={onModuleArgs + ? argsToJsonPayload(testPanelSchema, testPanelArgs) + : argsToJsonPayload(schema, args)} + updateOnBlur={false} + placeholder={`Write args as JSON.

    Example:

    {
      "foo": "12"
    }`} + /> + {/key}
    {:else}
    diff --git a/frontend/src/lib/components/SimpleEditor.svelte b/frontend/src/lib/components/SimpleEditor.svelte index 2764b130e2..65713e19f7 100644 --- a/frontend/src/lib/components/SimpleEditor.svelte +++ b/frontend/src/lib/components/SimpleEditor.svelte @@ -64,6 +64,9 @@ const CHANGE_TIMEOUT = 200 let changeTimeoutId: number | undefined = undefined + // Monaco fires onDidChangeModelContent synchronously from within `setValue`, so without + // this an authoritative overwrite reads as a user edit on the `input` event. + let applyingCode = false let divEl: HTMLDivElement | null = null let editor = $state(null) @@ -74,6 +77,10 @@ let width = $state(0) let initialized = $state(false) let placeholderVisible = $state(false) + // Monaco's content origin. The placeholder is a plain overlay on the editor container, so + // without these it sits over the line-number gutter and off the line-1 baseline. + let contentLeft = $state(0) + let contentLineHeight = $state(0) let mounted = $state(false) let valueAfterDispose: string | undefined = undefined @@ -179,7 +186,12 @@ if (ncode != code) { code = ncode } - editor?.setValue(ncode) + applyingCode = true + try { + editor?.setValue(ncode) + } finally { + applyingCode = false + } // setValue emits a change event of its own; drop the burst it opens so an edit // made right after an authoritative overwrite still counts as a leading change. cancelPendingChanges() @@ -454,6 +466,12 @@ changeTimeoutId = undefined updateCode() }, CHANGE_TIMEOUT) + // `change` trails the buffer by CHANGE_TIMEOUT, too late for a consumer that has to + // know the moment the buffer stopped being the one it wrote. `input` says only that, + // carrying no value: read `getCode()` for what is on screen. + if (!applyingCode) { + dispatch('input') + } if (leading) { updateCode() } @@ -533,6 +551,13 @@ } if (placeholder) { + const syncPlaceholderOrigin = () => { + if (!editor) return + contentLeft = editor.getLayoutInfo().contentLeft + contentLineHeight = editor.getOption(meditor.EditorOption.lineHeight) + } + syncPlaceholderOrigin() + editor.onDidLayoutChange(syncPlaceholderOrigin) editor.onDidChangeModelContent(() => { if (!editor) return const value = editor.getValue() @@ -755,9 +780,10 @@ {#if placeholder}
    {@html placeholder}
    diff --git a/frontend/src/lib/components/apps/components/helpers/InputValue.svelte b/frontend/src/lib/components/apps/components/helpers/InputValue.svelte index aba0569228..7ec2336d3b 100644 --- a/frontend/src/lib/components/apps/components/helpers/InputValue.svelte +++ b/frontend/src/lib/components/apps/components/helpers/InputValue.svelte @@ -19,6 +19,7 @@ import { computeGlobalContext, eval_like } from './eval' import { deepEqual } from 'fast-equals' import { deepMergeWithPriority, isCodeInjection, readFieldsRecursively } from '$lib/utils' + import { escapeTemplateBackticks } from '$lib/utils/templateLiteral' import sum from 'hash-sum' import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted' @@ -271,7 +272,7 @@ if ((input.type === 'template' || input.type == 'templatev2') && isCodeInjection(input.eval)) { try { const r = await eval_like( - '`' + input.eval.replaceAll('`', '\\`') + '`', + '`' + escapeTemplateBackticks(input.eval) + '`', computeGlobalContext($worldStore, id, fullContext), $stateStore, $mode == 'dnd', diff --git a/frontend/src/lib/components/common/radioCard/RadioCard.svelte b/frontend/src/lib/components/common/radioCard/RadioCard.svelte index 6cbb9c78a0..f9b7f675a4 100644 --- a/frontend/src/lib/components/common/radioCard/RadioCard.svelte +++ b/frontend/src/lib/components/common/radioCard/RadioCard.svelte @@ -10,12 +10,14 @@ onSelect, disabled = false, icon = undefined, + showRadio = true, class: className = '' }: { /** Title shown in bold at the top of the card */ label: string - /** Optional supporting line under the label */ - description?: string + /** Optional supporting line under the label. A snippet when it needs markup + * of its own — an emphasised name, a count — rather than plain text. */ + description?: string | Snippet /** Whether this card is the selected option */ selected?: boolean /** Called when the card is clicked */ @@ -23,35 +25,55 @@ disabled?: boolean /** Optional leading icon, rendered after the radio */ icon?: Snippet + /** Draw the radio glyph. Turn it off where the card itself is the only + * control and the border and tint already say which one is picked — the dot + * is then a second, redundant answer to the same question. The group still + * reads as radios to a screen reader, which is what `role` carries. */ + showRadio?: boolean class?: string } = $props() + + // A snippet is a function; a description string is not. Checked rather than + // requiring callers to pick between two props. + const describedBySnippet = $derived(typeof description === 'function')
    + {/if} {#if !hasFirstUserMessage} {/if} @@ -329,9 +382,7 @@ and reconcile would re-archive a workspace-archived one anyway. When the workspace is unavailable the SessionChangesBar below shows the move/discard banner instead (its actions are the real recovery path). --> -
    +
    This session is archived diff --git a/frontend/src/lib/components/sessions/sessionRecoveryNotice.svelte.ts b/frontend/src/lib/components/sessions/sessionRecoveryNotice.svelte.ts new file mode 100644 index 0000000000..635bd68601 --- /dev/null +++ b/frontend/src/lib/components/sessions/sessionRecoveryNotice.svelte.ts @@ -0,0 +1,18 @@ +import { SvelteSet } from 'svelte/reactivity' + +// Session ids opened as a stand-in for a `session_name` this browser doesn't +// hold. Kept in memory rather than on the Session record: persisted, the notice +// would replay on every reload of a session the user has since made their own. +const recovered = new SvelteSet() + +export function markSessionRecovered(id: string): void { + recovered.add(id) +} + +export function isSessionRecovered(id: string): boolean { + return recovered.has(id) +} + +export function clearSessionRecovered(id: string): void { + recovered.delete(id) +} diff --git a/frontend/src/lib/components/sessions/sessionState.svelte.ts b/frontend/src/lib/components/sessions/sessionState.svelte.ts index d8ef56b273..505af7a43a 100644 --- a/frontend/src/lib/components/sessions/sessionState.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionState.svelte.ts @@ -21,6 +21,7 @@ import { import { getLocalSetting, storeLocalSetting } from '$lib/utils' import { logFeatureUsage } from '$lib/utils/featureUsage' import { workspaceRootId } from './sessionScope.svelte' +import { clearSessionRecovered } from './sessionRecoveryNotice.svelte' import { type DBSchema, type IDBPDatabase } from 'idb' import { userScopedDb } from '$lib/userScopedDb' import { deleteItemsForSession } from '../copilot/chat/files/attachedFilesDB' @@ -343,8 +344,8 @@ export function setSessionDraftPrompt(sessionId: string, text: string): void { if ((s.draftPrompt ?? '') === text) return // Keep `transient` (means "in-memory only") set until the flush persists the // draft, so hydrateSessions preserves it across a reconcile inside this window; - // isReusableBlank, not `transient`, is what stops createSession reusing a typed - // draft. Only the IndexedDB write is debounced. + // isDiscardableDraft, not `transient`, is what stops createSession reusing a + // typed draft. Only the IndexedDB write is debounced. s.draftPrompt = text clearTimeout(draftPromptFlushHandles.get(sessionId)) draftPromptFlushHandles.set( @@ -748,30 +749,52 @@ export function requestComposerFocus(): void { composerFocusRequest.nonce++ } -// An untouched in-memory blank that `+` may reuse/discard. `draftPrompt === -// undefined` (never edited), not falsiness: a draft typed then erased to '' still -// has a pending flush and is a real session, so it must survive both. Every other +// An untouched in-memory blank that `+` may reuse and createSession may silently +// drop. `draftPrompt === undefined` (never edited), not falsiness: a draft typed +// then erased to '' still has a pending flush and is a real session. Every other // touch clears `transient` synchronously, so only the draft prompt needs checking. -function isReusableBlank(s: Session): boolean { +function isDiscardableDraft(s: Session): boolean { return !!s.transient && s.draftPrompt === undefined } +// Somewhere empty to put the user, for a URL naming a session this browser +// doesn't hold. Only `transient` makes "empty" trustworthy: chat seeding and +// attached-file persistence both key off `!transient` and leave every field +// below untouched, so a persisted session can hold a conversation regardless. +export function findEmptyLandingSession(): Session | undefined { + return sessionState.sessions.find( + (s) => + !!s.transient && + !s.archived && + !s.workspace_id && + // Falsiness, not `=== undefined`: we only navigate into the session, so a + // draft erased back to '' is still an empty composer to land on. + !s.draftPrompt?.trim() && + !s.pending_fork && + sessionInCurrentFamily(s) + ) +} + export function createSession(): Session { // Reuse an existing untouched draft from the active family rather than pile a // blank entry on every `+`, so several pending sessions can still be built up // in parallel, one touch at a time. A cross-family leftover blank is dropped // instead of reused (reusing it would act on that family). const reusable = sessionState.sessions.find( - (s) => isReusableBlank(s) && sessionInCurrentFamily(s) + (s) => isDiscardableDraft(s) && sessionInCurrentFamily(s) ) if (reusable) { sessionState.currentSessionId = reusable.id + // The blank recovery just landed on is exactly what this reuses, so `+` + // would otherwise hand back a session still carrying the recovery notice: + // asking for a new session must not be answered with "we couldn't find it". + clearSessionRecovered(reusable.id) // Reusing an already-active draft doesn't change currentSessionId, so ask // the composer to focus explicitly — the caller still navigates/redirects. requestComposerFocus() return reusable } - sessionState.sessions = sessionState.sessions.filter((s) => !isReusableBlank(s)) + sessionState.sessions = sessionState.sessions.filter((s) => !isDiscardableDraft(s)) const existingNumbers = sessionState.sessions .map((s) => /^session-(\d+)$/.exec(s.name)?.[1]) .map((n) => (n ? parseInt(n, 10) : 0)) @@ -1087,6 +1110,24 @@ export function setSessionArchived(id: string, archived: boolean) { persistTouched(s) } +// A counter rather than a flag: an inner teardown finishing must not reopen the +// gate while an outer one is still running. Released in a finally, so a delete +// that throws can't wedge it shut. +let openSessionTeardowns = $state(0) + +export function isTearingDownOpenSession(): boolean { + return openSessionTeardowns > 0 +} + +export async function withOpenSessionTeardown(run: () => Promise): Promise { + openSessionTeardowns++ + try { + return await run() + } finally { + openSessionTeardowns-- + } +} + export function deleteSession(id: string) { const s = sessionState.sessions.find((x) => x.id === id) if (!s) return diff --git a/frontend/src/lib/components/sessions/sessionState.test.ts b/frontend/src/lib/components/sessions/sessionState.test.ts index 7732c44746..1a54c86e78 100644 --- a/frontend/src/lib/components/sessions/sessionState.test.ts +++ b/frontend/src/lib/components/sessions/sessionState.test.ts @@ -4,14 +4,22 @@ import { commitSessionWorkspace, createSession, decideSessionLifecycle, + findEmptyLandingSession, isForkSession, + isTearingDownOpenSession, renameSession, sessionInCurrentFamily, setGeneratedSessionSummary, setSessionDraftPrompt, sessionState, + withOpenSessionTeardown, type Session } from './sessionState.svelte' +import { + clearSessionRecovered, + isSessionRecovered, + markSessionRecovered +} from './sessionRecoveryNotice.svelte' import { enterpriseLicense, usersWorkspaceStore, @@ -362,6 +370,28 @@ describe('createSession — reuses an untouched draft, family-scoped', () => { } }) + it('clears the recovery notice off the draft it reuses, so `+` is not answered with "not found"', () => { + const restore = withTwoFamilies('rootA') + const prevCurrent = sessionState.currentSessionId + const landed = session({ + id: 'recovered-blank', + name: 'session-903', + pending_workspace_id: 'forkA', + transient: true + }) + sessionState.sessions.push(landed) + markSessionRecovered(landed.id) + try { + expect(createSession().id).toBe('recovered-blank') + expect(isSessionRecovered('recovered-blank')).toBe(false) + } finally { + clearSessionRecovered('recovered-blank') + sessionState.sessions = sessionState.sessions.filter((s) => s.id !== 'recovered-blank') + sessionState.currentSessionId = prevCurrent + restore() + } + }) + it('drops an untouched draft left over from another family and starts in the active workspace', () => { const restore = withTwoFamilies('rootB') const prevCurrent = sessionState.currentSessionId @@ -488,3 +518,78 @@ describe('createSession — reuses an untouched draft, family-scoped', () => { } }) }) + +describe('findEmptyLandingSession — where an unresolvable session link lands', () => { + it('takes an untouched draft', () => { + const restore = withTwoFamilies('forkA') + const blank = session({ + id: 'landing-blank', + name: 'session-910', + pending_workspace_id: 'forkA', + transient: true + }) + sessionState.sessions.push(blank) + try { + expect(findEmptyLandingSession()?.id).toBe('landing-blank') + } finally { + sessionState.sessions = sessionState.sessions.filter((s) => s.id !== 'landing-blank') + restore() + } + }) + + it('passes over a persisted session, onto which chat seeding can graft a conversation', () => { + const restore = withTwoFamilies('forkA') + // ensureChatIdsSeeded assigns untagged legacy chats to `!transient` sessions + // and initRuntime loads them, without touching a field checked here. + const abandoned = session({ + id: 'landing-abandoned', + name: 'session-910', + pending_workspace_id: 'forkA' + }) + const others = sessionState.sessions + sessionState.sessions = [abandoned] + try { + expect(findEmptyLandingSession()).toBeUndefined() + } finally { + sessionState.sessions = others + restore() + } + }) + + it('passes over a session that has been sent, so recovery never reopens a conversation', () => { + const restore = withTwoFamilies('forkA') + const sent = session({ id: 'landing-sent', name: 'session-911', workspace_id: 'forkA' }) + // Sole candidate, so `undefined` pins the exclusion: `not.toBe` would also + // pass on any unrelated session the shared module state happens to hold. + const others = sessionState.sessions + sessionState.sessions = [sent] + try { + expect(findEmptyLandingSession()).toBeUndefined() + } finally { + sessionState.sessions = others + restore() + } + }) +}) + +describe('withOpenSessionTeardown — the gate that holds recovery off during a delete', () => { + it('stays shut until the outermost teardown finishes', async () => { + let innerDone = false + await withOpenSessionTeardown(async () => { + await withOpenSessionTeardown(async () => {}) + innerDone = true + expect(isTearingDownOpenSession()).toBe(true) + }) + expect(innerDone).toBe(true) + expect(isTearingDownOpenSession()).toBe(false) + }) + + it('reopens when the teardown throws, so a failed delete cannot wedge recovery shut', async () => { + await expect( + withOpenSessionTeardown(async () => { + throw new Error('fork deletion failed') + }) + ).rejects.toThrow('fork deletion failed') + expect(isTearingDownOpenSession()).toBe(false) + }) +}) diff --git a/frontend/src/lib/components/settings/PremiumInfo.svelte b/frontend/src/lib/components/settings/PremiumInfo.svelte index a1036a0ff6..42bfef9d65 100644 --- a/frontend/src/lib/components/settings/PremiumInfo.svelte +++ b/frontend/src/lib/components/settings/PremiumInfo.svelte @@ -1,11 +1,11 @@ @@ -154,17 +145,15 @@
    {#snippet actions()} - - - + {/snippet} @@ -260,8 +249,8 @@
    Developers

    - Calculated on the MAXIMUM number of users in a given billing - period, see the Customer Portal for more info. + Calculated on the MAXIMUM number of users in a given billing period, see the + Customer Portal for more info.

    @@ -276,8 +265,8 @@
    Operators

    - Calculated on the MAXIMUM number of operators in a given - billing period, see the Customer Portal for more info. + Calculated on the MAXIMUM number of operators in a given billing period, see + the Customer Portal for more info.

    @@ -295,8 +284,9 @@ 1 developer = 1 seat, 2 operators = 1 seat.

    - u = ceil({formatNumber(premiumInfo.developerNb)} + {formatNumber(premiumInfo.operatorNb)}/2) - = {formatNumber(premiumInfo.seatsFromUsers)} + u = ceil({formatNumber(premiumInfo.developerNb)} + {formatNumber( + premiumInfo.operatorNb + )}/2) = {formatNumber(premiumInfo.seatsFromUsers)}

    @@ -311,9 +301,8 @@
    Executions this month

    - One execution equals one job - up to 1 second on a worker with 2GB of memory, with each additional - second counting as an extra execution. + One execution equals one job up to 1 second on a worker with 2GB of memory, + with each additional second counting as an extra execution.

    @@ -365,8 +354,8 @@ Used seats (billed)

    - Highest between seats from 'Developers + Operators' and 'Seats from executions'. - This is the number of seats used for billing this month. + Highest between seats from 'Developers + Operators' and 'Seats from + executions'. This is the number of seats used for billing this month.

    u + c = {formatNumber(premiumInfo.usedSeats)} @@ -398,8 +387,8 @@

    Estimate your monthly cost

    - This is a rough estimate based on your expected team size and workload. Actual billing is based - on the maximum number of users and executions in a given month. + This is a rough estimate based on your expected team size and workload. Actual billing is + based on the maximum number of users and executions in a given month.

    @@ -420,13 +409,13 @@
    Operators
    - {estimatedOps} operator{estimatedOps === 1 ? '' : 's'} + {estimatedOps} operator{estimatedOps === 1 + ? '' + : 's'}
    -

    - 2 operators = 1 seat -

    +

    2 operators = 1 seat

    @@ -434,8 +423,8 @@
    Monthly executions
    - One execution equals one job up to 1 second on a virtual CPU with 2 GB of memory, with - each additional second counting as an extra execution. + One execution equals one job up to 1 second on a virtual CPU with 2 GB of memory, + with each additional second counting as an extra execution.
    @@ -449,9 +438,7 @@ format={(v) => `${v * 10}k`} hideInput /> -

    - Each seat includes 10k executions per month. -

    +

    Each seat includes 10k executions per month.

    @@ -558,8 +545,8 @@
  • Every seat includes 10 000 executions - One execution equals one job up to 1 second on a virtual CPU with 2 GB of memory, with - each additional second counting as an extra execution. + One execution equals one job up to 1 second on a virtual CPU with 2 GB of memory, + with each additional second counting as an extra execution.
  • {:else} @@ -586,9 +573,7 @@
    {/if} {:else} -
    - Workspace is on the team plan -
    +
    Workspace is on the team plan
    {/if} {:else if planTitle == 'Enterprise'} {#if plan != 'enterprise'} @@ -601,9 +586,7 @@ See more {:else} -
    - Workspace is on enterprise plan -
    +
    Workspace is on enterprise plan
    {/if} {:else if planTitle === 'Free'} {#if plan} @@ -611,9 +594,7 @@ Cancel your plan in the Customer Portal to downgrade to the free plan {:else} -
    - Workspace is on the free plan -
    +
    Workspace is on the free plan
    {/if} {/if} diff --git a/frontend/src/lib/components/sidebar/MenuLink.svelte b/frontend/src/lib/components/sidebar/MenuLink.svelte index b9d841f729..1c6b7ec715 100644 --- a/frontend/src/lib/components/sidebar/MenuLink.svelte +++ b/frontend/src/lib/components/sidebar/MenuLink.svelte @@ -64,7 +64,12 @@ 'group flex items-center px-2 py-2 text-sm font-light rounded-md h-8 gap-2', isSelected ? sidebarClasses.selectedBg - : sidebarClasses.hoverBg, + : item + ? 'transition-colors' + : sidebarClasses.hoverBg, + // Inside a menu, melt moves data-highlighted with both the keyboard and the + // pointer, so it is the only pointer state: a hover rule as well lights two rows. + item ? 'data-[highlighted]:bg-surface-hover' : '', isSelected ? sidebarClasses.selectedText : sidebarClasses.text, classNames )} @@ -77,7 +82,13 @@ > {#if icon} {@const SvelteComponent = icon} - + {/if} {#if !isCollapsed} diff --git a/frontend/src/lib/components/sidebar/OperatorMenu.svelte b/frontend/src/lib/components/sidebar/OperatorMenu.svelte index e6519ffafc..0914068607 100644 --- a/frontend/src/lib/components/sidebar/OperatorMenu.svelte +++ b/frontend/src/lib/components/sidebar/OperatorMenu.svelte @@ -40,7 +40,6 @@ import type { FavoriteKind } from './FavoriteMenu.svelte' let darkMode: boolean = $state(false) let showExtraTriggers = $state(false) - let menubarEl: HTMLElement | undefined = $state() interface Props { isCollapsed?: boolean @@ -171,211 +170,227 @@ ) - -
    { - const btn = menubarEl?.querySelector('[data-melt-menubar-trigger]') - if (btn instanceof HTMLElement) btn.click() - }} -> - - {#snippet children({ createMenu })} - (showExtraTriggers = false)}> - {#snippet triggr({ trigger })} - - {/snippet} - {#snippet children({ item })} -
    - {#each favoriteLinks ?? [] as favorite (favorite.href)} - - - {#if favorite.kind == 'script'} - - {:else if favorite.kind == 'flow'} - - {:else if favorite.kind == 'app' || favorite.kind == 'raw_app'} - - {:else if favorite.kind == 'asset'} - - {/if} - - - {favorite.label} - - - {/each} + + {#snippet children({ createMenu })} + (showExtraTriggers = false)} + > + {#snippet triggr({ trigger, pinned })} + + {/snippet} + {#snippet children({ item })} +
    + {#each favoriteLinks ?? [] as favorite (favorite.href)} + + + {#if favorite.kind == 'script'} + + {:else if favorite.kind == 'flow'} + + {:else if favorite.kind == 'app' || favorite.kind == 'raw_app'} + + {:else if favorite.kind == 'asset'} + + {/if} + + + {favorite.label} + + + {/each} +
    + + {#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)} + + {/each} + +
    +
    + + + Account settings +
    - {#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)} - - {/each} - -
    -
    - - - Account settings - -
    - -
    - { - if (!document.documentElement.classList.contains('dark')) { - document.documentElement.classList.add('dark') - window.localStorage.setItem('dark-mode', 'dark') - } else { - document.documentElement.classList.remove('dark') - window.localStorage.setItem('dark-mode', 'light') - } - }} - lightMode - class={twMerge( - 'w-full flex gap-3.5 px-2 py-2', - sidebarClasses.hoverBg, - sidebarClasses.text - )} - {item} - > - {#if darkMode} - - {:else} - - {/if} - Switch theme - - clearWorkspaceFromStorage()} - lightMode - class={twMerge( - 'flex gap-3.5 px-2 py-2', - sidebarClasses.hoverBg, - sidebarClasses.text - )} - {item} - > - - All workspaces - - - {#if $superadmin} - - - Instance settings - +
    + { + if (!document.documentElement.classList.contains('dark')) { + document.documentElement.classList.add('dark') + window.localStorage.setItem('dark-mode', 'dark') + } else { + document.documentElement.classList.remove('dark') + window.localStorage.setItem('dark-mode', 'light') + } + }} + lightMode + class={twMerge( + 'w-full flex gap-3.5 px-2 py-2', + 'transition-colors', + sidebarClasses.text, + 'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' + )} + {item} + > + {#if darkMode} + + {:else} + {/if} + Switch theme + + clearWorkspaceFromStorage()} + lightMode + class={twMerge( + 'flex gap-3.5 px-2 py-2', + 'transition-colors', + sidebarClasses.text, + 'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' + )} + {item} + > + + All workspaces + + {#if $superadmin} logout()} + href="#superadmin-settings" class={twMerge( - 'flex flex-row gap-3.5 items-center px-2 py-2 w-full', - 'text-primary text-xs', - 'hover:bg-surface-hover cursor-pointer', + 'flex flex-row gap-3.5 items-center px-2 py-2 ', + 'text-secondary text-xs', + 'cursor-pointer', 'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' )} {item} > - - Sign out + + Instance settings -
    -
    - {#snippet renderSecondMenuLinks(menuLinks: SecondMenuLink[])} - {#each menuLinks as menuLink (menuLink.href ?? menuLink.label)} - - {menuLink.label} - - {/each} - {/snippet} - {#if secondMenuLinks.length || secondMenuTriggerLinks.length || extraTriggerLinks.length} -
    - {#if secondMenuLinks.length}
    {@render renderSecondMenuLinks(secondMenuLinks)}
    {/if} - {#if secondMenuTriggerLinks.length}
    {@render renderSecondMenuLinks(secondMenuTriggerLinks)}
    {/if} - {#if extraTriggerLinks.length}
    - -
    { - e.stopPropagation() - showExtraTriggers = !showExtraTriggers - }} + {/if} + + logout()} + class={twMerge( + 'flex flex-row gap-3.5 items-center px-2 py-2 w-full', + 'text-primary text-xs', + 'cursor-pointer', + 'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' + )} + {item} + > + + Sign out + +
    +
    + {#snippet renderSecondMenuLinks(menuLinks: SecondMenuLink[])} + {#each menuLinks as menuLink (menuLink.href ?? menuLink.label)} + + {menuLink.label} + + {/each} + {/snippet} + {#if secondMenuLinks.length || secondMenuTriggerLinks.length || extraTriggerLinks.length} +
    + {#if secondMenuLinks.length}
    {@render renderSecondMenuLinks(secondMenuLinks)}
    {/if} + {#if secondMenuTriggerLinks.length}
    {@render renderSecondMenuLinks(secondMenuTriggerLinks)}
    {/if} + {#if extraTriggerLinks.length}
    + +
    { + // This row expands the list below it instead of acting on the selection, and + // melt keeps the menu open only for a click it sees as defaultPrevented. + // Svelte delegates onclick to the root, which runs after melt's own listener, + // and a capture listener on the item itself would be ordered only by + // registration, so an ancestor's capture phase is what reliably wins. + e.preventDefault() + showExtraTriggers = !showExtraTriggers + }} + > + More triggers -
    - {#if showExtraTriggers} - {#each extraTriggerLinks as menuLink (menuLink.href)} - - {menuLink.label} - - {/each} - {/if} -
    {/if} -
    - {/if} - {#if $enterpriseLicense} - - {/if} -
    + +
    + {#if showExtraTriggers} + {#each extraTriggerLinks as menuLink (menuLink.href)} + + {menuLink.label} + + {/each} + {/if} +
    {/if} +
    + {/if} + {#if $enterpriseLicense} + + {/if}
    - {/snippet} -
    - {/snippet} -
    -
    +
    + {/snippet} + + {/snippet} + diff --git a/frontend/src/lib/components/sidebar/SidebarUsage.svelte b/frontend/src/lib/components/sidebar/SidebarUsage.svelte index e62cd02565..1625ed83e2 100644 --- a/frontend/src/lib/components/sidebar/SidebarUsage.svelte +++ b/frontend/src/lib/components/sidebar/SidebarUsage.svelte @@ -2,21 +2,18 @@ import { resource } from 'runed' import { goto } from '$lib/navigation' import { isCloudHosted } from '$lib/cloud' - import { UserService } from '$lib/gen' + import { WorkspaceService } from '$lib/gen' import { isPremiumStore, usageStore, userStore, - userWorkspaces, workspaceMembershipVersion, workspaceStore, - workspaceUsageStore, - type UserWorkspace + workspaceUsageStore } from '$lib/stores' import { refreshExecutions } from '$lib/usage.svelte' import { logFeatureUsage } from '$lib/utils/featureUsage' import { scopedValue, tagged } from '$lib/utils/scopedValue' - import { findWorkspaceAncestors } from '$lib/utils/workspaceHierarchy' import { Button } from '$lib/components/common' import Modal from '$lib/components/common/modal/Modal.svelte' import { Tooltip } from '$lib/components/meltComponents' @@ -30,51 +27,30 @@ let open = $state(false) - // A fork's usage and tier resolve to its billing root while its member list is a - // subset of the root's, so seats must come from the root or the cap is fork-sized - // against root usage. `undefined` when the root isn't visible from here: the cap - // is then unknowable, and the caller hides the meter rather than guessing. - function billingRoot(workspace: string, all: UserWorkspace[]): string | undefined { - const self = all.find((w) => w.id === workspace) - if (!self) return undefined - if (!self.parent_workspace_id) return workspace - const top = findWorkspaceAncestors(workspace, all).at(-1) - return top && !top.parent_workspace_id ? top.id : undefined - } + // Seat count for a paid workspace, the basis of its included executions. The server + // resolves a fork to the workspace its plan is billed on and counts the seats there, + // because neither is answerable from here: a fork's member list is a subset of that + // root's, and a fork member need not be a member of the root at all. + const fetchSeats = tagged( + async (workspace: string) => (await WorkspaceService.getBillableSeats({ workspace })).seats + ) - // Seat count for a paid workspace, the basis of its included executions. Only the - // user list is needed: `premium_info` carries the same usage number as - // `workspaceUsageStore` but requires admin and only exists when Stripe is - // configured, so it would leave regular members with no block at all. - const fetchSeats = tagged(async (root: string) => { - // Throws for a fork member with no seat in the root, which is the same answer as - // an unresolvable root: leave the paid meter hidden. - const users = await UserService.listUsers({ workspace: root }) - // Same basis as the backend's `count_paid_seats`: disabled members and service - // accounts are not billed, so counting them inflates the cap and hides a real - // overage. 1 developer = 1 seat, 2 operators = 1 seat. - const billable = users.filter((u) => !u.disabled && !u.is_service_account) - const developers = billable.filter((u) => !u.operator).length - const operators = billable.length - developers - return Math.ceil(developers + operators / 2) - }) - - const billingRootId = $derived.by(() => { - const workspace = $workspaceStore - if (!isCloudHosted() || !$isPremiumStore || !workspace) return undefined - return billingRoot(workspace, $userWorkspaces ?? []) - }) + const meteredWorkspace = $derived( + isCloudHosted() && $isPremiumStore ? $workspaceStore : undefined + ) // The membership version is in the key so a change re-resolves the cap, but not in // the tag: tagging by it would blank the bar on every change. const seatsResource = resource( () => - billingRootId ? { root: billingRootId, version: $workspaceMembershipVersion } : undefined, - async (key) => (key ? await fetchSeats(key.root) : undefined) + meteredWorkspace + ? { workspace: meteredWorkspace, version: $workspaceMembershipVersion } + : undefined, + async (key) => (key ? await fetchSeats(key.workspace) : undefined) ) const scopedSeats = scopedValue() - const seats = $derived(scopedSeats(billingRootId, seatsResource.current)) + const seats = $derived(scopedSeats(meteredWorkspace, seatsResource.current)) type QuotaKey = 'user' | 'workspace' diff --git a/frontend/src/lib/components/wizards/SetupChecklist.svelte b/frontend/src/lib/components/wizards/SetupChecklist.svelte index 9da6d3a5ad..95f278b0c1 100644 --- a/frontend/src/lib/components/wizards/SetupChecklist.svelte +++ b/frontend/src/lib/components/wizards/SetupChecklist.svelte @@ -31,9 +31,12 @@ type Props = { steps: SetupStep[] class?: string + /** Applied to each step's substep block, for a caller whose substeps are a long + * list rather than a handful of checks and need their own scroll. */ + substepsClass?: string } - let { steps, class: className = '' }: Props = $props() + let { steps, class: className = '', substepsClass = '' }: Props = $props() /** * Only the steps the user has actually toggled. A failed step opens itself, so recording @@ -108,8 +111,8 @@ {#if step.substeps?.length} -
    - +
    +
    {/if}
    diff --git a/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte b/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte index 2e19cbea82..3495bdbee0 100644 --- a/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte +++ b/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte @@ -21,6 +21,8 @@ FolderService, OauthService, ResourceService, + UserService, + type User, VariableService, WorkspaceService } from '$lib/gen' @@ -85,6 +87,38 @@ customInstanceDbs: ResourceReturn confirmationModal: ConfirmationModalHandle defaultInstanceDbName: () => string + /** Name to open with, when the caller needs a table of a particular name rather + * than whatever the user picks — the import wizard configures the one a project's + * migrations target. + * + * Locked when `onFinishAlso` is also given, because that work targets this name and + * nothing carries an edit through to it: renaming `main` to `other` would create + * `other`, then run the migrations against `main`, fail, and leave a data table + * nobody asked for. Editable without one, where the name is only a name. */ + initialName?: string + /** Where the dialog portals to. `#content` is the app shell's scroll container, + * which only exists inside the `(logged)` layout; a page reparented out of it + * (the hub import wizard) has to say `body` or the portal finds nothing and the + * dialog never appears. */ + modalTarget?: string + /** What the caller does once the table exists, named on the final button so the + * user is told before pressing it — the import wizard runs the project's + * migrations, which is otherwise invisible until it has already happened. */ + finishAlso?: string + /** The work `finishAlso` names. Run as the last checklist step, so it reports + * where the rest of the run does instead of starting after the dialog closes. + * Throwing marks that step failed; the table itself is already made either way. */ + onFinishAlso?: () => Promise + /** The workspace everything here is created in and checked against. + * + * Defaults to `$workspaceStore`, which is right for the settings page — it is the + * workspace being looked at. The import wizard is the exception: its page is + * reparented out of `(logged)`, so nothing re-runs the layout's workspace + * persistence, and after a reload the store still names whatever workspace the + * user came from while the plan in the URL names the destination. Left ambient, + * this would create the data table in one workspace and run the project's + * migrations in the other. */ + workspace?: string } let { @@ -95,9 +129,62 @@ onDone, customInstanceDbs, confirmationModal, - defaultInstanceDbName + defaultInstanceDbName, + initialName, + modalTarget = '#content', + finishAlso, + onFinishAlso, + workspace: workspaceProp }: Props = $props() + /** + * The caller needs this exact table, and has follow-up work bound to its name. + * + * Captured when the dialog opens rather than read live: `initialName` is the caller's + * `wizardFor`, which it clears from `onDone` — and that fires after a *failed* run too, + * while the dialog stays up offering Back. Reading it live releases the lock exactly when + * the user is most likely to edit, which is the divergence the lock exists to stop. + */ + let nameLocked = $state(false) + + /** Every write and every check goes through this, never `$workspaceStore` directly. */ + const targetWorkspace = $derived(workspaceProp ?? $workspaceStore ?? '') + + /** + * Who the caller is *in the destination*, which is not who `$userStore` describes. + * + * `$userStore` is the membership of the workspace the app is in. Routing the API calls + * elsewhere without routing this leaves the username behind: after a reload on the import + * wizard's step 4 it names the workspace the user came from, and a resource path built + * from it lands on `u/` inside the destination — failing an ownership check, + * or for an admin, quietly putting database credentials in another member's namespace. + */ + let targetUser = $state(undefined) + const aimedElsewhere = $derived(!!workspaceProp && workspaceProp !== $workspaceStore) + const ambientUsername = $derived($userStore?.username ?? '') + const targetUsername = $derived(aimedElsewhere ? (targetUser?.username ?? '') : ambientUsername) + /** The destination's membership could not be read, so nothing here knows who the user is. */ + let membershipFailed = $state(false) + + async function loadTargetUser(): Promise { + const ws = workspaceProp + if (!ws || ws === $workspaceStore) { + targetUser = undefined + membershipFailed = false + return + } + try { + targetUser = await UserService.whoami({ workspace: ws }) + membershipFailed = false + } catch { + // Recorded rather than swallowed: an unknown username silently becomes `admin` in + // the default path, which is the wrong namespace to write credentials into. Setup + // is blocked instead. + targetUser = undefined + membershipFailed = true + } + } + const STEPS = ['Choose a database', 'Set it up', 'Review'] let wiz: WizardState = $state( @@ -146,7 +233,7 @@ } clearTimeout(variableCheck) variableCheck = setTimeout(async () => { - const taken = await VariableService.existsVariable({ workspace: $workspaceStore!, path }) + const taken = await VariableService.existsVariable({ workspace: targetWorkspace, path }) // Two checks can be in flight at once and resolve out of order. A `false` for a path // nobody is on any more would clear the error guarding the one about to be written; // a `true` would disable Finish over a path this run stopped caring about. @@ -163,7 +250,7 @@ * in flight when Finish is pressed. */ async function pathConflictMessage(path: string): Promise { - const workspace = $workspaceStore! + const workspace = targetWorkspace // Each namespace answers to its own claim. Holding the secret says nothing about who owns // the resource beside it, so one claim must not wave the other's check through. const [variable, resource] = await Promise.all([ @@ -178,11 +265,14 @@ let maxStep = $state(1) function defaultProjectName(): string { - return `windmill-${$workspaceStore ?? 'workspace'}` + return `windmill-${targetWorkspace || 'workspace'}` } function defaultTableName(): string { - return existingNames.includes('main') ? `${$workspaceStore ?? 'data'}_datatable` : 'main' + // A caller that needs a specific name wins over the usual "main, unless taken": + // the import wizard's migrations only apply to a table of the name they target. + if (initialName) return initialName + return existingNames.includes('main') ? `${targetWorkspace || 'data'}_datatable` : 'main' } // Takes the list rather than reading it, so the fetch that loads it can seed off its own @@ -190,7 +280,7 @@ function defaultFolder(list: string[] = folders): string { // The first folder this admin can write to, so the resource lands somewhere the team // can find and repair. A workspace with no folders falls back to the personal space. - return list.length ? `f/${list[0]}` : `u/${$userStore?.username ?? 'admin'}` + return list.length ? `f/${list[0]}` : `u/${targetUsername || 'admin'}` } // A row this run wrote and could not take back out is still its own: `removeRow` reports @@ -236,7 +326,7 @@ ) const pgResources = resource( - () => (opened && wiz.provider === 'resource' ? ($workspaceStore ?? '') : ''), + () => (opened && wiz.provider === 'resource' ? targetWorkspace : ''), async (workspace) => { if (!workspace) return undefined const list = await ResourceService.listResource({ workspace, resourceType: 'postgresql' }) @@ -330,7 +420,7 @@ ) const folderNames = resource( - () => (opened ? ($workspaceStore ?? '') : ''), + () => (opened ? targetWorkspace : ''), async (workspace) => { if (!workspace) return [] const all = await FolderService.listFolderNames({ workspace }) @@ -374,7 +464,11 @@ let resumedPath = $state(undefined) function reset(from: WizardResume | undefined) { + nameLocked = !!initialName && !!onFinishAlso resumedPath = from?.resourcePath + // A pending confirmation that never settled leaves `dismissing` true, and `finally` + // cannot clear what never resolves — so a fresh open always starts dismissable. + dismissing = false wiz = newWizardState({ name: from?.name || defaultTableName(), projectName: from?.projectName || defaultProjectName(), @@ -391,6 +485,7 @@ createdProjects = [] nameConflictFor = undefined lastFailure = '' + finishAlsoFailed = false pathTakenError = '' poolerUnavailable = undefined if (from) { @@ -424,7 +519,11 @@ * what keeps the restore independent of when the prop it was assigned to reaches this * component. */ - export function open(parked?: WizardResume) { + export async function open(parked?: WizardResume) { + // Awaited before `reset`, which seeds the resource path from `defaultFolder()` and so + // needs the destination's username. Seeding first and correcting later loses whenever + // the folder list resolves first, and never corrects at all if `whoami` fails. + await loadTargetUser() reset(parked ?? resume) opened = true } @@ -472,12 +571,14 @@ // Also retires any check still in flight, so its answer cannot land on the edited value. probeToken++ clearProbe(wiz) - // Read off one attempt against one project; the review step would otherwise warn about - // a limitation that no longer applies while claiming session pooling right above it. + // Read off one attempt against one project, so it does not survive a change of inputs: + // the review step would otherwise warn about a limitation that does not apply to what + // it is describing, while claiming session pooling right above it. poolerUnavailable = undefined // Same for the failure carried back to the review step: it names inputs that have since // been edited, so it would describe a run nobody can still act on. lastFailure = '' + finishAlsoFailed = false if (maxStep > wiz.step) maxStep = wiz.step } @@ -527,7 +628,7 @@ settle({ checking: false, report: undefined, error: undefined }) return } - const report = await probeDatatableConnection($workspaceStore!, database) + const report = await probeDatatableConnection(targetWorkspace, database) settle({ checking: false, report, error: undefined }) } catch (err: any) { settle({ @@ -579,6 +680,13 @@ ) /** Why the last run failed, kept on the review step after the checklist is dropped. */ let lastFailure = $state('') + /** + * The appended `onFinishAlso` step failed while `runSetup` itself succeeded. Tracked apart + * from `run.result`, which stays the setup's own verdict: the data table really was + * created, so a retry must re-run only this last step. Re-running the setup would ask for + * the table name it has just taken, and be refused as a duplicate. + */ + let finishAlsoFailed = $state(false) /** * A refused pre-flight means nothing ran, so the checklist from a previous attempt has to @@ -631,7 +739,7 @@ const name = wiz.review.name.trim() try { if (claimedName !== name) { - const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! }) + const settings = await WorkspaceService.getSettings({ workspace: targetWorkspace }) if (settings.datatable?.datatables?.[name]) { nameConflictFor = { name, @@ -666,6 +774,7 @@ } run = { steps: planSteps(wiz), running: true } lastFailure = '' + finishAlsoFailed = false // The database is registered by the call whatever it answers, so asking for one is // already leaving something behind. if (wiz.provider === 'instance' && wiz.instance.mode === 'create') { @@ -675,7 +784,7 @@ let result: RunResult | undefined = undefined try { result = await runSetup(wiz, { - workspace: $workspaceStore!, + workspace: targetWorkspace, supabaseToken: supaOauth.token, onInstanceDbsChanged: async () => { await customInstanceDbs.refetch() @@ -684,9 +793,27 @@ onPoolerUnavailable: (reason) => (poolerUnavailable = reason), createdProjects, claims, - username: $userStore?.username ?? '' + username: targetUsername }) } finally { + // The caller's own finishing work, appended to the same checklist. It only runs + // on a clean setup: there is no table for it to act on otherwise. + if (result?.ok && onFinishAlso && finishAlso) { + const title = finishAlso.charAt(0).toUpperCase() + finishAlso.slice(1) + run.steps = [...run.steps, { title, status: 'running' }] + try { + await onFinishAlso() + run.steps = run.steps.map((s, i) => + i === run.steps.length - 1 ? { ...s, status: 'done' as const } : s + ) + } catch (err: any) { + const description = err?.body ?? err?.message ?? String(err) + run.steps = run.steps.map((s, i) => + i === run.steps.length - 1 ? { ...s, status: 'failed' as const, description } : s + ) + finishAlsoFailed = true + } + } // `runSetup` catches per step, but anything escaping it would otherwise leave the // button spinning with a page reload the only way out. // Kept, not replaced: what an earlier attempt wrote is still out there, so a later @@ -713,7 +840,10 @@ /** * Whether closing would throw away work. A failed run counts: its inputs are still editable * and it may have left something behind. A run in flight cannot be closed at all, and one - * that succeeded has nothing left to lose. + * that made its data table has nothing left to lose — including when `onFinishAlso` failed + * afterwards, because the table is real and working and the caller owns what is left. The + * import step, the only caller that passes one, shows that failure on its own row with a + * way to run it again and will not let Finish through while it stands. */ function hasUnfinishedIntent(): boolean { return wiz.provider !== undefined && !run.running && !run.result?.ok @@ -730,19 +860,52 @@ return } dismissing = true - const confirmed = await confirmationModal.ask({ - title: 'Leave without adding a data table?', - // A run that failed and was sent back to be edited leaves whatever it got through - // behind it, so promising otherwise would be a lie exactly when it matters most. - children: leftBehind - ? 'The setup that already ran left what it created behind, and what you have filled in here will be lost.' - : 'Nothing has been created yet, and what you have filled in here will be lost.', - confirmationText: 'Discard' - }) - dismissing = false - // Re-read rather than trust the entry check: a run can start while the dialog is up, and - // answering Discard would otherwise tear the modal down in the middle of it. - if (confirmed && !preventClose) close() + // `finally`, because the flag is what blocks a second attempt: an `ask` that throws + // would otherwise leave the dialog permanently undismissable — the backdrop, Escape + // and the close button all return early here, so the only way out would be a reload. + try { + const confirmed = await confirmationModal.ask({ + title: 'Leave without adding a data table?', + // A run that failed and was sent back to be edited leaves whatever it got through + // behind it, so promising otherwise would be a lie exactly when it matters most. + children: leftBehind + ? 'The setup that already ran left what it created behind, and what you have filled in here will be lost.' + : 'Nothing has been created yet, and what you have filled in here will be lost.', + confirmationText: 'Discard' + }) + // Re-read rather than trust the entry check: a run can start while the dialog is up, and + // answering Discard would otherwise tear the modal down in the middle of it. + if (confirmed && !preventClose) close() + } finally { + dismissing = false + } + } + + /** Re-runs only the appended step, which is the only thing that failed. */ + async function retryFinishAlso() { + if (!onFinishAlso || !finishAlso) return + const title = finishAlso.charAt(0).toUpperCase() + finishAlso.slice(1) + finishAlsoFailed = false + run = { + ...run, + running: true, + steps: [...run.steps.slice(0, -1), { title, status: 'running' as const }] + } + try { + await onFinishAlso() + run.steps = run.steps.map((s, i) => + i === run.steps.length - 1 ? { ...s, status: 'done' as const } : s + ) + } catch (err: any) { + const description = err?.body ?? err?.message ?? String(err) + run.steps = run.steps.map((s, i) => + i === run.steps.length - 1 ? { ...s, status: 'failed' as const, description } : s + ) + finishAlsoFailed = true + } finally { + run = { ...run, running: false } + onDone() + } } function close() { @@ -752,10 +915,18 @@ // The single primary action. Its label says what it is about to do, and doing it is what // moves the wizard on. let primary = $derived.by(() => { + // Ahead of everything: without the destination's membership the resource path would be + // guessed, and a guess here writes database credentials into somebody else's namespace. + if (aimedElsewhere && membershipFailed) + return { label: 'Cannot read your access to this workspace', disabled: true } if (submitting && !run.running) return { label: 'Setting things up', disabled: true, busy: true } if (run.steps.length) { if (run.running) return { label: 'Setting things up', disabled: true, busy: true } + // Before the `ok` check: the setup succeeded and the step after it did not, so + // "Done" would be offered over a failed row. + if (finishAlsoFailed) + return { label: 'Try again', disabled: false, act: retryFinishAlso } if (run.result?.ok) return { label: 'Done', disabled: false, act: close } // A run that died because the Supabase token expired would retry into the same 401 // forever; authorizing again is the only thing that can move it on. @@ -809,11 +980,12 @@ act: enterReview } } + const created = + wiz.provider === 'supabase' && wiz.supabase.mode === 'create' + ? 'Create project and data table' + : 'Create data table' return { - label: - wiz.provider === 'supabase' && wiz.supabase.mode === 'create' - ? 'Create project and data table' - : 'Create data table', + label: finishAlso ? `${created} and ${finishAlso}` : created, disabled: // Guards the way back as well as the way forward: the stepper can return to step 2, // and not every control there invalidates the review it just made stale. @@ -842,7 +1014,7 @@ else opened = v } } - target="#content" + target={modalTarget} formStyling title="Add a data table" contentClasses="flex flex-col" @@ -1034,7 +1206,7 @@ {#if wiz.instance.mode === 'existing'} {@const shared = ( customInstanceDbs.current?.[wiz.instance.dbName ?? '']?.used_by_workspaces ?? [] - ).filter((w) => w !== $workspaceStore)} + ).filter((w) => w !== targetWorkspace)} {#if shared.length} @@ -1047,7 +1219,7 @@
    {#each instanceDbs as { name, db } (name)} {@const selected = wiz.instance.dbName === name} - {@const others = (db.used_by_workspaces ?? []).filter((w) => w !== $workspaceStore)} + {@const others = (db.used_by_workspaces ?? []).filter((w) => w !== targetWorkspace)} {:else if s.phase === 'draft'} + {/if} + {/if} + {#if s.liveOnHub && s.phase === 'draft'} + {/if}
    @@ -270,8 +319,10 @@ {#if s.phase === 'predeploy'}
    - Bundling creates a draft project on the Hub from the selected scripts, flows and - apps of {s.selectedFolder}/. + Bundling creates {s.liveOnHub + ? 'an update to your Hub project' + : 'a draft project on the Hub'} from the selected scripts, flows and apps of + {s.selectedFolder}/. {s.selectedItems.length} of {s.filteredWorkspaceItems.length} items selected.
    @@ -351,6 +402,31 @@ {/if} {/if} + {#if s.liveOnHub && s.phase !== 'live' && s.phase !== 'under_review'} + + Visitors keep seeing the published version, with its stars, forks and comments, + until this update is approved. Approving replaces it in place; discarding leaves it + exactly as it is. + + {/if} + {#if s.pipelineReplayMayBeStale && s.phase === 'draft'} + + This update carries the cascade recorded for the version that is live, and at least + one item has changed since. Record it again below, or visitors will replay the old + run as though it were this version. + + {/if} + {#if s.rejectionReason && s.phase === 'draft'} + + {s.rejectionReason} + + {/if} {#if s.phase === 'draft'}
    @@ -477,18 +553,21 @@
    {/if} {#if s.phase === 'under_review'} -
    - -
    - Locked while under review - - The Windmill team is reviewing this submission. Editing, recording, and sharing - actions are disabled. Estimated turnaround: 1-2 business days. - -
    -
    + The Windmill team is reviewing your project. Submission is locked until they answer + — no new version can be sent to the Hub, and no recording added to this one. + Estimated turnaround: 1-2 business days{#if s.hubSupportsUpdates}; cancel the + submission to get back to it sooner{/if}.{#if s.liveOnHub} + Visitors keep seeing the published version meanwhile, with its stars, forks and + comments; approving replaces it in place.{/if} Your folder itself is untouched — keep + editing your scripts and flows as usual. + {/if} {#if s.phase === 'draft'} {@const recordedCount = s.recordableItems.filter((i) => i.rec === 'recorded').length} @@ -668,7 +747,11 @@ Waiting for the Windmill team to review the submission. {:else} - Iterate further by starting a new draft. + + {s.liveOnHub + ? 'Publish an update to change it — this stays live until the update is approved.' + : 'Iterate further by starting a new draft.'} + {/if} {/snippet} diff --git a/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte b/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte index 1e6c990819..a2ccf0b958 100644 --- a/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte @@ -44,6 +44,14 @@ onDiscard?: () => void } = $props() + const creatableStorageTypes = [ + { value: 's3', label: 'S3' }, + { value: 'azure_blob', label: 'Azure Blob' }, + { value: 's3_aws_oidc', label: 'AWS OIDC' }, + { value: 'azure_workload_identity', label: 'Azure Workload Identity' }, + { value: 'gcloud_storage', label: 'Google Cloud Storage' } + ] + let advancedPermissionModalState: | { open: false } | { open: true; storage: S3ResourceSettingsItem } = $state({ open: false }) @@ -294,31 +302,38 @@
    - {#if tableRow[1].resourceType === 'filesystem'} - +
    + - {/if} + {#if tableRow[1].resourceType === 'filesystem'} + + Filesystem storage points the workspace at a directory on the server's own + disk. Only development builds of Windmill accept it — switch this storage to + S3, Azure Blob or Google Cloud Storage to configure it here. + + {/if} +
    {#if tableRow[1].resourceType === 'filesystem'} diff --git a/frontend/src/lib/components/workspaceSettings/deployToHubSession.svelte.ts b/frontend/src/lib/components/workspaceSettings/deployToHubSession.svelte.ts index 5cc7f0d1d7..c421553a7c 100644 --- a/frontend/src/lib/components/workspaceSettings/deployToHubSession.svelte.ts +++ b/frontend/src/lib/components/workspaceSettings/deployToHubSession.svelte.ts @@ -198,8 +198,31 @@ export class DeployToHubSession { // Whether the Hub currently has a custom logo for this project (from // rehydration) — drives the "Remove current logo" affordance. hubHasRemoteLogo = $state(false) + // A pipeline recording is attached on the Hub. An update inherits the published + // one, which is only a demo of the new version if nothing it runs changed. + hubHasPipelineRecording = $state(false) + // The Hub's own verdict: this update runs different content from the published + // version. False when there is no update in flight. + hubItemsChanged = $state(false) + // The attached pipeline recording is the published version's, copied when this + // update started, rather than one recorded for it. Authoritative across reloads, + // unlike `pipelineRecorded`, which only remembers this session. + hubPipelineRecordingInherited = $state(false) effectiveSlug = $state('') hubItemIds = $state>({}) + // Set once the project is published: everything the wizard shows from here on + // describes an update to it, and the published version keeps serving until that + // update is approved. `phase` is the update's own status, not the project's. + liveOnHub = $state(false) + /** This Hub knows about pending updates — it answers rehydration with a `live` + * key. An older one takes a project offline to republish and has neither the + * withdraw nor the discard endpoint, so the actions built on them stay hidden. */ + hubSupportsUpdates = $state(false) + // A reviewer's verdict on the current draft, shown so the publisher knows what + // to fix before resubmitting. + rejectionReason = $state(undefined) + discardingUpdate = $state(false) + withdrawing = $state(false) // Best-effort data table migrations for the bundle, editable in the drawer and // pushed on deploy. Regenerated when the bundle drawer opens. @@ -228,6 +251,10 @@ export class DeployToHubSession { submitting = $state(false) syncing = $state(false) + // Set from the Hub's answer to the draft request: this push went into an update + // rather than over the published project. + #publishedAsUpdate = false + // Intra-session tokens: latest call wins among competing calls on this session. #triggerLoadTok = 0 #recordRunTok = 0 @@ -316,6 +343,18 @@ export class DeployToHubSession { ) pipelineScriptPathSet = $derived(new Set(this.pipelineScriptPaths)) isPipelineProject = $derived(this.pipelineScriptPaths.length > 0) + /** The pipeline replay this update carries came from the published version, and + * something it runs has changed since — so it is a recording of another version. + * `hubItemsChanged` is the Hub comparing content, not a guess from which items + * carry recordings: an item nobody ever recorded has not changed. */ + pipelineReplayMayBeStale = $derived( + this.liveOnHub && + this.isPipelineProject && + this.hubHasPipelineRecording && + this.hubPipelineRecordingInherited && + this.hubItemsChanged && + !this.pipelineRecorded + ) hubSlug = $derived(this.effectiveSlug || sanitizeSlug(this.hubName)) relevantTriggers = $derived.by(() => { @@ -573,6 +612,17 @@ export class DeployToHubSession { this.hubSummary = p.summary ?? '' this.hubReadme = p.readme ?? '' this.hubHasRemoteLogo = p.has_logo === true + this.hubHasPipelineRecording = p.has_pipeline_recording === true + this.hubItemsChanged = p.items_changed === true + this.hubPipelineRecordingInherited = p.pipeline_recording_inherited === true + this.rejectionReason = p.rejection_reason ?? undefined + // `live` is a key this Hub always sends — null unless an update is in + // flight, in which case the fields above describe that update and the + // project itself is still published. Its absence means a Hub old enough to + // still take a project offline while it re-publishes, so the wizard must + // not promise otherwise. + this.hubSupportsUpdates = 'live' in p + this.liveOnHub = this.hubSupportsUpdates && (p.live?.approved === true || p.status === 'live') this.phase = p.status === 'live' ? 'live' : p.status === 'under_review' ? 'under_review' : 'draft' const ids: Record = {} @@ -898,6 +948,9 @@ export class DeployToHubSession { try { const parsed = JSON.parse(text) if (typeof parsed?.slug === 'string') returnedSlug = parsed.slug + // The Hub decides this: publishing over an approved project goes into a + // pending update instead, and the project keeps serving meanwhile. + this.#publishedAsUpdate = parsed?.pending_revision === true } catch {} if (!returnedSlug) { sendUserToast(`Hub did not return a slug. Aborting publish to avoid path drift.`, true) @@ -1252,8 +1305,13 @@ export class DeployToHubSession { // UI stuck in `predeploy`; rehydrate then upgrades to authoritative state. this.draftItems = itemsSnapshot.map((i) => ({ ...i, rec: 'none' })) this.phase = 'draft' + const asUpdate = this.#publishedAsUpdate await this.rehydrateFromHub() - sendUserToast(`Draft created on the Hub. Add recordings before submitting for review.`) + sendUserToast( + asUpdate + ? `Update ready on the Hub. Your published project stays live until it is approved.` + : `Draft created on the Hub. Add recordings before submitting for review.` + ) } finally { this.deploying = false } @@ -1313,12 +1371,82 @@ export class DeployToHubSession { } } + /** Go back to picking items, to publish again. Local only — nothing reaches the + * Hub until the bundle is confirmed, and where the Hub supports updates the + * published version keeps serving even then. */ startNewDraft = () => { this.draftItems = [] this.recordings = {} + this.rejectionReason = undefined + // All of it belongs to the update just finished, not the one starting. The + // captured cascade especially: left in place, the next update could save a + // replay of the version it replaces. Bumping the token first abandons a run + // still in flight, which would otherwise write its result back over this. + this.#pipelineRunTok++ + this.pipelineRecorded = false + this.pipelineRecordingResult = undefined + this.pipelineRunState = 'idle' + this.pipelineRunError = undefined this.phase = 'predeploy' } + /** Take the submission back out of review. Everything pushed for it is kept, so + * it can be fixed and submitted again. */ + cancelSubmission = async () => { + if (this.withdrawing) return + const slug = this.effectiveSlug + if (!slug) return + this.withdrawing = true + try { + const res = await fetch( + `/api/w/${this.workspace}/hub/projects/${encodeURIComponent(slug)}/withdraw${this.#folderQs()}`, + { method: 'POST', credentials: 'include' } + ) + if (!res.ok) { + sendUserToast(`Could not cancel the submission: ${await res.text()}`, true) + return + } + if (this.#disposed) return + this.phase = 'draft' + await this.rehydrateFromHub() + sendUserToast(`Submission cancelled. Everything you pushed is still here.`) + } catch (e: any) { + sendUserToast(`Could not cancel the submission: ${e?.message ?? e}`, true) + } finally { + this.withdrawing = false + } + } + + /** Throw away an update in progress and go back to what is published. */ + discardUpdate = async () => { + if (this.discardingUpdate) return + const slug = this.effectiveSlug + if (!slug) return + this.discardingUpdate = true + try { + const res = await fetch( + `/api/w/${this.workspace}/hub/projects/${encodeURIComponent(slug)}/discard_update${this.#folderQs()}`, + { method: 'POST', credentials: 'include' } + ) + if (!res.ok) { + sendUserToast(`Could not discard the update: ${await res.text()}`, true) + return + } + if (this.#disposed) return + this.draftItems = [] + this.recordings = {} + this.deploymentStatus = {} + this.rejectionReason = undefined + this.phase = 'live' + await this.rehydrateFromHub() + sendUserToast(`Update discarded. The published project is unchanged.`) + } catch (e: any) { + sendUserToast(`Could not discard the update: ${e?.message ?? e}`, true) + } finally { + this.discardingUpdate = false + } + } + /** Reset record-drawer state and load the target's schema. */ async openRecord(it: DeployItem) { const tok = ++this.#recordRunTok diff --git a/frontend/src/lib/components/workspaceSettings/projectInstall.ts b/frontend/src/lib/components/workspaceSettings/projectInstall.ts index abc48a3ad6..6fd4bdd728 100644 --- a/frontend/src/lib/components/workspaceSettings/projectInstall.ts +++ b/frontend/src/lib/components/workspaceSettings/projectInstall.ts @@ -44,6 +44,11 @@ export interface InstallResult { path: string ok: boolean error?: string + /** + * Already in the destination, so nothing was written. Not a failure and not an import — + * reporting it as either would be a lie, and the difference is what a retry is for. + */ + skipped?: boolean } // Guarding an item's own path is not enough: the `$res:`/script/flow refs baked @@ -201,7 +206,11 @@ async function importApp(workspace: string, a: ExportItem): Promise { // Apply one migration to the target data table. If the data table opted into // migrations, record it (datatable_migrations + _wm_migrations, run only this // version); otherwise run the SQL once as a preview job (unrecorded). -async function applyOneMigration( +// +// Exported because the import can leave migrations unapplied: a data table the +// project needs may not be configured in the destination yet, and the wizard's setup +// step runs them once it is. +export async function applyOneMigration( workspace: string, projectSlug: string, m: ProjectMigration @@ -254,15 +263,73 @@ async function applyOneMigration( * reviewed) migrations. Each item's outcome is reported through `onResult`; * failures never abort the remaining items. */ +/** + * The kinds an import writes that carry a path and can therefore already be there. + * + * Triggers carry their own kind too (`trigger:schedule`, `trigger:http`, … — the values of + * `WorkspaceTriggerKind`): each trigger kind is a separate table keyed on + * `(path, workspace_id)`, so one workspace can hold a schedule and an HTTP trigger both + * called `f/cal/sync`. Flattening them to `trigger` would let whichever exists answer for + * the other. + */ +export type ImportedKind = + | 'script' + | 'flow' + | 'app' + | 'resource' + | `trigger:${string}` + +/** + * The key `alreadyPresent` is built and read with. Kind and path together, because the kinds + * share one path namespace and a bare path cannot say which of them is already there. + */ +export function presenceKey(kind: ImportedKind, path: string): string { + return `${kind}:${path}` +} + export async function installProject(args: { workspace: string exportData: ProjectExport folder: string migrations: ProjectMigration[] + /** Called once, before the reviewed migrations are applied, when there are any. Lets a + * caller show them as their own step rather than folding them into the item import. */ + onMigrationsStart?: () => void + /** + * Asked before each write. Returning true stops the run where it is — the writes already + * made stay, the rest never start. Nothing here can cancel a request already in flight, + * so this is the granularity available without threading an `AbortSignal` through every + * service call: the import wizard uses it when the user confirms leaving mid-run. + */ + stopped?: () => boolean + /** + * What is already in the destination, as `presenceKey` keys — so a retry writes only what + * is missing instead of replaying the bundle into a wall of "already exists". Built from + * retargeted paths, because that is what these items will actually be called. + * + * Keyed by kind and not by path alone: the five kinds share one `f//` namespace, so + * a trigger and a script may legitimately both be called `f/cal/sync`. A flat path set + * would let either one mask the other and silently skip an item that was never imported. + * + * Never a way to *replace* anything: an item that is there is left exactly as it is, + * which is the same promise `updateIfExists: false` makes for a resource whose value + * someone has since filled in. + */ + alreadyPresent?: Set hasEeLicense: boolean onResult: (r: InstallResult) => void }): Promise { - const { workspace, exportData, folder, migrations, hasEeLicense, onResult } = args + const { + workspace, + exportData, + folder, + migrations, + hasEeLicense, + onResult, + onMigrationsStart, + stopped, + alreadyPresent + } = args const record = (path: string, p: Promise): Promise => p.then( @@ -270,6 +337,20 @@ export async function installProject(args: { (e: any) => onResult({ path, ok: false, error: errorMessage(e) }) ) + /** Every write goes through here, so one check covers items, variables and migrations. */ + const halted = () => stopped?.() === true + + /** + * True when the destination already has this path, so the write is not attempted. Reported + * rather than dropped: the checklist has to account for every item the project ships, and + * "already there" is a different thing from "imported". + */ + const present = (kind: ImportedKind, path: string): boolean => { + if (!alreadyPresent?.has(presenceKey(kind, path))) return false + onResult({ path, ok: true, skipped: true }) + return true + } + try { await FolderService.createFolder({ workspace, requestBody: { name: folder } }) } catch {} @@ -309,6 +390,8 @@ export async function installProject(args: { } for (const s of proj.scripts) { + if (halted()) return + if (present('script', s.path)) continue // `$var:` is resolved in job args (flow inputs, schedule args, trigger config), // not in script source, so there is no variable arg to contain here. await checkedItem(s.path, extractScriptRefs(s.content ?? ''), undefined, () => @@ -316,19 +399,26 @@ export async function installProject(args: { ) } for (const f of proj.flows) { + if (halted()) return + if (present('flow', f.path)) continue await checkedItem(f.path, extractFlowRefs(f.value), f.value, () => importFlow(workspace, f)) } for (const r of proj.resources) { + if (halted()) return + if (present('resource', r.path)) continue await checked(r.path, () => importResourceStub(workspace, r)) } // Placeholders for the project's internal `$var:`/`$jsonvar:` refs (retargeted // into this folder). External refs are rejected per-item, so only stub in-folder // ones; guard again in case an out-of-folder ref slipped through retargeting. for (const p of collectExportVarPaths(proj)) { + if (halted()) return if (!p.startsWith(prefix)) continue await record(`variable: ${p}`, importVariablePlaceholder(workspace, p)) } for (const a of proj.apps) { + if (halted()) return + if (present('app', a.path)) continue const isRaw = a.app_type === 'raw' const refs = isRaw ? extractRawAppRefs(a.value?.raw ?? '') : extractAppRefs(a.value) // Raw apps hold their runnables in the `value.raw` JSON string; parse it so the @@ -368,6 +458,8 @@ export async function installProject(args: { return varContainmentViolation(cfg, folder) } for (const t of proj.triggers) { + if (halted()) return + if (present(`trigger:${t.kind}`, String(t.path))) continue const violation = guard(t.path, t.runnable_path) ?? triggerConfigViolation(t) await record( String(t.path), @@ -389,7 +481,10 @@ export async function installProject(args: { } // Apply the reviewed data table migrations after items exist. + if (halted()) return + if (migrations.length) onMigrationsStart?.() for (const m of migrations) { + if (halted()) return await record( `data table: ${m.datatable_name}`, applyOneMigration(workspace, exportData.project.slug, m) diff --git a/frontend/src/lib/components/workspaceSettings/projectInstallPresence.test.ts b/frontend/src/lib/components/workspaceSettings/projectInstallPresence.test.ts new file mode 100644 index 0000000000..186d581d58 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/projectInstallPresence.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * A retry must not re-write what the destination already has. The interesting kind is the + * trigger: it is the one item the import creates through an API that rejects an existing + * path outright, so replaying it turns "already there" into a reported failure. + */ + +const created = vi.hoisted(() => ({ triggers: [] as string[] })) + +vi.mock('$lib/gen', () => { + const nothing = vi.fn(async () => []) + return { + AppService: { createApp: vi.fn(), listApps: nothing }, + FlowService: { createFlow: vi.fn(), listFlows: nothing }, + FolderService: { createFolder: vi.fn() }, + ResourceService: { createResource: vi.fn(), listResource: nothing }, + ScriptService: { createScript: vi.fn(), listScripts: nothing }, + VariableService: { createVariable: vi.fn(), listVariable: nothing }, + WorkspaceService: { listDataTables: vi.fn(async () => []) } + } +}) + +vi.mock('../triggers/workspaceTriggersList', () => ({ + TRIGGER_KINDS: { + schedule: { badge: 'Schedule', resourceField: undefined }, + http: { badge: 'HTTP', resourceField: undefined } + }, + createWorkspaceTriggerDisabled: vi.fn(async (_ws: string, t: { path: string; kind: string }) => { + created.triggers.push(`${t.kind}:${t.path}`) + }), + triggerHandlerRefs: () => [] +})) + +import { installProject, presenceKey } from './projectInstall' + +const exportData = { + project: { slug: 'calendly', name: 'Calendly', summary: '', readme: null }, + scripts: [], + flows: [], + apps: [], + resources: [], + variables: [], + triggers: [ + { + kind: 'schedule', + path: 'f/calendly/nightly', + runnable_path: 'f/calendly/sync', + runnable_kind: 'script', + summary: null, + config: {} + } + ], + migrations: [] +} as any + +async function run(alreadyPresent?: Set) { + const results: any[] = [] + await installProject({ + workspace: 'w', + exportData, + folder: 'calendly', + migrations: [], + hasEeLicense: true, + alreadyPresent, + onResult: (r) => results.push(r) + }) + return results +} + +describe('installProject presence', () => { + beforeEach(() => { + created.triggers = [] + }) + + it('creates a trigger the destination does not have', async () => { + const results = await run(new Set()) + expect(created.triggers).toEqual(['schedule:f/calendly/nightly']) + expect(results).toContainEqual({ path: 'f/calendly/nightly', ok: true }) + }) + + // Without the skip the retry calls the create API again, which rejects the existing + // path, and the row reads as a failure for something that is already there. + it('skips a trigger that is already there instead of re-creating it', async () => { + const results = await run(new Set([presenceKey('trigger:schedule', 'f/calendly/nightly')])) + expect(created.triggers).toEqual([]) + expect(results).toContainEqual({ path: 'f/calendly/nightly', ok: true, skipped: true }) + }) + + // Kinds share one `f//` namespace, so the key has to carry the kind: a script of + // the same name is not this trigger and must not stand in for it. + it('does not let another kind at the same path mask the trigger', async () => { + const results = await run(new Set([presenceKey('script', 'f/calendly/nightly')])) + expect(created.triggers).toEqual(['schedule:f/calendly/nightly']) + expect(results).toContainEqual({ path: 'f/calendly/nightly', ok: true }) + }) + + // Each trigger kind is its own table keyed on (path, workspace_id), so a workspace can + // hold a schedule and an HTTP trigger both called `f/calendly/nightly`. The one that + // exists must not answer for the one that does not. + it('does not let another trigger kind at the same path mask this one', async () => { + const results = await run(new Set([presenceKey('trigger:http', 'f/calendly/nightly')])) + expect(created.triggers).toEqual(['schedule:f/calendly/nightly']) + expect(results).toContainEqual({ path: 'f/calendly/nightly', ok: true }) + }) +}) diff --git a/frontend/src/lib/githubApp.ts b/frontend/src/lib/githubApp.ts index f60b1f8538..666734546e 100644 --- a/frontend/src/lib/githubApp.ts +++ b/frontend/src/lib/githubApp.ts @@ -10,7 +10,12 @@ export interface GitHubAppState { loadingGithubInstallations: boolean githubInstallations: GetGlobalConnectedRepositoriesResponse workspaceGithubInstallations: GetGlobalConnectedRepositoriesResponse - selectedGHAppAccountId: string | undefined + /** + * Installations are keyed by `installation_id`, never by `account_id`: an org + * can be installed more than once (re-installed, or added from another + * workspace), and matching on the org name resolves to the wrong one. + */ + selectedGHAppInstallationId: number | undefined selectedGHAppRepository: string | undefined githubInstallationUrl: string | undefined installationCheckInterval: number | undefined @@ -25,11 +30,6 @@ export interface GitHubAppState { isGhesSelfManaged: boolean } -export interface GitHubRepository { - name: string - url: string -} - export interface GitHubAppError extends Error { code: 'VALIDATION_ERROR' | 'NETWORK_ERROR' | 'AUTH_ERROR' | 'UNKNOWN_ERROR' details?: unknown @@ -101,7 +101,7 @@ export function createGitHubAppState(): GitHubAppState { loadingGithubInstallations: false, githubInstallations: [], workspaceGithubInstallations: [], - selectedGHAppAccountId: undefined, + selectedGHAppInstallationId: undefined, selectedGHAppRepository: undefined, githubInstallationUrl: undefined, installationCheckInterval: undefined, @@ -241,18 +241,6 @@ export function stopInstallationCheck(state: GitHubAppState): void { state.isCheckingInstallation = false } -/** - * Gets repositories for a specific GitHub account - */ -export function getRepositories(state: GitHubAppState, accountId: string): GitHubRepository[] { - if (!accountId) return [] - - return ( - state.githubInstallations.find((installation) => installation.account_id === accountId) - ?.repositories || [] - ) -} - /** * Adds a GitHub installation to the current workspace */ diff --git a/frontend/src/lib/hubProject.ts b/frontend/src/lib/hubProject.ts new file mode 100644 index 0000000000..b58b338f7d --- /dev/null +++ b/frontend/src/lib/hubProject.ts @@ -0,0 +1,87 @@ +import type { Component } from 'svelte' +import { appIconComponent } from '$lib/components/icons' +import { SettingService } from '$lib/gen' +import { DEFAULT_HUB_BASE_URL } from '$lib/hub' +import type { ImportProjectSummary } from '$lib/components/ImportProjectCard.svelte' + +/** + * The browser-reachable hub. `hub_accessible_url` exists precisely for this: on a + * private instance `hub_base_url` may be an address only the server can resolve. + * The (logged) layout does the same lookup, but the import wizard renders outside + * it, so it has to ask for itself. + */ +export async function hubBrowserUrl(): Promise { + try { + const accessible = (await SettingService.getGlobal({ key: 'hub_accessible_url' })) as string + if (accessible) return accessible.replace(/\/+$/, '') + const base = (await SettingService.getGlobal({ key: 'hub_base_url' })) as string + if (base) return base.replace(/\/+$/, '') + } catch { + // Unset or unreadable — the public hub is the right default either way. + } + return DEFAULT_HUB_BASE_URL.replace(/\/+$/, '') +} + +/** Shape of `GET /projects/` — the hub's own summary endpoint. */ +interface HubProject { + slug: string + name: string + summary: string + author: string + apps: string[] + logoApp: string | null + hasLogo: boolean + counts: { scripts: number; flows: number; apps: number; resources: number; total: number } +} + +/** + * Fetches one project's presentation straight from the hub. Cross-origin and + * unauthenticated by design: this runs before the wizard has a workspace, so the + * workspace-scoped `/api/w//hub/...` proxy is not available yet. The endpoint + * is public and sends `Access-Control-Allow-Origin: *`. + */ +export async function fetchHubProject(slug: string): Promise { + const hub = await hubBrowserUrl() + const res = await fetch(`${hub}/projects/${encodeURIComponent(slug)}`, { + headers: { accept: 'application/json' } + }) + if (!res.ok) throw new Error(`hub returned ${res.status}`) + const p = (await res.json()) as HubProject + return { + slug: p.slug, + name: p.name, + summary: p.summary, + author: p.author, + apps: p.apps ?? [], + // A project with an uploaded logo shows that; otherwise the icon of the + // integration it is filed under, otherwise its first integration. + logoUrl: p.hasLogo ? `${hub}/projects/${encodeURIComponent(p.slug)}/logo` : undefined, + iconApps: [p.logoApp, ...(p.apps ?? [])].filter( + (a, i, all): a is string => !!a && all.indexOf(a) === i + ), + counts: { + apps: p.counts?.apps ?? 0, + flows: p.counts?.flows ?? 0, + scripts: p.counts?.scripts ?? 0, + resources: p.counts?.resources ?? 0 + } + } +} + +/** + * The icon for a hub integration slug, resolved from the icons Windmill already bundles. + * + * Not fetched from the hub: the hub renders these out of `@windmill-labs/components`, which + * is this frontend's own package, so asking it over HTTP is a round trip to get our own + * assets back — and it made the card depend on a cross-origin request that an `API_SECRET` + * hub refuses anyway. + * + * The alias exists because the two repos disagree on one slug: the hub files Postgres scripts + * under `postgres`, the icon set ships the mark as `postgresql`. The hub bridges it in + * `aliasApp`; this is the same bridge on the consuming side. + */ +const HUB_APP_ICON_ALIAS: Record = { postgres: 'postgresql' } + +export function hubAppIcon(app: string): Component | undefined { + return appIconComponent(HUB_APP_ICON_ALIAS[app] ?? app) +} diff --git a/frontend/src/lib/importWizard/abandon.test.ts b/frontend/src/lib/importWizard/abandon.test.ts new file mode 100644 index 0000000000..abf0ff61b9 --- /dev/null +++ b/frontend/src/lib/importWizard/abandon.test.ts @@ -0,0 +1,237 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Everything the executor reaches over the network, stubbed. The two behaviours under test +// are decisions it makes around those calls, not the calls themselves. +vi.mock('$lib/gen', () => ({ + WorkspaceService: { + createWorkspace: vi.fn(), + listDataTables: vi.fn(async () => []), + // No workspace of ours at that id: the existing-workspace plan these tests use never + // reaches the create, and an empty list is the honest answer for a fresh instance. + listWorkspaces: vi.fn(async () => []) + }, + UserService: { + whoami: vi.fn(async () => ({ username: 'u' })), + globalWhoami: vi.fn(async () => ({ email: 'u@example.com' })) + } +})) +vi.mock('$lib/storeUtils', () => ({ switchWorkspace: vi.fn() })) +// What the destination already holds. A test sets this to stand in for a workspace that has +// some of the bundle in it — a half-finished run, or an existing workspace. +const present = vi.hoisted(() => ({ + paths: new Set(), + /** Fires when the probe is entered, so a test can abandon while it is in flight. */ + onProbe: undefined as (() => void) | undefined +})) +vi.mock('./probe', async (orig) => ({ + ...(await orig()), + probeWorkspace: vi.fn(async () => ({ exists: false, ours: false })), + probeImportedPaths: vi.fn(async () => { + present.onProbe?.() + return present.paths + }) +})) +vi.mock('$lib/user', () => ({ getUserExt: vi.fn(async () => ({ username: 'u' })) })) +// Let a test abandon *during* a write loop, which is the only way it happens for real: +// `run()` clears the flag on entry so a retry can proceed. Two hooks, because the item and +// migration phases stop in different places and the second is what pins the migrate row. +const hooks = vi.hoisted(() => ({ + afterFirstItem: undefined as (() => void) | undefined, + afterMigrationsStart: undefined as (() => void) | undefined +})) + +vi.mock('$lib/components/workspaceSettings/projectInstall', () => ({ + installProject: vi.fn(async (args: any) => { + // Ordered as the real one is: every item loop, then `onMigrationsStart`, then the + // migrations — and `stopped` checked before each write, returning the same way it + // returns on success. + for (const path of ['a', 'b', 'c']) { + if (args.stopped?.() === true) return + // Keyed exactly as the real `installProject` keys it, so this stand-in cannot drift + // into testing a contract the production code does not have. + if (args.alreadyPresent?.has(`script:${path}`)) { + args.onResult({ path, ok: true, skipped: true }) + continue + } + args.onResult({ path, ok: true }) + hooks.afterFirstItem?.() + hooks.afterFirstItem = undefined + } + if (args.stopped?.() === true) return + if (args.migrations?.length) { + args.onMigrationsStart?.() + hooks.afterMigrationsStart?.() + hooks.afterMigrationsStart = undefined + for (const m of args.migrations) { + if (args.stopped?.() === true) return + args.onResult({ path: `data table: ${m.datatable_name}`, ok: true }) + } + } + }) +})) + +// The export the run fetches from the hub proxy. Two items so an abandoned run can stop +// partway through, which is the case under test. +const EXPORT = { + project: { slug: 'calendly', name: 'Calendly', summary: '', readme: null }, + scripts: [], + flows: [], + apps: [], + resources: [], + triggers: [], + migrations: [] +} +vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: true, status: 200, text: async () => JSON.stringify(EXPORT) })) +) + +import { ImportExecution } from './execution.svelte' + +const PLAN = { slug: 'calendly', destination: { kind: 'existing' as const, workspaceId: 'ws-a' } } +const deps = { reviewMigrations: async () => [], hasEeLicense: false } + +/** A project that ships one, so `#import` appends the `migrate` row at all. */ +const MIGRATION = { + datatable_name: 'main', + sql: 'CREATE TABLE IF NOT EXISTS "calendly"."config" (id int)', + sql_down: '', + enabled: true +} +const depsWithMigration = { reviewMigrations: async () => [MIGRATION], hasEeLicense: false } + +describe('planTag', () => { + // A run handed back after a remount is checked against the plan being rendered. Tagging + // it with that plan instead of its own would make the check pass by construction. + it('identifies the plan the run belongs to', () => { + const a = new ImportExecution(PLAN, deps) + const b = new ImportExecution( + { ...PLAN, destination: { kind: 'existing', workspaceId: 'ws-b' } }, + deps + ) + expect(a.planTag).not.toBe(b.planTag) + }) + + it('ignores the folder, which stays editable after the run is made', () => { + const a = new ImportExecution(PLAN, deps) + const tag = a.planTag + a.setFolder('somewhere-else') + expect(a.planTag).toBe(tag) + }) + + it('separates two projects going to the same workspace', () => { + const a = new ImportExecution(PLAN, deps) + const b = new ImportExecution({ ...PLAN, slug: 'bitly' }, deps) + expect(a.planTag).not.toBe(b.planTag) + }) +}) + +describe('abandoning mid-import', () => { + beforeEach(() => { + hooks.afterFirstItem = undefined + hooks.afterMigrationsStart = undefined + present.paths = new Set() + }) + + it('does not report done, so the resumed step offers Retry rather than Continue', async () => { + const run = new ImportExecution(PLAN, deps) + hooks.afterFirstItem = () => run.abandon() + await run.run() + // `installProject` returned early exactly as it does on success; calling that done + // would report a clean import over items that never started. + expect(run.done).toBe(false) + expect(run.error).toMatch(/stopped/i) + }) + + it('reports done when nothing abandoned it', async () => { + const run = new ImportExecution(PLAN, deps) + await run.run() + expect(run.done).toBe(true) + expect(run.itemResults.length).toBe(3) + }) + + it('stops the migrate row spinning when it is abandoned mid-migration', async () => { + const run = new ImportExecution(PLAN, depsWithMigration) + // After `onMigrationsStart`, which is where the row is actually set to running — + // the real one fires it at the head of the migration loop, past every item loop. + hooks.afterMigrationsStart = () => run.abandon() + await run.run() + const migrate = run.tasks.find((t) => t.key === 'migrate') + // Guards the test itself: without a migration in the export there is no row, and the + // assertion below would pass over a branch that never ran. + expect(migrate).toBeDefined() + // A row left on `running` reads as work still in progress on a run that has stopped. + expect(migrate?.status).not.toBe('running') + expect(run.done).toBe(false) + }) +}) + +/** + * A retry must write only what is missing. Resending the whole bundle turns every item that + * already landed into an "already exists" failure — a wall of red over work that succeeded. + * What is already there is skipped, and reported as skipped rather than as imported, because + * the checklist has to account for every item the project ships. + */ +describe('retrying over what is already there', () => { + beforeEach(() => { + present.paths = new Set() + }) + + it('writes nothing for a path the destination already holds', async () => { + present.paths = new Set(['script:a', 'script:b']) + const run = new ImportExecution(PLAN, deps) + await run.run() + const byPath = new Map(run.itemResults.map((r) => [r.path, r])) + expect(byPath.get('a')?.skipped).toBe(true) + expect(byPath.get('b')?.skipped).toBe(true) + expect(byPath.get('c')?.skipped).toBeUndefined() + }) + + it('still accounts for every item, so the checklist stays complete', async () => { + present.paths = new Set(['script:a', 'script:b']) + const run = new ImportExecution(PLAN, deps) + await run.run() + expect(run.itemResults.length).toBe(3) + expect(run.done).toBe(true) + }) + + it('says what it did rather than claiming to have imported all of it', async () => { + present.paths = new Set(['script:a', 'script:b']) + const run = new ImportExecution(PLAN, deps) + await run.run() + const importRow = run.tasks.find((t) => t.key === 'import') + expect(importRow?.detail).toMatch(/1 imported/) + expect(importRow?.detail).toMatch(/2 already there/) + }) + + it('imports everything when the destination is empty', async () => { + const run = new ImportExecution(PLAN, deps) + await run.run() + expect(run.itemResults.every((r) => !r.skipped)).toBe(true) + expect(run.tasks.find((t) => t.key === 'import')?.detail).toMatch(/3 imported/) + }) +}) + +describe('abandoning while the presence probe is in flight', () => { + beforeEach(() => { + present.paths = new Set() + present.onProbe = undefined + }) + + /** + * `import` goes `running` before the probe is asked, so returning straight out of an + * abandon here leaves a spinner on a run that has stopped — next to an enabled Retry and + * with no explanation of why it stopped. + */ + it('leaves no task running', async () => { + const run = new ImportExecution(PLAN, deps as any) + // Abandoned from inside the probe: `import` is already `running` by then, and the + // executor's next look at the flag is the early return under test. + present.onProbe = () => run.abandon() + await run.run() + expect(run.tasks.find((t) => t.status === 'running')).toBeUndefined() + expect(run.tasks.find((t) => t.key === 'import')?.status).toBe('failed') + expect(run.error).toBeTruthy() + expect(run.done).toBe(false) + }) +}) diff --git a/frontend/src/lib/importWizard/execution.svelte.ts b/frontend/src/lib/importWizard/execution.svelte.ts new file mode 100644 index 0000000000..06c33f3222 --- /dev/null +++ b/frontend/src/lib/importWizard/execution.svelte.ts @@ -0,0 +1,517 @@ +import { UserService, WorkspaceService } from '$lib/gen' +import { switchWorkspace } from '$lib/storeUtils' +import { userStore, workspaceStore } from '$lib/stores' +import { getUserExt } from '$lib/user' +import { get } from 'svelte/store' +import { enterNewWorkspace, refreshWorkspaceList } from '$lib/workspaceCreation' +import { + installProject, + type InstallResult +} from '$lib/components/workspaceSettings/projectInstall' +import type { + ProjectExport, + ProjectMigration +} from '$lib/components/workspaceSettings/projectBundle' +import { planWorkspaceId, type ImportPlan } from './plan' +import { probeImportedPaths, probeWorkspace } from './probe' + +/** + * The only thing in the wizard that changes anything. It takes a finished plan and + * runs it as an ordered, observable list of tasks, so the last step can show what + * is happening and exactly where it stopped. + * + * Everything the run needs from the outside — reviewing data table migrations, + * the EE licence — is injected, so the wizard's UI decisions stay in the wizard + * and this file stays testable without a browser. + */ + +/** + * Whether an import is mid-flight, readable by anything that can navigate away + * from it. The run outlives no component: leaving the last step unmounts the + * migration review the executor is awaiting, so a run in progress has to block + * the stepper and the browser rather than be silently detached. + */ +const runState = $state({ active: false }) +export function importIsRunning(): boolean { + return runState.active +} + +export type TaskStatus = 'pending' | 'running' | 'done' | 'failed' | 'skipped' + +export interface TaskView { + key: string + label: string + status: TaskStatus + detail?: string +} + +/** + * What a plan will do, as the same task list the run reports against. Exported so the + * last step can show it before the run starts: the checklist is what the step says it + * is going to do, and the run then fills in the same rows rather than replacing them. + * + * Derived from the plan alone — no network — so it is safe to call while rendering. + */ +export function plannedTasks(plan: ImportPlan): TaskView[] { + const d = plan.destination + const tasks: TaskView[] = [] + if (d?.kind === 'new') { + tasks.push({ key: 'create', label: `Create workspace ${d.id}`, status: 'pending' }) + } + tasks.push({ key: 'fetch', label: 'Fetch the project from the hub', status: 'pending' }) + // The destination rides on this row when nothing creates it, so the list still says + // where the items are going in the existing-workspace case. + tasks.push({ + key: 'import', + label: d?.kind === 'existing' ? `Import the items into ${d.workspaceId}` : 'Import the items', + status: 'pending' + }) + return tasks +} + +export interface ExecutionDeps { + /** + * Chooses which data table migrations to run. Returns the migrations to apply, + * or null to abort the whole import (the user backed out at the warning). + */ + reviewMigrations: ( + workspace: string, + migrations: ProjectMigration[] + ) => Promise + hasEeLicense: boolean +} + +export class ImportExecution { + #plan: ImportPlan + #deps: ExecutionDeps + + /** Carried between attempts so a retry does not redo finished work. */ + // The hub's summary endpoint counts scripts/flows/apps/resources; only the export + // carries triggers and data table migrations. Surfaced so the last step can say + // what it is about to create — the warning below it talks about triggers, and the + // page this replaced did show both. + #export = $state(undefined) + + /** + * Data tables the project's migrations target. The wizard compares these with the + * destination's configured tables to decide whether a setup step is needed — + * `installProject` skips a migration whose data table does not exist. + */ + get datatableNames(): string[] { + const e = this.#export + if (!e) return [] + return [ + ...new Set( + (e.migrations ?? []) + .filter((m) => m.enabled && (m.sql ?? '').trim() !== '') + .map((m) => m.datatable_name) + ) + ] + } + + /** + * How many resources the project shipped. Every one arrives as an empty stub — + * the hub never publishes resource values — so a non-zero count means the setup + * step has something to offer. + */ + get resourceCount(): number { + return this.#export?.resources?.length ?? 0 + } + + get extraCounts(): { triggers: number; migrations: number } | undefined { + const e = this.#export + if (!e) return undefined + return { + triggers: e.triggers?.length ?? 0, + migrations: (e.migrations ?? []).filter((m) => m.enabled && (m.sql ?? '').trim() !== '') + .length + } + } + // $state, not a plain field: the UI offers to delete the workspace this run + // created, and a plain field would never re-render that button. + #workspaceCreated = $state(false) + + tasks = $state([]) + results = $state([]) + running = $state(false) + /** Set when a run stopped early; cleared when a retry starts. */ + error = $state(undefined) + done = $state(false) + + // Where the app pointed before this run switched away from it, so undoing the run + // can put it back. Captured at construction rather than at switch time: by then + // `$workspaceStore` already holds the workspace being entered. + #priorWorkspace = get(workspaceStore) + + constructor(plan: ImportPlan, deps: ExecutionDeps) { + this.#plan = plan + this.#deps = deps + this.tasks = this.#initialTasks() + } + + /** + * Identifies the plan this run belongs to — destination and project, not the folder, + * which stays editable on the last step and is pushed onto the run instead. A caller + * handing a run back after a remount compares this against the plan it is rendering; + * computing the tag from *that* plan would make the check pass by construction. + */ + get planTag(): string { + return JSON.stringify(this.#plan.destination) + this.#plan.slug + } + + get workspaceId(): string | undefined { + return planWorkspaceId(this.#plan) + } + + /** + * True once this run created a workspace — the only case where deleting is ours to offer. + * + * Deliberately not satisfied by having *adopted* one. `workspace.owner` is enough to know + * a create can be skipped — the id is one this user made — but not enough to offer to + * delete it, because `owner` is an identity, not a run: a second import by the same person + * into the same id looks identical. Skipping a create wrongly is recoverable; deleting a + * workspace is not, so an adopted run finishes the import and leaves the undo to the run + * that did the creating. + */ + get createdWorkspace(): boolean { + return this.#workspaceCreated + } + + get failedCount(): number { + return this.results.filter((r) => !r.ok).length + } + + /** `installProject` reports migrations through the same channel as items, tagged by this + * prefix. Split so the import row counts what it imported and the migrate row counts + * what it migrated — one failure should not be attributed to both. */ + static readonly MIGRATION_PREFIX = 'data table: ' + get itemResults(): InstallResult[] { + return this.results.filter((r) => !r.path.startsWith(ImportExecution.MIGRATION_PREFIX)) + } + get migrationResults(): InstallResult[] { + return this.results.filter((r) => r.path.startsWith(ImportExecution.MIGRATION_PREFIX)) + } + + #initialTasks(): TaskView[] { + return plannedTasks(this.#plan) + } + + #set(key: string, status: TaskStatus, detail?: string) { + this.tasks = this.tasks.map((t) => (t.key === key ? { ...t, status, detail } : t)) + } + + /** + * Set when the user confirms leaving mid-run. Nothing here can abort a request already + * in flight — `installProject` takes no signal — so this stops the run at the next phase + * boundary instead, which is as far as "stops where it is" can honestly go. + * + * The workspace it created stays, and the run stays resumable: coming back to the link + * re-probes the instance, finds the workspace, and carries on rather than trying to create + * it a second time. + */ + #abandoned = false + + /** The user has left. Stop at the next phase boundary and leave the run resumable. */ + abandon() { + this.#abandoned = true + } + + /** + * Runs every task that has not already succeeded. Safe to call again after a failure: the + * destination is asked what it already holds, so a workspace that exists is entered rather + * than recreated and an item that landed is skipped rather than rewritten. What a second + * run costs is the reads, not the writes. + */ + async run(): Promise { + if (this.running) return + this.#abandoned = false + this.running = true + runState.active = true + this.error = undefined + try { + const workspace = await this.#ensureWorkspace() + if (!workspace || this.#abandoned) return + const exportData = await this.#ensureExport(workspace) + if (!exportData || this.#abandoned) return + await this.#import(workspace, exportData) + } finally { + this.running = false + runState.active = false + } + } + + /** + * The folder is the one part of the plan still editable on the last step, so a + * retry after changing it must import where the field now says — not where the + * first attempt was told to. + */ + setFolder(folder: string) { + this.#plan = { ...this.#plan, folder } + } + + async #ensureWorkspace(): Promise { + const d = this.#plan.destination + if (!d) { + this.error = 'No destination' + return undefined + } + if (d.kind === 'existing') { + if (!d.workspaceId) { + this.error = 'No destination workspace' + return undefined + } + switchWorkspace(d.workspaceId) + await this.#adoptUser(d.workspaceId) + return d.workspaceId + } + // Asked of the instance, not remembered. A retry after entering it failed must not + // run the create again — that would only report the id as taken by the workspace this + // run just made — and after a reload the field is false again while the workspace is + // still there. `ours` is what makes adopting it safe: an id that exists but belongs to + // someone else is not this run's work, and importing into it would be importing into + // a stranger's workspace. + const already = await probeWorkspace(d.id, await this.#email()) + if (!this.#workspaceCreated && !(already.exists && already.ours)) { + this.#set('create', 'running') + try { + await WorkspaceService.createWorkspace({ + requestBody: { id: d.id, name: d.name, username: d.username } + }) + } catch (e: any) { + const detail = e?.body?.toString?.() ?? String(e) + this.#set('create', 'failed', detail) + this.error = `Could not create the workspace: ${detail}` + return undefined + } + this.#workspaceCreated = true + } + try { + await enterNewWorkspace(d.id) + await this.#adoptUser(d.id) + } catch (e: any) { + const detail = e?.body?.toString?.() ?? String(e) + this.#set('create', 'failed', `created, but could not be entered: ${detail}`) + this.error = `Created ${d.id}, but could not enter it: ${detail}` + return undefined + } + this.#set('create', 'done') + return d.id + } + + async #ensureExport(workspace: string): Promise { + if (this.#export) { + this.#set('fetch', 'done') + return this.#export + } + this.#set('fetch', 'running') + try { + // Workspace-scoped on purpose: this is the same proxy the rest of the app + // uses, so a private hub reachable only from the server still works. + const res = await fetch( + `/api/w/${encodeURIComponent(workspace)}/hub/projects/${encodeURIComponent(this.#plan.slug)}/export`, + { credentials: 'include', headers: { accept: 'application/json' } } + ) + const text = await res.text() + if (!res.ok) throw new Error(`export ${res.status}: ${text}`) + this.#export = JSON.parse(text) as ProjectExport + this.#set('fetch', 'done', `${itemCount(this.#export)} items`) + return this.#export + } catch (e: any) { + const detail = e?.message ?? String(e) + this.#set('fetch', 'failed', detail) + this.error = `Could not read the project: ${detail}` + return undefined + } + } + + /** + * Leave the checklist saying what actually happened, from wherever the run stopped. + * + * Reached from every point after `import` goes `running`, so nothing is left spinning on a + * run that has ended. A partial import is failed rather than done: calling it done reports + * a clean import over items that never started, and the resumed step offers Continue where + * it should offer Retry. + */ + #settleAbandoned() { + const landed = this.itemResults.length + this.#set('import', 'failed', `stopped after ${landed} item${landed === 1 ? '' : 's'}`) + // The migrate row is appended once the review settles and set running by + // `onMigrationsStart`. Stopping before its loop leaves it spinning forever, which + // reads as work still in progress on a run that has stopped. + if (this.tasks.some((t) => t.key === 'migrate' && t.status === 'running')) { + this.#set('migrate', 'pending') + } + this.error = 'Import stopped. Retry to import what is left.' + } + + async #import(workspace: string, exportData: ProjectExport): Promise { + this.#set('import', 'running') + const folder = this.#plan.folder?.trim() || exportData.project.slug + + let migrations: ProjectMigration[] | null + try { + migrations = await this.#deps.reviewMigrations(workspace, exportData.migrations ?? []) + } catch (e: any) { + this.#set('import', 'failed', String(e)) + this.error = `Could not plan the data table migrations: ${e}` + return + } + if (migrations === null) { + // The user backed out at the missing-data-table warning. + this.#set('import', 'skipped', 'cancelled at the data table warning') + this.error = 'Import cancelled.' + return + } + + // Appended only once the review has settled: until then nothing knows whether any + // migration is runnable here, and a row that might not apply is worse than none. + if (migrations.length && !this.tasks.some((t) => t.key === 'migrate')) { + const n = migrations.length + this.tasks = [ + ...this.tasks, + { + key: 'migrate', + label: `Run ${n} data table migration${n === 1 ? '' : 's'}`, + status: 'pending' + } + ] + } + + this.results = [] + // Asked every run, not only on a retry: the destination may be a workspace that already + // holds some of these paths, and a run interrupted halfway is indistinguishable from + // one that never started. On a workspace this run just created the answer is empty and + // nothing is skipped. + const alreadyPresent = await probeImportedPaths(workspace, folder, { + triggers: exportData.triggers.length > 0, + hasEeLicense: this.#deps.hasEeLicense + }) + // Settled, not just returned: `import` has been `running` since before the probe, and + // leaving it there shows a spinner on a run that has stopped, next to a Retry button. + if (this.#abandoned) { + this.#settleAbandoned() + return + } + try { + await installProject({ + alreadyPresent, + workspace, + exportData, + folder, + migrations, + hasEeLicense: this.#deps.hasEeLicense, + onResult: (r) => (this.results = [...this.results, r]), + onMigrationsStart: () => this.#set('migrate', 'running'), + // Checked before every write, so leaving mid-run stops the remaining items + // rather than only the phases. What already landed stays and is listed. + stopped: () => this.#abandoned + }) + } catch (e: any) { + this.#set('import', 'failed', String(e)) + this.error = `The import stopped: ${e}` + return + } + + // `installProject` returns early when `stopped` goes true, and it returns the same way + // it does on success — so the tail has to ask why. An abandoned run has written only + // what it got through; calling that `done` reports a clean import over items that + // never started, and the resumed step would offer Continue instead of Retry. + if (this.#abandoned) { + this.#settleAbandoned() + return + } + + const items = this.itemResults + const failed = items.filter((r) => !r.ok).length + const skipped = items.filter((r) => r.skipped).length + // Three outcomes, so the row says which: written, left alone because it was already + // there, and failed. Rolling the second into the first would report an import that + // did not happen. + const wrote = items.length - failed - skipped + const parts: string[] = [] + if (wrote > 0 || (failed === 0 && skipped === 0)) parts.push(`${wrote} imported`) + if (skipped > 0) parts.push(`${skipped} already there`) + if (failed > 0) parts.push(`${failed} failed`) + this.#set('import', failed > 0 ? 'failed' : 'done', parts.join(', ')) + + const migrated = this.migrationResults + const badMigrations = migrated.filter((r) => !r.ok).length + if (migrated.length) { + const badly = migrated.filter((r) => !r.ok) + this.#set( + 'migrate', + badly.length ? 'failed' : 'done', + badly.length ? badly.map((r) => r.error).join('; ') : undefined + ) + } + // A partial import is finished, not broken: the items that landed are real, + // and the failures are listed. Only a hard stop leaves `done` false. + this.done = true + // Both kinds of failure, because `error` is what offers Retry. A migration that fails + // against an existing data table is as retryable as a failed item; leaving it out here + // would present the run as a clean finish with no way to run it again. + const problems: string[] = [] + if (failed > 0) problems.push(`${failed} item${failed === 1 ? '' : 's'} failed to import`) + if (badMigrations > 0) { + problems.push( + `${badMigrations} data table migration${badMigrations === 1 ? '' : 's'} failed` + ) + } + if (problems.length) this.error = `${problems.join(', ')}.` + } + + /** + * Load the membership for the workspace this run just entered. + * + * The wizard's page is reparented out of `(logged)`, so it never gets that + * layout's `getUserExt` call and `$userStore` stays undefined. Anything deciding + * what the user may do then reads "no user" and refuses: `canWrite` returns false + * without one, which renders every field of the resource editor disabled — the + * setup step could show the credentials to fill and then not let anyone fill them. + */ + async #adoptUser(workspace: string): Promise { + try { + userStore.set(await getUserExt(workspace)) + } catch { + // Leave it unset; the step degrades to read-only rather than failing the run. + } + } + + /** + * Who we are, for the ownership check. `$userStore` is workspace-scoped and this page is + * reparented out of `(logged)`, so it is unset until a run adopts one — `globalWhoami` is + * the identity that exists before any workspace does. + */ + async #email(): Promise { + const known = get(userStore)?.email + if (known) return known + try { + return (await UserService.globalWhoami()).email + } catch { + return undefined + } + } + + /** Undoes the one thing this run created, when the user asks for it. */ + async deleteCreatedWorkspace(): Promise { + const d = this.#plan.destination + if (!this.#workspaceCreated || d?.kind !== 'new') return + await WorkspaceService.deleteWorkspace({ workspace: d.id }) + // Leave the app pointing somewhere that exists. The run switched into the + // workspace it created; without this the store keeps the deleted id, the + // layout persists it to local/sessionStorage, and the next full page load + // fails `getUserExt` and logs the user out. + switchWorkspace(this.#priorWorkspace) + await refreshWorkspaceList() + this.#workspaceCreated = false + // The id is free again; the next `#ensureWorkspace` asks the instance and finds it + // gone, so a retry creates rather than adopts. + this.#set('create', 'pending') + this.done = false + this.results = [] + } +} + +function itemCount(e: ProjectExport): number { + return e.scripts.length + e.flows.length + e.apps.length + e.resources.length +} diff --git a/frontend/src/lib/importWizard/migrationOutcome.test.ts b/frontend/src/lib/importWizard/migrationOutcome.test.ts new file mode 100644 index 0000000000..87a23cc8ef --- /dev/null +++ b/frontend/src/lib/importWizard/migrationOutcome.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * A run that imported every item but failed a data table migration is not a clean finish. + * `error` is what offers Retry, so this is about whether the user can act on the failure — + * the `migrate` row already says it happened. + */ + +vi.mock('$lib/gen', () => ({ + WorkspaceService: { + createWorkspace: vi.fn(), + listDataTables: vi.fn(async () => []), + listWorkspaces: vi.fn(async () => []) + }, + UserService: { + whoami: vi.fn(async () => ({ username: 'u' })), + globalWhoami: vi.fn(async () => ({ email: 'u@example.com' })) + } +})) +vi.mock('$lib/storeUtils', () => ({ switchWorkspace: vi.fn() })) +vi.mock('./probe', async (orig) => ({ + ...(await orig()), + probeWorkspace: vi.fn(async () => ({ exists: false, ours: false })), + probeImportedPaths: vi.fn(async () => new Set()) +})) +vi.mock('$lib/user', () => ({ getUserExt: vi.fn(async () => ({ username: 'u' })) })) + +/** Whether the migration this run applies succeeds. The item writes always do. */ +const outcome = vi.hoisted(() => ({ migrationOk: true })) + +vi.mock('$lib/components/workspaceSettings/projectInstall', () => ({ + installProject: vi.fn(async (args: any) => { + args.onResult({ path: 'f/calendly/one', ok: true }) + if (args.migrations?.length) { + args.onMigrationsStart?.() + for (const m of args.migrations) { + args.onResult({ + path: `data table: ${m.datatable_name}`, + ok: outcome.migrationOk, + error: outcome.migrationOk ? undefined : 'relation already exists' + }) + } + } + }) +})) + +const EXPORT = { + project: { slug: 'calendly', name: 'Calendly', summary: '', readme: null }, + scripts: [], + flows: [], + apps: [], + resources: [], + triggers: [], + migrations: [] +} +vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: true, status: 200, text: async () => JSON.stringify(EXPORT) })) +) + +import { ImportExecution } from './execution.svelte' + +const PLAN = { slug: 'calendly', destination: { kind: 'existing' as const, workspaceId: 'ws-a' } } +const MIGRATION = { + datatable_name: 'main', + sql: 'CREATE TABLE IF NOT EXISTS "calendly"."config" (id int)', + sql_down: '', + enabled: true +} +const deps = { reviewMigrations: async () => [MIGRATION], hasEeLicense: false } + +describe('a failed migration', () => { + beforeEach(() => { + outcome.migrationOk = true + }) + + it('marks the migrate row failed and leaves the run retryable', async () => { + outcome.migrationOk = false + const run = new ImportExecution(PLAN, deps as any) + await run.run() + expect(run.tasks.find((t) => t.key === 'migrate')?.status).toBe('failed') + // `error` is what the page reads to offer Retry: a `migrate` row saying failed with + // `error` unset sends it down the finished-run path with no way to run it again. + expect(run.error).toBeTruthy() + expect(run.error).toContain('migration') + }) + + it('says nothing went wrong when the migration succeeds', async () => { + const run = new ImportExecution(PLAN, deps as any) + await run.run() + expect(run.tasks.find((t) => t.key === 'migrate')?.status).toBe('done') + expect(run.error).toBeFalsy() + }) +}) diff --git a/frontend/src/lib/importWizard/plan.test.ts b/frontend/src/lib/importWizard/plan.test.ts new file mode 100644 index 0000000000..c81ed700bc --- /dev/null +++ b/frontend/src/lib/importWizard/plan.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest' + +import { + planProblem, + planToSearch, + planWorkspaceId, + readPlan, + type ImportPlan, + type WizardStep +} from './plan' + +// The wizard keeps no state of its own — the plan *is* the URL. Back, forward, the +// stepper and a pasted link are all the same operation, which only holds if this +// round trip is lossless. + +const read = (search: string) => readPlan(new URL(`https://x.dev/projects/import${search}`)) +const roundTrip = (plan: ImportPlan, step: WizardStep) => read(planToSearch(plan, step)) + +describe('readPlan / planToSearch round trip', () => { + const cases: Array<[string, ImportPlan, WizardStep]> = [ + ['bare slug', { slug: 'support-automation' }, 1], + ['new destination', { slug: 's', destination: { kind: 'new', id: 'w', name: 'W' } }, 2], + [ + 'new with username', + { slug: 's', destination: { kind: 'new', id: 'w', name: 'W', username: 'ada' } }, + 2 + ], + [ + 'existing destination', + { slug: 's', destination: { kind: 'existing', workspaceId: 'admins' } }, + 3 + ], + [ + 'folder carried', + { slug: 's', destination: { kind: 'existing', workspaceId: 'admins' }, folder: 'finance' }, + 3 + ] + ] + + for (const [name, plan, step] of cases) { + it(name, () => { + expect(roundTrip(plan, step)).toEqual({ plan, step }) + }) + } + + it('survives characters that need encoding', () => { + const plan: ImportPlan = { + slug: 'a b&c=d', + destination: { kind: 'new', id: 'w', name: 'Name & Co = 100%' } + } + expect(roundTrip(plan, 2)).toEqual({ plan, step: 2 }) + }) +}) + +describe('readPlan', () => { + it('reads legacy links that carry only a workspace', () => { + expect(read('?hub=s&workspace=admins').plan.destination).toEqual({ + kind: 'existing', + workspaceId: 'admins' + }) + }) + + it('reads legacy links that carry only new_workspace_id, falling the name back to the id', () => { + expect(read('?hub=s&new_workspace_id=w').plan.destination).toEqual({ + kind: 'new', + id: 'w', + name: 'w', + username: undefined + }) + }) + + it('clamps and rounds the step', () => { + expect(read('?hub=s&step=0').step).toBe(1) + // 4 is the optional setup step, so that is the ceiling. + expect(read('?hub=s&step=9').step).toBe(4) + expect(read('?hub=s&step=nope').step).toBe(1) + // A fractional step would match neither `=== 2` nor `=== 3`. + expect(read('?hub=s&step=2.5').step).toBe(3) + expect(read('?hub=s&step=2.4').step).toBe(2) + }) + + it('has no destination when nothing names one', () => { + expect(read('?hub=s').plan.destination).toBeUndefined() + }) +}) + +/** + * `create_workspace` takes the username it is given and writes it to `usr.username` without + * checking it — `Some("")` passes its only guard — so a blank or malformed one becomes a + * workspace whose owner has no usable name. The wizard is the last thing that can refuse it. + */ +describe('planProblem — the new-workspace username', () => { + const dest = (username?: string) => ({ + slug: 'calendly', + destination: { kind: 'new' as const, id: 'calendly', name: 'Calendly', username } + }) + + it('asks for nothing when the instance derives the username', () => { + expect(planProblem(dest(undefined))).toBeUndefined() + }) + + it('refuses a blank or whitespace-only username', () => { + expect(planProblem(dest(''))).toMatch(/needs a username/i) + expect(planProblem(dest(' '))).toMatch(/needs a username/i) + }) + + it('refuses one the backend would store verbatim but never accept elsewhere', () => { + expect(planProblem(dest('1bad'))).toMatch(/letters and numbers/i) + expect(planProblem(dest('a b'))).toMatch(/letters and numbers/i) + }) + + it('accepts a valid one', () => { + expect(planProblem(dest('guilhem'))).toBeUndefined() + }) +}) + +/** `validate_workspace_name` refuses > 50 chars, and only at creation — two steps later. */ +describe('planProblem — the new-workspace name length', () => { + const named = (name: string) => ({ + slug: 'calendly', + destination: { kind: 'new' as const, id: 'calendly', name } + }) + + it('accepts the longest name the backend takes', () => { + expect(planProblem(named('x'.repeat(50)))).toBeUndefined() + }) + + it('refuses one character more, rather than failing at create', () => { + expect(planProblem(named('x'.repeat(51)))).toMatch(/too long/i) + }) +}) + +describe('planProblem', () => { + it('names what is missing, in the order the wizard asks for it', () => { + expect(planProblem({ slug: '' })).toMatch(/No project/) + expect(planProblem({ slug: 's' })).toMatch(/Pick a destination/) + expect(planProblem({ slug: 's', destination: { kind: 'new', id: 'w', name: '' } })).toMatch( + /needs a name/ + ) + expect(planProblem({ slug: 's', destination: { kind: 'existing' } })).toMatch( + /Pick the workspace/ + ) + }) + + it('validates the id of either destination kind', () => { + expect( + planProblem({ slug: 's', destination: { kind: 'new', id: 'not valid', name: 'W' } }) + ).toMatch(/letters, numbers and dashes/) + // An existing id arrives from the URL just as a new one does. + expect( + planProblem({ slug: 's', destination: { kind: 'existing', workspaceId: '../admins' } }) + ).toMatch(/not a valid workspace id/) + }) + + it('rejects a folder name the import could not create', () => { + const base: ImportPlan = { slug: 's', destination: { kind: 'existing', workspaceId: 'admins' } } + expect(planProblem({ ...base, folder: 'ok_folder-1' })).toBeUndefined() + expect(planProblem({ ...base, folder: 'not ok' })).toMatch(/Folder/) + }) +}) + +describe('planWorkspaceId', () => { + it('is the id either kind of destination will end up in', () => { + expect(planWorkspaceId({ slug: 's', destination: { kind: 'new', id: 'w', name: 'W' } })).toBe( + 'w' + ) + expect( + planWorkspaceId({ slug: 's', destination: { kind: 'existing', workspaceId: 'admins' } }) + ).toBe('admins') + expect(planWorkspaceId({ slug: 's' })).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/importWizard/plan.ts b/frontend/src/lib/importWizard/plan.ts new file mode 100644 index 0000000000..f209041787 --- /dev/null +++ b/frontend/src/lib/importWizard/plan.ts @@ -0,0 +1,131 @@ +/** + * The import wizard's state, and the only thing the first two steps produce. + * + * Nothing is created, switched or written while the wizard is being filled in: + * steps 1 and 2 only ever describe *what should happen*, and the plan travels in + * the URL. That is what makes the back button and the stepper safe — going back + * is a URL change and cannot leave a half-made workspace behind, because there is + * no state anywhere else to unwind. + * + * `execution.svelte.ts` is the only thing that acts on a plan, and only when the + * user asks it to on the last step. + */ + +import { validateUsername } from '$lib/utils' +import { validateWorkspaceId, WORKSPACE_NAME_MAX_LENGTH } from '$lib/utils/workspaceId' + +/** + * `existing` carries no workspace until one is picked: step 1 answers *which kind* + * of destination and step 2 answers *which one*, and the plan has to be able to + * hold the state in between. Encoding that gap as an absent destination would make + * "chose an existing workspace, has not picked it" and "answered nothing yet" the + * same URL. + */ +export type ImportDestination = + | { kind: 'new'; name: string; id: string; username?: string } + | { kind: 'existing'; workspaceId?: string } + +export interface ImportPlan { + /** The hub project being imported. */ + slug: string + /** Undefined until step 1 has been answered. */ + destination?: ImportDestination + /** Folder the items land in; defaults to the project slug at execution time. */ + folder?: string +} + +/** 4 is the optional setup step, reached only when the import leaves work to do. */ +export type WizardStep = 1 | 2 | 3 | 4 + +export const FOLDER_NAME_RE = /^[a-zA-Z_0-9-]+$/ + +export function readPlan(url: URL): { plan: ImportPlan; step: WizardStep } { + const params = url.searchParams + const slug = params.get('hub') ?? '' + + const newId = params.get('new_workspace_id') + const existing = params.get('workspace') + // `destination` records the step 1 answer on its own; the older links that only + // carried `workspace` or `new_workspace_id` still read as the kind they imply. + const kind = params.get('destination') + const destination: ImportDestination | undefined = + newId || kind === 'new' + ? { + kind: 'new', + id: newId ?? '', + name: params.get('new_workspace_name') || newId || '', + username: params.get('username') || undefined + } + : existing || kind === 'existing' + ? { kind: 'existing', workspaceId: existing || undefined } + : undefined + + // Rounded as well as clamped: the steps are compared with `>` and `===`, so a + // fractional `?step=2.5` would clamp to 2.5 and match neither. + const raw = Number(params.get('step') ?? 1) + const step = (Number.isFinite(raw) ? Math.min(4, Math.max(1, Math.round(raw))) : 1) as WizardStep + + return { plan: { slug, destination, folder: params.get('folder') || undefined }, step } +} + +export function planToSearch(plan: ImportPlan, step: WizardStep): string { + const params = new URLSearchParams({ hub: plan.slug }) + if (step !== 1) params.set('step', String(step)) + if (plan.destination) params.set('destination', plan.destination.kind) + if (plan.destination?.kind === 'new') { + params.set('new_workspace_id', plan.destination.id) + params.set('new_workspace_name', plan.destination.name) + if (plan.destination.username) params.set('username', plan.destination.username) + } else if (plan.destination?.workspaceId) { + params.set('workspace', plan.destination.workspaceId) + } + if (plan.folder) params.set('folder', plan.folder) + return `?${params}` +} + +/** + * Whether the plan can be executed. Returned as a reason rather than a boolean so + * the button can say why it is disabled instead of just being grey. + */ +export function planProblem(plan: ImportPlan): string | undefined { + if (!plan.slug) return 'No project to import' + const d = plan.destination + if (!d) return 'Pick a destination first' + if (d.kind === 'new') { + if (!d.name.trim()) return 'The new workspace needs a name' + // The backend refuses a longer one (`validate_workspace_name`), and only at + // creation — by then the wizard has already walked the user through two more steps. + if (d.name.trim().length > WORKSPACE_NAME_MAX_LENGTH) { + return `The name is too long (${d.name.trim().length} chars). Maximum is ${WORKSPACE_NAME_MAX_LENGTH}.` + } + const idProblem = validateWorkspaceId(d.id) + if (idProblem) return idProblem + // Only asked for when the instance does not derive it. `create_workspace` takes + // whatever it is given here — `Some("")` passes its only check — so a blank or + // malformed username is written to `usr.username` verbatim rather than refused. + // The sibling creator validates it; this is the same check. + if (d.username !== undefined) { + if (!d.username.trim()) return 'The new workspace needs a username' + const bad = validateUsername(d.username.trim()) + if (bad) return bad + } + } else if (!d.workspaceId) { + return 'Pick the workspace to import into' + } else if (validateWorkspaceId(d.workspaceId)) { + // The id arrives from the URL exactly as the new-workspace one does, so it gets + // the same check. Downstream it is interpolated into a credentialed same-origin + // API path and pushed into `workspaceStore`; an id that cannot name a workspace + // has no business reaching either. + return 'That is not a valid workspace id' + } + if (plan.folder && !FOLDER_NAME_RE.test(plan.folder)) { + return 'Folder: letters, digits, dashes and underscores' + } + return undefined +} + +/** The workspace the plan lands in, once one has been named or picked. */ +export function planWorkspaceId(plan: ImportPlan): string | undefined { + const d = plan.destination + return (d?.kind === 'new' ? d.id : d?.workspaceId) || undefined +} diff --git a/frontend/src/lib/importWizard/probe.test.ts b/frontend/src/lib/importWizard/probe.test.ts new file mode 100644 index 0000000000..1d66a0e14b --- /dev/null +++ b/frontend/src/lib/importWizard/probe.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' + +import { expectedTables } from './probe' + +/** + * `expectedTables` is inference over what the export happens to say, so it is the part of the + * probe that can be wrong while everything still type-checks. The network reads around it + * either answer or throw. + */ + +describe('expectedTables', () => { + // The shape `datatableSchemaSql.ts` emits, and what every published project carries. + const REAL = `BEGIN; +CREATE SCHEMA IF NOT EXISTS "calendly"; +CREATE TABLE IF NOT EXISTS "calendly"."config" ( + "id" SERIAL NOT NULL, + "host_name" text NOT NULL DEFAULT ''::text +); +CREATE TABLE IF NOT EXISTS "calendly"."bookings" ( "id" SERIAL NOT NULL ); +COMMIT;` + + it('reads every table a migration creates', () => { + expect(expectedTables(REAL)).toEqual(['calendly.config', 'calendly.bookings']) + }) + + it('does not mistake the schema for a table', () => { + expect(expectedTables(REAL)).not.toContain('calendly') + }) + + it('reads the form without IF NOT EXISTS', () => { + expect(expectedTables('CREATE TABLE "bitly"."links" (id int)')).toEqual(['bitly.links']) + }) + + it('is case- and whitespace-insensitive the way SQL is', () => { + expect(expectedTables('create table\n "a" . "b" (x int)')).toEqual(['a.b']) + }) + + it('reports each table once, however many times it is named', () => { + const sql = 'CREATE TABLE "a"."b" (x int); CREATE TABLE IF NOT EXISTS "a"."b" (x int);' + expect(expectedTables(sql)).toEqual(['a.b']) + }) + + /** + * The answer that keeps a caller honest. An unquoted or unqualified `CREATE TABLE` is + * something this cannot resolve — the schema would come from `search_path` at run time — + * so it reads as nothing expected, and the caller treats that as "cannot tell" rather than + * as "no tables, so the migration must have run". + */ + it('claims nothing about SQL it cannot resolve', () => { + expect(expectedTables('CREATE TABLE links (id int)')).toEqual([]) + expect(expectedTables('CREATE TABLE bitly.links (id int)')).toEqual([]) + expect(expectedTables('')).toEqual([]) + }) +}) diff --git a/frontend/src/lib/importWizard/probe.ts b/frontend/src/lib/importWizard/probe.ts new file mode 100644 index 0000000000..0df5173536 --- /dev/null +++ b/frontend/src/lib/importWizard/probe.ts @@ -0,0 +1,181 @@ +/** + * What is already true in the destination, read from the destination itself. + * + * Nothing here is remembered between runs, and nothing may be: a note saying "this run + * created workspace X" outlives the reload it was written for, but it also outlives the + * workspace it names, and the two are indistinguishable when it is read back. The plan in + * the URL says what should exist; these functions ask the instance what does. + * + * The one thing the instance cannot answer is *which tables a migration was supposed to + * create*. That is inferred from the SQL the project ships (`expectedTables`), because the + * export states only what to run, never what running it should produce. + */ + +import { ResourceService, ScriptService, FlowService, AppService, WorkspaceService } from '$lib/gen' +import type { ProjectMigration } from '$lib/components/workspaceSettings/projectBundle' +import { + presenceKey, + type ImportedKind +} from '$lib/components/workspaceSettings/projectInstall' +import { listAllWorkspaceTriggers } from '$lib/components/triggers/workspaceTriggersList' + +/** + * The tables a migration creates, as `schema.table`, read off its `CREATE TABLE` statements. + * + * Inference, not a contract: the export ships SQL and nothing else, so this is the only way + * to check a migration's work without a record of it having run. It deliberately reads only + * the shape this project's generator emits (`datatableSchemaSql.ts` always writes the + * schema-qualified, quoted form) — anything hand-edited into a different shape simply reads + * as no expected tables, which makes the caller fall back to "cannot tell" rather than to a + * confident wrong answer. + */ +export function expectedTables(sql: string): string[] { + const out: string[] = [] + const re = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?"([^"]+)"\s*\.\s*"([^"]+)"/gi + let m: RegExpExecArray | null + while ((m = re.exec(sql)) !== null) out.push(`${m[1]}.${m[2]}`) + return [...new Set(out)] +} + +export interface WorkspaceState { + /** The user is a member of a workspace with this id. */ + exists: boolean + /** …and it is one they own, so a run of theirs is what made it. */ + ours: boolean +} + +/** + * Whether the destination workspace is there, and whether it is the caller's. + * + * `listWorkspaces` answers both in one call: it returns only workspaces the caller is a + * member of, each carrying the `owner` email set at creation. Ownership is what makes + * skipping a create safe — an id that exists but belongs to someone else is not a workspace + * this run made, and importing into it would be importing into a stranger's. + */ +export async function probeWorkspace( + id: string, + email: string | undefined +): Promise { + try { + const mine = await WorkspaceService.listWorkspaces() + const found = mine.find((w) => w.id === id) + if (!found) return { exists: false, ours: false } + return { exists: true, ours: !!email && found.owner === email } + } catch { + // Cannot tell. Reported as absent so the caller creates rather than adopts: a failed + // create is a clear error, adopting the wrong workspace is a silent one. + return { exists: false, ours: false } + } +} + +const PROBE_PAGE_SIZE = 100 +/** + * A stop, not a limit on what may be imported: 100 pages is 10,000 items in one folder, far + * past any project, and a paginating endpoint that never returns a short page would otherwise + * loop forever. Hitting it under-reports, which only ever means "still to do". + */ +const MAX_PROBE_PAGES = 100 + +/** + * Which of the items the import would write are already there, as `presenceKey` keys. + * + * Scoped by `pathStart` to the import's own folder, so the four path-bearing kinds are four + * small reads rather than a workspace scan. Presence is not provenance — importing into an + * existing workspace that already held a path reads the same as having imported it — so + * callers use this to decide what is left to do, never to claim credit for what is there. + * + * Triggers are asked for separately and only when the project ships some: they have no + * prefix-filtered list endpoint, so answering for them means one call per trigger kind, and a + * project without triggers should not pay for that. + */ +export async function probeImportedPaths( + workspace: string, + folder: string, + opts?: { triggers?: boolean; hasEeLicense?: boolean } +): Promise> { + const pathStart = `f/${folder}/` + const found = new Set() + /** + * Every page, not the first one. These endpoints paginate and default to 30 rows, so a + * single call answers for a small project and quietly under-reports a large one — leaving + * everything past the first page to be created again, and rejected as already existing. + */ + const collectAll = async ( + kind: ImportedKind, + list: (page: number) => Promise + ): Promise => { + for (let page = 1; page <= MAX_PROBE_PAGES; page++) { + const rows = ((await list(page)) as { path?: string }[] | undefined) ?? [] + for (const r of rows) if (r.path) found.add(presenceKey(kind, r.path)) + if (rows.length < PROBE_PAGE_SIZE) return + } + } + const calls: Promise[] = [ + collectAll('script', (page) => + ScriptService.listScripts({ workspace, pathStart, page, perPage: PROBE_PAGE_SIZE }) + ), + collectAll('flow', (page) => + FlowService.listFlows({ workspace, pathStart, page, perPage: PROBE_PAGE_SIZE }) + ), + collectAll('app', (page) => + AppService.listApps({ workspace, pathStart, page, perPage: PROBE_PAGE_SIZE }) + ), + collectAll('resource', (page) => + ResourceService.listResource({ workspace, pathStart, page, perPage: PROBE_PAGE_SIZE }) + ) + ] + if (opts?.triggers) { + // `failedKinds` is deliberately ignored: a kind that could not be listed leaves its + // triggers out of the set, and a missing key only ever means "still to do". + calls.push( + listAllWorkspaceTriggers(workspace, { + includeEeOnly: opts.hasEeLicense === true + }).then(({ triggers }) => { + for (const t of triggers) { + if (t.path?.startsWith(pathStart)) found.add(presenceKey(`trigger:${t.kind}`, t.path)) + } + }) + ) + } + // One kind failing should narrow the answer, not lose the others: a missing key only ever + // means "still to do", which is the safe direction. + await Promise.allSettled(calls) + return found +} + +/** + * Whether every table a data table's migrations create is in it. + * + * The ground truth for "did this run", and the only one that covers both paths + * `applyOneMigration` takes: it records a migration when the data table has migrations + * enabled, and otherwise runs the SQL once as a job that nothing remembers. The tables + * outlive both. + * + * Every migration for one data table at once: they all target the same schema, so asking + * per migration would introspect the same database N times for one answer. + * + * `undefined` means the question could not be answered — the schema was unreadable, or the + * SQL named no tables this can recognise. Distinct from `false`, because "not there" invites + * a caller to run the migration and "cannot tell" does not. + */ +export async function probeMigrationsApplied( + workspace: string, + datatableName: string, + migrations: ProjectMigration[] +): Promise { + const wanted = [...new Set(migrations.flatMap((m) => expectedTables(m.sql ?? '')))] + if (wanted.length === 0) return undefined + try { + const schema = (await WorkspaceService.getDatatableFullSchema({ + workspace, + requestBody: { source: `datatable://${datatableName}` } + })) as Record> + const present = new Set() + for (const [schemaName, tables] of Object.entries(schema ?? {})) { + for (const table of Object.keys(tables ?? {})) present.add(`${schemaName}.${table}`) + } + return wanted.every((t) => present.has(t)) + } catch { + return undefined + } +} diff --git a/frontend/src/lib/importWizard/probePaging.test.ts b/frontend/src/lib/importWizard/probePaging.test.ts new file mode 100644 index 0000000000..17e7fb9a11 --- /dev/null +++ b/frontend/src/lib/importWizard/probePaging.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * The list endpoints paginate. A probe that reads one page answers correctly for a small + * project and under-reports a large one, which puts every item past the first page back + * through a create call that rejects it. + */ + +const calls = vi.hoisted(() => ({ script: [] as { page?: number; perPage?: number }[] })) +/** What the destination's trigger listing answers with, per test. */ +const triggerRows = vi.hoisted(() => ({ rows: [] as { kind: string; path: string }[] })) + +/** 150 scripts in the folder — more than one page at any page size the probe might pick. */ +const ALL = Array.from({ length: 150 }, (_, i) => ({ path: `f/calendly/s${i}` })) + +vi.mock('$lib/gen', () => ({ + ScriptService: { + listScripts: vi.fn(async (args: any) => { + calls.script.push({ page: args.page, perPage: args.perPage }) + const per = args.perPage ?? 30 + return ALL.slice((args.page - 1) * per, args.page * per) + }) + }, + FlowService: { listFlows: vi.fn(async () => []) }, + AppService: { listApps: vi.fn(async () => []) }, + ResourceService: { listResource: vi.fn(async () => []) }, + WorkspaceService: { listWorkspaces: vi.fn(async () => []), getDatatableFullSchema: vi.fn() } +})) +vi.mock('$lib/components/triggers/workspaceTriggersList', () => ({ + listAllWorkspaceTriggers: vi.fn(async () => ({ triggers: triggerRows.rows, failedKinds: [] })) +})) + +import { probeImportedPaths } from './probe' +import { presenceKey } from '$lib/components/workspaceSettings/projectInstall' + +describe('probeImportedPaths paging', () => { + beforeEach(() => { + calls.script = [] + }) + + it('reads every page, not just the first', async () => { + const found = await probeImportedPaths('w', 'calendly') + expect(found.size).toBe(150) + expect(found.has(presenceKey('script', 'f/calendly/s0'))).toBe(true) + // The one that a single-page probe misses, and would then try to create again. + expect(found.has(presenceKey('script', 'f/calendly/s149'))).toBe(true) + }) + + it('stops on the first short page rather than asking forever', async () => { + await probeImportedPaths('w', 'calendly') + const pages = calls.script.map((c) => c.page) + expect(pages).toEqual([1, 2]) + expect(new Set(calls.script.map((c) => c.perPage))).toEqual(new Set([100])) + }) +}) + +describe('trigger presence keys', () => { + beforeEach(() => { + triggerRows.rows = [] + }) + + /** + * Each trigger kind is its own table keyed on `(path, workspace_id)`, so a workspace can + * hold a schedule and an HTTP trigger both called `f/calendly/sync`. The probe builds the + * keys and `installProject` reads them, so they have to agree that those are two things — + * key on the path alone and the one that exists reports the other as already imported. + */ + it('keys a trigger by its kind, so one kind cannot answer for another', async () => { + triggerRows.rows = [{ kind: 'http', path: 'f/calendly/sync' }] + const found = await probeImportedPaths('w', 'calendly', { triggers: true }) + expect(found.has(presenceKey('trigger:http', 'f/calendly/sync'))).toBe(true) + // The schedule the project also ships at that path has not been imported. + expect(found.has(presenceKey('trigger:schedule', 'f/calendly/sync'))).toBe(false) + }) +}) diff --git a/frontend/src/lib/schema.test.ts b/frontend/src/lib/schema.test.ts new file mode 100644 index 0000000000..195a7b9478 --- /dev/null +++ b/frontend/src/lib/schema.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest' +import { argsToJsonPayload } from './schema' +import type { Schema } from './common' + +const schemaOf = (...names: string[]): Schema => + ({ + $schema: undefined, + type: 'object', + properties: Object.fromEntries(names.map((n) => [n, { type: 'string' }])), + required: [] + }) as Schema + +describe('argsToJsonPayload', () => { + it('spells out every schema property in schema order, unset ones as null', () => { + // `0`, `false` and `''` are values, not gaps: only a missing arg becomes `null`. + expect(argsToJsonPayload(schemaOf('a', 'b', 'c'), { c: 0, a: false })).toBe( + JSON.stringify({ a: false, b: null, c: 0 }, null, '\t') + ) + }) + + it('keeps args the schema does not declare, after the declared ones', () => { + expect(argsToJsonPayload(schemaOf('a'), { z: 9, a: 1 })).toBe( + JSON.stringify({ a: 1, z: 9 }, null, '\t') + ) + }) + + it('falls back to the schema default for an absent arg, but not over an explicit null', () => { + // `args` only carries defaults once a `SchemaForm` has mounted for that schema, and the + // JSON view alone never mounts one — seeding `null` there would commit `null` over the + // argument's default on the first keystroke. + const schema = schemaOf('a', 'b') + schema.properties.a.default = 'hi' + schema.properties.b.default = 42 + expect(argsToJsonPayload(schema, {})).toBe(JSON.stringify({ a: 'hi', b: 42 }, null, '\t')) + expect(argsToJsonPayload(schema, { a: null })).toBe( + JSON.stringify({ a: null, b: 42 }, null, '\t') + ) + }) + + it('keeps declared args named after Object.prototype members', () => { + // A plain `nargs[key]` read returns the inherited function for an unset `constructor`, + // and `JSON.stringify` drops function-valued properties — the argument would vanish. + expect(argsToJsonPayload(schemaOf('constructor', 'toString', 'ok'), {})).toBe( + JSON.stringify({ constructor: null, toString: null, ok: null }, null, '\t') + ) + }) + + it('keeps undeclared args named after Object.prototype members', () => { + // On a plain `{}` accumulator, `'constructor' in payload` is true before anything is + // assigned to it. + expect(argsToJsonPayload(undefined, { constructor: 'x', toString: 'y', ok: 1 })).toBe( + JSON.stringify({ constructor: 'x', toString: 'y', ok: 1 }, null, '\t') + ) + }) + + it('handles a missing schema or missing args', () => { + expect(argsToJsonPayload(undefined, undefined)).toBe('{}') + expect(argsToJsonPayload(schemaOf('a'), undefined)).toBe( + JSON.stringify({ a: null }, null, '\t') + ) + }) +}) diff --git a/frontend/src/lib/schema.ts b/frontend/src/lib/schema.ts index c96b635202..36faa8a590 100644 --- a/frontend/src/lib/schema.ts +++ b/frontend/src/lib/schema.ts @@ -52,3 +52,33 @@ export function schemaToObject(schema: Schema, args: Record): Objec }) return object } + +/** Args as the JSON payload the JSON editor starts from. Every schema property is spelled out, + * so an argument with no value yet still shows its name; args the schema does not declare are + * kept, since what the editor holds replaces the args wholesale on the next keystroke. */ +export function argsToJsonPayload( + schema: Schema | undefined, + args: Record | undefined +): string { + const nargs = args ?? {} + // Null prototype: an arg named after an `Object.prototype` member (`constructor`, + // `toString`) has to be an own key here, or the `in` check below reads it as already + // present and its value never reaches the payload. + const payload: Record = Object.create(null) + const props = schema?.properties ?? {} + // Schema order first, so the payload reads like the form it replaces. + for (const key of Object.keys(props)) { + // Own-property read: an arg named after an `Object.prototype` member (`constructor`, + // `toString`) would otherwise come back as the inherited function, which `JSON.stringify` + // drops. An arg that is merely absent falls back to the schema default — `args` only + // carries defaults once a `SchemaForm` has mounted, which the JSON view alone never does. + payload[key] = + (Object.prototype.hasOwnProperty.call(nargs, key) ? nargs[key] : props[key]?.default) ?? null + } + for (const [key, value] of Object.entries(nargs)) { + if (!(key in payload)) { + payload[key] = value + } + } + return JSON.stringify(payload, null, '\t') +} diff --git a/frontend/src/lib/utils/templateLiteral.test.ts b/frontend/src/lib/utils/templateLiteral.test.ts new file mode 100644 index 0000000000..3aa16ecf38 --- /dev/null +++ b/frontend/src/lib/utils/templateLiteral.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest' +import { escapeTemplateBackticks, unescapeTemplateBackticks } from './templateLiteral' + +// Template mode stores its value as a JS template literal, so backticks in the text have to be +// escaped. Escaping them inside `${...}` too is what broke nested template literals: a backslash +// is a syntax error in expression position. +describe('escapeTemplateBackticks', () => { + const nested = + '--input ${flow_input.iter.value}${results.config ? ` --config ${results.config}` : ""} --host ${flow_input.hostname}' + + it('leaves a nested template literal inside an interpolation intact', () => { + const expr = '`' + escapeTemplateBackticks(nested) + '`' + expect(expr).not.toContain('\\`') + expect( + new Function('flow_input', 'results', 'return ' + expr)( + { iter: { value: 'data.csv' }, hostname: 'host1' }, + { config: '/tmp/cfg.json' } + ) + ).toBe('--input data.csv --config /tmp/cfg.json --host host1') + }) + + it('still escapes a backtick in the literal text', () => { + expect(escapeTemplateBackticks('a ` b')).toBe('a \\` b') + expect(escapeTemplateBackticks('a ` ${x} ` b')).toBe('a \\` ${x} \\` b') + }) + + it('leaves an escaped interpolation as literal text', () => { + // `\\${...}` is escaped in the template source, so the backticks inside it are literal + // text and still need escaping. + expect(escapeTemplateBackticks('\\${foo `bar`}')).toBe('\\${foo \\`bar\\`}') + expect(unescapeTemplateBackticks('\\${foo \\`bar\\`}')).toBe('\\${foo `bar`}') + expect( + () => new Function('return `' + escapeTemplateBackticks('\\${foo `bar`}') + '`') + ).not.toThrow() + }) + + // Braces, quotes, regex literals and comments all hide backticks and braces from anything + // short of a real lexer, which is why the parser decides. + it('handles text only a lexer can read correctly', () => { + const inputs = [ + '${ x["}"] } `', + "${ f({ a: '`' }) } `", + "${ x.replace(/'/g, '') } `", + '${ /* ` */ x } `', + '{"match": ${/{/.test(flow_input.x)}, "literal": "`x`"}' + ] + for (const v of inputs) { + expect(() => new Function('return `' + escapeTemplateBackticks(v) + '`')).not.toThrow() + expect(unescapeTemplateBackticks(escapeTemplateBackticks(v))).toBe(v) + } + }) + + // The failure that matters most is not a syntax error but literal text quietly becoming code: + // here the author's `+ flow_input.y +` must stay text rather than being evaluated. + it('never lets literal text escape into the expression', () => { + const v = '{"match": ${/{/.test(flow_input.x)}, "literal": "` + flow_input.y + `"}' + const evaluated = new Function('flow_input', 'return `' + escapeTemplateBackticks(v) + '`')({ + x: 'x', + y: 'LEAKED' + }) + expect(evaluated).not.toContain('LEAKED') + expect(evaluated).toContain('` + flow_input.y + `') + }) + + // An expression the old blanket rule broke — it escaped backticks inside `${...}`, which + // does not parse — comes back as the author typed it, instead of showing the backslashes + // and escaping them one deeper on every save. + it('heals an expression the old rule broke', () => { + const broken = '-p ${a}${b ? \\` --x ${c}\\` : ""}' + const clean = '-p ${a}${b ? ` --x ${c}` : ""}' + expect(unescapeTemplateBackticks(broken)).toBe(clean) + expect(escapeTemplateBackticks(clean)).toBe(clean) + }) + + // A text that already parses is left alone: an over-escaped legacy value and a backslash the + // author wrote are the same bytes, so healing on looks alone would drop a real character. + it('leaves an expression that already parses alone', () => { + const run = (body: string) => new Function('return `' + body + '`')() + for (const stored of ['${"\\`"}', '${"a\\\\`"}']) { + expect(unescapeTemplateBackticks(stored)).toBe(stored) + expect(run(escapeTemplateBackticks(unescapeTemplateBackticks(stored)))).toBe(run(stored)) + } + }) + + // ...but a backtick escaped inside a nested template belongs there and must survive. + it('leaves an escaped backtick that is inside a nested template alone', () => { + const stored = '${cond ? `a\\`b` : ""}' + expect(unescapeTemplateBackticks(stored)).toBe(stored) + }) + + // Escapes the author wrote inside a nested template are not the old rule's doing, and + // stripping them changes what the expression means — here into chained tagged templates, + // which throw. Only a text whose backticks are *all* escaped came from the old rule. + it('leaves escapes that belong to a nested template alone', () => { + const run = (body: string) => new Function('flag', 'value', 'return `' + body + '`')(true, 'X') + for (const stored of ['${flag ? `\\`\\`${value}\\`\\`` : ""}', '${flag ? `a\\`b` : ""}']) { + expect(unescapeTemplateBackticks(stored)).toBe(stored) + expect(escapeTemplateBackticks(unescapeTemplateBackticks(stored))).toBe(stored) + expect(run(stored)).toBe(run(escapeTemplateBackticks(unescapeTemplateBackticks(stored)))) + } + }) + + it('round-trips through unescapeTemplateBackticks', () => { + for (const v of [ + nested, + 'a ` b', + '${ x["}"] } `', + 'plain', + '${a}${b}', + '\\${a}', + '${cond ? `a\\`b` : ""}' + ]) { + expect(unescapeTemplateBackticks(escapeTemplateBackticks(v))).toBe(v) + } + }) + // The guarantee that matters: opening a flow in the editor and saving it back must not change + // the stored expression, including for a value that only the all-or-nothing fallback can + // handle. + it('never rewrites the stored expression on a view/save cycle', () => { + const inputs = [ + '{"match": ${/{/.test(flow_input.x)}, "literal": "`x`"}', + '${cond ? `a\\`b` : ""}', + "${ x.replace(/'/g, '') } `", + 'a ` b', + '${ /* ` */ x } `' + ] + for (const v of inputs) { + const stored = escapeTemplateBackticks(v) + expect(() => new Function('return `' + stored + '`')).not.toThrow() + expect(escapeTemplateBackticks(unescapeTemplateBackticks(stored))).toBe(stored) + } + }) +}) diff --git a/frontend/src/lib/utils/templateLiteral.ts b/frontend/src/lib/utils/templateLiteral.ts new file mode 100644 index 0000000000..388169ea95 --- /dev/null +++ b/frontend/src/lib/utils/templateLiteral.ts @@ -0,0 +1,59 @@ +import { parseExpressionAt } from 'acorn' + +/** + * Template mode stores what the author typed as a JS template literal, so the text is spliced + * between backticks. A backtick in the literal part has to be escaped or it ends the literal + * early — but one inside a `${...}` must not be, since a backslash is a syntax error in + * expression position and a nested template literal there is legitimate. + * + * Telling those apart means knowing where each `${...}` ends, which needs a real JS lexer: + * regex literals, comments and nested templates all hide braces and backticks from anything + * simpler. So rather than escaping selectively, ask the parser whether the text already reads as + * one template literal. If it does, it needs no escaping at all; if it does not, escape every + * backtick, which is what this did before nested templates were supported. + * + * Known limitation: a value mixing a bare literal backtick with a nested template cannot be + * expressed either way, and gets the all-or-nothing fallback. Escaping the literal one by hand + * makes the whole value parse and it is then kept verbatim. + */ +function isCompleteTemplateBody(text: string): boolean { + const source = '`' + text + '`' + try { + const node = parseExpressionAt(source, 0, { ecmaVersion: 'latest' }) + // The type is what rejects a body that closes its own literal early: `` ` + evil() + ` `` + // parses, but as a concatenation, and would evaluate the author's literal text. The span + // rejects a body that stops short, like `` `a` x ``. + return node.type === 'TemplateLiteral' && node.start === 0 && node.end === source.length + } catch { + return false + } +} + +/** Escape `text` so it can be wrapped in backticks and mean what the author typed. */ +export function escapeTemplateBackticks(text: string): string { + // No backtick means nothing to escape and nothing to decide, which is every ordinary value. + if (!text.includes('`')) { + return text + } + return isCompleteTemplateBody(text) ? text : text.replaceAll('`', '\\`') +} + +/** Inverse of {@link escapeTemplateBackticks}, for turning an expression back into a template. */ +export function unescapeTemplateBackticks(text: string): string { + if (!text.includes('`')) { + return text + } + const unescaped = text.replaceAll('\\`', '`') + if (escapeTemplateBackticks(unescaped) === text) { + return unescaped + } + // Only an expression the old blanket rule *broke* is healed: it escaped backticks inside + // `${...}` too, which does not parse, while the unescaped form does. A text that already + // parses is left alone even if it looks over-escaped, because the two are indistinguishable + // from the text alone and guessing changes what the expression means — `${"a\\\\`"}` is a + // backslash the author wrote, not one the old rule added. + if (!isCompleteTemplateBody(text) && isCompleteTemplateBody(unescaped)) { + return unescaped + } + return text +} diff --git a/frontend/src/lib/utils/workspaceId.test.ts b/frontend/src/lib/utils/workspaceId.test.ts new file mode 100644 index 0000000000..8972498138 --- /dev/null +++ b/frontend/src/lib/utils/workspaceId.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' + +import { WORKSPACE_ID_MAX_LENGTH, toWorkspaceId, validateWorkspaceId } from './workspaceId' + +// This module was extracted so the import wizard and the create-workspace form +// could not drift on what a workspace id is. These pin the contract they share. + +describe('validateWorkspaceId', () => { + it('accepts dash-separated groups of word characters', () => { + for (const id of ['a', 'prod', 'my_ws', 'a-b-c', 'a1-2b', 'wm-fork-x']) { + expect(validateWorkspaceId(id), id).toBeUndefined() + } + }) + + it('rejects leading, trailing and doubled dashes, and anything outside \\w', () => { + for (const id of ['-a', 'a-', 'a--b', '', 'a b', 'a.b', 'a/b', 'é']) { + expect(validateWorkspaceId(id), id).toMatch(/letters, numbers and dashes/) + } + }) + + it('measures length against effectiveId, but the character rule against what was typed', () => { + const typed = 'a'.repeat(45) + // The typed id is fine on its own... + expect(validateWorkspaceId(typed)).toBeUndefined() + // ...but a fork submits it prefixed, and that is what the backend stores. + const prefixed = `wm-fork-${typed}` + expect(prefixed.length).toBeGreaterThan(WORKSPACE_ID_MAX_LENGTH) + expect(validateWorkspaceId(typed, prefixed)).toMatch(/too long/) + }) + + it('reports the character problem before the length one', () => { + expect(validateWorkspaceId('a b'.padEnd(80, 'c'))).toMatch(/letters, numbers and dashes/) + }) + + it('allows exactly the maximum length', () => { + const id = 'a'.repeat(WORKSPACE_ID_MAX_LENGTH) + expect(validateWorkspaceId(id)).toBeUndefined() + expect(validateWorkspaceId(id + 'a')).toMatch(/too long/) + }) +}) + +describe('toWorkspaceId', () => { + it('produces something validateWorkspaceId accepts', () => { + for (const raw of [ + 'Support automation', + ' GitHub Release Dashboard ', + 'a//b__c', + 'Ünïcødé nåme', + '---leading and trailing---', + 'MiXeD CaSe' + ]) { + const id = toWorkspaceId(raw) + expect(validateWorkspaceId(id), `${raw} -> ${id}`).toBeUndefined() + } + }) + + it('lowercases, collapses runs into single dashes, and trims them', () => { + expect(toWorkspaceId('Support automation')).toBe('support-automation') + expect(toWorkspaceId('a//b')).toBe('a-b') + expect(toWorkspaceId(' spaced out ')).toBe('spaced-out') + }) + + it('clips to the maximum length without leaving a trailing dash', () => { + // Slicing at 50 lands mid-separator here; the result must still be valid. + const raw = `${'a'.repeat(WORKSPACE_ID_MAX_LENGTH - 1)} tail` + const id = toWorkspaceId(raw) + expect(id.length).toBeLessThanOrEqual(WORKSPACE_ID_MAX_LENGTH) + expect(id.endsWith('-')).toBe(false) + expect(validateWorkspaceId(id)).toBeUndefined() + }) +}) + +describe('validateWorkspaceId — the reserved id', () => { + // `check_w_id_conflict` refuses it, and `existsWorkspace` reports it free, so without + // this the wizard walks the user to the last step before the create fails. + it('refuses `global`', () => { + expect(validateWorkspaceId('global')).toMatch(/not allowed/i) + }) + + it('refuses it as the effective id too', () => { + expect(validateWorkspaceId('wm-fork-x', 'global')).toMatch(/not allowed/i) + }) + + it('allows a fork named `global`, which reaches the backend as `wm-fork-global`', () => { + expect(validateWorkspaceId('global', 'wm-fork-global')).toBeUndefined() + }) + + it('still accepts ids that merely contain it', () => { + expect(validateWorkspaceId('global-ops')).toBeUndefined() + expect(validateWorkspaceId('my-global')).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/utils/workspaceId.ts b/frontend/src/lib/utils/workspaceId.ts new file mode 100644 index 0000000000..492a00d391 --- /dev/null +++ b/frontend/src/lib/utils/workspaceId.ts @@ -0,0 +1,53 @@ +/** + * What the backend accepts as a workspace id, in one place. Every screen that lets + * someone name a workspace validates against this — a laxer copy elsewhere only + * moves the rejection from the form to the create call, after the user has + * finished the whole flow. + */ + +/** Letters, digits and underscores in dash-separated groups: no leading, trailing or doubled dash. */ +export const WORKSPACE_ID_RE = /^\w+(-\w+)*$/ +/** The DB column and the git branch name derived from it both stop here. */ +export const WORKSPACE_ID_MAX_LENGTH = 50 + +/** `validate_workspace_name` (windmill-common/src/workspaces.rs:246) refuses a longer name. */ +export const WORKSPACE_NAME_MAX_LENGTH = 50 + +/** `check_w_id_conflict` (windmill-api-workspaces/src/workspaces.rs:5111) rejects this id. */ +const RESERVED_WORKSPACE_ID = 'global' + +/** + * The reason `id` is not a usable workspace id, or undefined when it is. + * + * `effectiveId` is what actually reaches the backend: a fork's id is submitted + * with a `wm-fork-` prefix, so the length limit applies to the prefixed form while + * the character rule still applies to what the user typed. + */ +export function validateWorkspaceId(id: string, effectiveId: string = id): string | undefined { + if (!WORKSPACE_ID_RE.test(id)) { + return 'ID can only contain letters, numbers and dashes and must not finish by a dash' + } + // `check_w_id_conflict` refuses it outright, and `existsWorkspace` reports it free — + // so without this the wizard walks the user to the last step before the create fails. + // Only the effective id, which is what the backend receives: it defaults to the raw one, + // so a plain `global` is still caught, while a fork named `global` — submitted as + // `wm-fork-global`, which the backend accepts — is not. + if (effectiveId === RESERVED_WORKSPACE_ID) { + return `'${RESERVED_WORKSPACE_ID}' is not allowed as a workspace ID` + } + if (effectiveId.length > WORKSPACE_ID_MAX_LENGTH) { + return `ID '${effectiveId}' is too long (${effectiveId.length} chars). Maximum is ${WORKSPACE_ID_MAX_LENGTH}.` + } + return undefined +} + +/** Slugifies free text into something `validateWorkspaceId` accepts, for a prefill. */ +export function toWorkspaceId(raw: string): string { + return raw + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, WORKSPACE_ID_MAX_LENGTH) + .replace(/-+$/, '') +} diff --git a/frontend/src/lib/workspaceCreation.ts b/frontend/src/lib/workspaceCreation.ts new file mode 100644 index 0000000000..49e4bdbea3 --- /dev/null +++ b/frontend/src/lib/workspaceCreation.ts @@ -0,0 +1,80 @@ +/** + * The instance policy around creating a workspace, and what the client has to do + * once one exists. Shared by every screen that creates workspaces — the workspace + * settings creator and the hub import wizard — so the two cannot drift apart on + * who is allowed to create, what username the backend expects, or what state the + * client is left in afterwards. + * + * Id validation lives next door in `$lib/utils/workspaceId`, which has no imports + * and so can be used from anywhere. + */ + +import { SettingService, UserService, WorkspaceService } from '$lib/gen' +import { usersWorkspaceStore } from '$lib/stores' +import { switchWorkspace } from '$lib/storeUtils' +import { isCloudHosted } from '$lib/cloud' +import { base } from '$lib/base' + +/** + * Whether this user may create a workspace at all. Self-hosted instances default + * `CREATE_WORKSPACE_REQUIRE_SUPERADMIN` to true, so offering the choice to + * everyone ends in a 403 at the last step. + * + * Superadmin arrives asynchronously in most callers, so pass the current value and + * call again when it flips. When the gate is on, the server is asked directly + * rather than trusting that value: `refreshSuperadmin` skips its fetch once the + * store holds anything, so a page loaded logged out leaves it `false` for the rest + * of the session — including right after signing in. + */ +export async function canCreateWorkspace(isSuperadmin: boolean): Promise { + if (isSuperadmin || isCloudHosted()) return true + try { + const r = await fetch(base + '/api/workspaces/create_workspace_require_superadmin') + if ((await r.text()) != 'true') return true + return !!(await UserService.globalWhoami()).super_admin + } catch { + return false + } +} + +export interface UsernamePolicy { + /** When true the backend derives the username and rejects one sent explicitly. */ + automate: boolean + /** A username to prefill with, when the caller has to ask for one. */ + suggested?: string +} + +/** + * `createWorkspace` rejects a username when the instance automates them and + * requires one when it does not, so the field only exists in the second case. + */ +export async function loadUsernamePolicy(): Promise { + const automate = + ((await SettingService.getGlobal({ + key: 'automate_username_creation' + })) as boolean | null) ?? true + if (automate) return { automate: true } + try { + const me = await UserService.globalWhoami() + const from = me.name ? me.name.split(' ')[0] : me.email.split('@')[0] + return { automate: false, suggested: from.replace(/\./g, '').toLowerCase() } + } catch { + return { automate: false } + } +} + +/** + * Re-reads the workspaces this user belongs to. Anything that creates or deletes a + * workspace owes the client this call: `usersWorkspaceStore` is what the picker and + * every derived workspace list read from, and nothing else refreshes it until a + * full page load. + */ +export async function refreshWorkspaceList(): Promise { + usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) +} + +/** Enters a workspace that was just created, with the list refreshed to match. */ +export async function enterNewWorkspace(id: string): Promise { + await refreshWorkspaceList() + switchWorkspace(id) +} diff --git a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte index 96f0c4f2f6..6d2a0a5ca4 100644 --- a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte @@ -732,9 +732,6 @@ rightTooltip: 'Fill args from JSON' }} lightMode - on:change={(e) => { - runForm?.setCode(JSON.stringify(args ?? {}, null, '\t')) - }} /> {/if}
    @@ -817,7 +814,7 @@ const nargs = JSON.parse(JSON.stringify(e.detail)) args = nargs if (jsonView) { - runForm?.setCode(JSON.stringify(args ?? {}, null, '\t')) + runForm?.syncJsonEditor() } }} /> diff --git a/frontend/src/routes/(root)/(logged)/projects/import/+page@(root).svelte b/frontend/src/routes/(root)/(logged)/projects/import/+page@(root).svelte new file mode 100644 index 0000000000..a1bc2ab970 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/projects/import/+page@(root).svelte @@ -0,0 +1,550 @@ + + +{#if leaving} + +
    + + Taking you to {planWorkspaceId(plan) ?? 'your workspace'}… +
    +
    +{:else if !slug} + +

    + This page needs a ?hub=<slug> to know which project to import. + Open it from a project on the hub. +

    +
    +{:else} + + + + {#snippet subtitleSnippet()} + + Connected as {$usersWorkspaceStore?.email} + {#if step === 1} + · + + Switch account + + {/if} + + {/snippet} + + + + {#if step === 1} +
    + {#if project} + + {:else if projectError} +

    + Could not read {slug} from the hub. You can still choose a + destination — the project is fetched again before it is imported. +

    + {:else} +
    + Loading {slug}… +
    + {/if} + +
    +

    Where should it go?

    + + +
    + {#if canCreate} + (chosen = 'new')} + > + {#snippet icon()} + + {/snippet} + {#snippet description()} + Creates {name || slug} and imports + {itemsLabel} into it. + {/snippet} + + {/if} + + (chosen = 'existing')} + > + {#snippet icon()} + + {/snippet} + +
    + +
    + +
    +
    +
    + {:else if step === 2} +
    + {#if !choiceIsExisting} +
    +

    Name the new workspace

    +
    + +
    + + +
    + + {#if !automateUsername} + + {/if} + {:else} +
    +

    Pick a workspace

    +

    The project is imported into this one.

    +
    + + {#if workspaceList.loading} +
    + Loading your workspaces… +
    + {:else if workspaceList.error} +

    + Could not list your workspaces. Reload the page, or go back and create a new one. +

    + {:else if workspaces.length === 0} +

    + You are not a member of any workspace yet. Go back and create one, or ask an admin to + invite you. +

    + {:else} + + {#if workspaces.length > 1} +
    +
    + + +
    + {#if hasForks} + + {/if} +
    + {/if} + + + + {/if} + {/if} + +
    + + {#if !choiceIsExisting} + + {/if} +
    +
    + {:else if step === 3} + go({ folder }, 3, { replace: true })} + onFinish={() => + setupNeeded + ? // Replaces rather than pushes: after a reload on step 4 the run is gone, and + // a step-3 entry in history is a browser-Back route to the same fresh import + // the stepper is now blocked from reaching. + go({}, 4, { replace: true }) + : finish()} + onBack={() => go({}, 2)} + onExecution={(e) => (execution = e)} + resume={execution} + /> + {:else} + go({}, 3) : undefined} + /> + {/if} +
    +{/if} diff --git a/frontend/src/routes/(root)/(logged)/projects/install/+page.svelte b/frontend/src/routes/(root)/(logged)/projects/install/+page.svelte deleted file mode 100644 index 06f2344356..0000000000 --- a/frontend/src/routes/(root)/(logged)/projects/install/+page.svelte +++ /dev/null @@ -1,374 +0,0 @@ - - -
    - {#if !slug} -

    Missing ?hub=<slug>.

    - {:else if loading} -
    - Loading project… -
    - {:else if loadError} -

    Failed to load project: {loadError}

    - {:else if data} -

    Add “{data.project.name}” to workspace

    -

    {data.project.summary}

    - -
    -

    - Folder in {workspace} -

    - -

    - Items import under f/{folderName.trim() || data.project.slug}/. -

    -
    - -
    - {counts?.scripts} scripts - {counts?.flows} flows - {counts?.apps} apps - {counts?.resources} resources - {counts?.triggers} triggers - {#if counts && counts.migrations > 0} - {counts.migrations} data table migrations - {/if} -
    - -
    - Resources are imported as empty stubs — set their values after import; a resource whose path - already exists is reported as failed (existing values are never overwritten). Trigger kinds - are recreated disabled, except GCP and Azure triggers, which manage cloud subscriptions at - creation and must be re-created manually after filling their resource. Kafka, NATS, SQS, GCP - and Azure triggers all require Enterprise. Triggers that reference a resource depend on stubs - imported empty, so fill in the resource value before re-enabling the trigger. -
    - -
    - - {#if done} - - {/if} -
    - - {#if results.length} -
      - {#each results as r} -
    • - {r.ok ? '✓' : '✗'} - {r.path} - {#if !r.ok}— {r.error}{/if} -
    • - {/each} -
    - {/if} - {/if} -
    - - - - - - closeMigrationReview(false)}> - closeMigrationReview(false)}> -
    -

    - This project ships migrations that recreate the data tables it uses. Review and edit the - SQL, then choose which to run. A migration runs against the data table of the same name in - {workspace}; if that data table has migrations enabled it is - recorded, otherwise it runs once as a preview job. -

    - {#each reviewList as m (m.datatable_name)} -
    -
    - {m.datatable_name} - -
    - {#if m.run} - - {/if} -
    - {/each} -
    - {#snippet actions()} - - - {/snippet} -
    -
    diff --git a/frontend/src/routes/(root)/(logged)/projects/install/+page.ts b/frontend/src/routes/(root)/(logged)/projects/install/+page.ts new file mode 100644 index 0000000000..718b624bd4 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/projects/install/+page.ts @@ -0,0 +1,13 @@ +import { redirect } from '@sveltejs/kit' +import { base } from '$app/paths' +import type { PageLoad } from './$types' + +/** + * `/projects/install?hub=` was where the hub's "Add to workspace" button + * pointed before the import wizard existed. Hubs upgrade on their own schedule — + * a self-hosted one may keep sending people here for a long time — so the old + * entry point forwards to the wizard rather than 404ing, query string intact. + */ +export const load: PageLoad = ({ url }) => { + redirect(307, `${base}/projects/import${url.search}`) +} 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 6b3b0e5c1f..f0023ddb49 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte @@ -388,7 +388,7 @@ if (!held || !current || block?.label !== 'retry' || block['dbt_retry_job']) return args = { ...current, command: { ...block, dbt_retry_job: held } } if (jsonView) { - runForm?.setCode(JSON.stringify(args, null, '\t')) + runForm?.syncJsonEditor() } }) .catch(() => {}) @@ -430,7 +430,7 @@ } } if (jsonView) { - runForm?.setCode(JSON.stringify(args, null, '\t')) + runForm?.syncJsonEditor() } } @@ -934,9 +934,6 @@ rightTooltip: 'Fill args from JSON' }} lightMode - on:change={(e) => { - runForm?.setCode(JSON.stringify(args ?? {}, null, '\t')) - }} /> {/if}
    @@ -1051,7 +1048,7 @@ const nargs = JSON.parse(JSON.stringify(e.detail)) args = nargs if (jsonView) { - runForm?.setCode(JSON.stringify(args ?? {}, null, '\t')) + runForm?.syncJsonEditor() } }} /> diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index b6f5029616..fbf045fe19 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -1,5 +1,5 @@ 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 c4d2168d26..ed9bf2b11e 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte @@ -26,6 +26,7 @@ import { switchWorkspace } from '$lib/storeUtils' import { GitFork, Settings, User, Search, ChevronsDownUp, ChevronsUpDown } from 'lucide-svelte' import { isCloudHosted } from '$lib/cloud' + import { canCreateWorkspace } from '$lib/workspaceCreation' import AnimatedButton from '$lib/components/common/button/AnimatedButton.svelte' import { emptyString } from '$lib/utils' import { getUserExt } from '$lib/user' @@ -104,9 +105,7 @@ let onlyAdminsWorkspace = $derived(allWorkspaces.length === 1 && allWorkspaces[0].id === 'admins') async function getCreateWorkspaceRequireSuperadmin() { - const r = await fetch(base + '/api/workspaces/create_workspace_require_superadmin') - const t = await r.text() - createWorkspace = t != 'true' + createWorkspace = await canCreateWorkspace(false) } let createWorkspace = $state($superadmin || isCloudHosted()) diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index 782da1bb2c..8dd2937273 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -41,7 +41,7 @@ import { sendUserToast } from '$lib/toast' import { clone, emptyString, encodeState, hasUnsavedChanges } from '$lib/utils' import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' - import { Slack, Target } from 'lucide-svelte' + import { ExternalLink, Slack, Target } from 'lucide-svelte' import SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte' import PremiumInfo from '$lib/components/settings/PremiumInfo.svelte' @@ -1472,9 +1472,30 @@ This workspace is a fork of {currentWorkspace.parent_workspace_id}. It runs on the parent's plan and its executions count toward the parent's usage and bill, - so there is no separate subscription here. Manage billing, seats, and quotas from - the parent workspace's settings. + so it is never invoiced separately. Manage billing, seats, and quotas from the + parent workspace's settings. + {#if plan} +
    + + It is on a paid plan that is billed on its own, so this workspace is paid for + twice. Cancel that subscription in the customer portal to keep only + {currentWorkspace.parent_workspace_id}'s plan. This workspace keeps + running either way, on the parent's plan. + {#if customer_id} +
    + +
    + {/if} +
    +
    + {/if} {:else} {/if} diff --git a/frontend/src/routes/(root)/+layout.svelte b/frontend/src/routes/(root)/+layout.svelte index 1f7a813eac..0e404afbcc 100644 --- a/frontend/src/routes/(root)/+layout.svelte +++ b/frontend/src/routes/(root)/+layout.svelte @@ -1,4 +1,5 @@